server/lib/src/sandbox/tests.rs
//! The server's half of the scan sandbox, without a container engine: the
//! wire's checks, fail-closed refusals, the same verdicts in-process as before,
//! and the image CVE list. The container arms are `tests/scan_sandbox_live.rs`.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use holger_sandbox::{Engine, Output, Refusal, Sandbox, SandboxConfig};
use super::wire::{Job, Report, REPORT_SCHEMA};
use super::*;
use crate::scanner::{ScanSubject, ScanVerdict, Scanner, ScannerKind};
fn template() -> JobTemplate {
JobTemplate {
format_name: "raw".into(),
inspect: holger_inspect::InspectConfig::default(),
kinds: vec![
ScannerKind::Inspection,
ScannerKind::Model,
ScannerKind::Malware,
ScannerKind::License,
ScannerKind::Sbom,
],
license: Default::default(),
allow_pickle: Vec::new(),
secrets: None,
}
}
fn off() -> Arc<ScanWorker> {
Arc::new(ScanWorker::new(SandboxRule::Off, None, template()).unwrap())
}
/// What a [`Scripted`] engine answers a `run` with.
type Script = Box<dyn FnMut(&[String]) -> Result<Output, Refusal> + Send>;
/// An engine that answers what a test scripts, and counts its runs.
struct Scripted {
answer: Mutex<Script>,
runs: Arc<AtomicUsize>,
}
impl Engine for Scripted {
fn ready(&self, _: &SandboxConfig) -> Result<(), Refusal> {
Ok(())
}
fn run(&self, _: &SandboxConfig, _: &str, args: &[String]) -> Result<Output, Refusal> {
self.runs.fetch_add(1, Ordering::SeqCst);
(self.answer.lock().unwrap())(args)
}
}
struct Absent;
impl Engine for Absent {
fn ready(&self, _: &SandboxConfig) -> Result<(), Refusal> {
Err(Refusal::NoEngine("no podman on this host".into()))
}
fn run(&self, _: &SandboxConfig, _: &str, _: &[String]) -> Result<Output, Refusal> {
unreachable!("an absent engine is never run")
}
}
fn contained(engine: impl Engine + 'static) -> Arc<ScanWorker> {
let sb = Sandbox::with_engine(SandboxConfig::default(), Box::new(engine)).unwrap();
Arc::new(ScanWorker::with_sandbox(sb, template()).unwrap())
}
/// The scan job's input directory, read back from the `run` arguments (`/in/i`).
fn input_of(args: &[String]) -> std::path::PathBuf {
let v = args
.iter()
.find(|a| a.ends_with(":/in/i:ro"))
.expect("the job's input at /in/i");
std::path::PathBuf::from(
v.trim_start_matches("--volume=")
.trim_end_matches(":/in/i:ro"),
)
}
/// The scan document the server wrote.
fn job_of(args: &[String]) -> Job {
serde_json::from_slice(&std::fs::read(input_of(args).join(SCAN_FILE)).unwrap()).unwrap()
}
fn artifact_of(args: &[String]) -> Vec<u8> {
std::fs::read(input_of(args).join(ARTIFACT_FILE)).unwrap()
}
/// What a worker answers: `report` written to the job's `/out`, the answer on stdout.
fn answer(args: &[String], report: &[u8]) -> Result<Output, Refusal> {
let out = std::path::PathBuf::from(holger_sandbox::out_of(args).expect("the job's /out"));
std::fs::write(out.join(REPORT_FILE), report).unwrap();
let a = holger_job_runner::wire::Answer::accepted(vec![REPORT_FILE.into()]);
Ok(Output {
report: serde_json::to_vec(&a).unwrap(),
elapsed: Default::default(),
})
}
/// A gzip stream of 64 MiB of zeros, as a source distribution.
fn gzip_bomb() -> 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..64 {
e.write_all(&zeros).unwrap();
}
e.finish().unwrap()
}
#[test]
fn sandbox_off_is_a_test_build_only_and_required_is_the_default() {
assert_eq!(IntakeRules::default().sandbox, SandboxRule::Required);
let r: IntakeRules = ron::from_str("(sandbox: off)").unwrap();
assert_eq!(r.sandbox, SandboxRule::Off);
assert!(ron::from_str::<IntakeRules>("(sandbox: maybe)").is_err());
assert_eq!(
check_in(SandboxRule::Off, false).unwrap_err().code().code,
"holger-sandbox-6"
);
check_in(SandboxRule::Required, false).unwrap();
check_in(SandboxRule::Off, true).unwrap();
}
#[test]
fn no_engine_refuses_every_scan_by_name_and_nothing_runs_in_process() {
let w = contained(Absent);
let s = ScanSubject::new("demo-1.0-py3-none-any.whl", "pip", b"PK\x03\x04");
let f = w.inspect(&s);
assert_eq!(f.verdict(), holger_inspect::Verdict::Fail);
assert!(
f.findings[0].detail.contains("[holger-sandbox-1]"),
"{:?}",
f.findings
);
assert!(
f.findings[0].detail.contains("refuses to scan"),
"{:?}",
f.findings
);
for s2 in WorkerScanner::all(&w) {
let o = s2.scan(&s).unwrap();
assert_eq!(o.verdict, ScanVerdict::Block, "{} {o:?}", s2.id());
assert_eq!(o.findings[0].id, "sandbox-no-engine");
}
// `Absent::run` is unreachable: the engine was asked once whether it is
// there (the answer is remembered) and never asked to run.
}
#[test]
fn a_planted_zip_bomb_is_refused_in_the_worker_exactly_as_before() {
let bomb = gzip_bomb();
let before = holger_inspect::Inspector::default().inspect(
&bomb,
holger_inspect::Format::Sdist,
"demo-1.0.tar.gz",
);
let w = Arc::new(
ScanWorker::new(
SandboxRule::Off,
None,
JobTemplate {
format_name: "pip".into(),
..template()
},
)
.unwrap(),
);
let after = w.inspect(&ScanSubject::new("demo-1.0.tar.gz", "pip", &bomb));
assert_eq!(
after.verdict(),
holger_inspect::Verdict::Fail,
"{:?}",
after.findings
);
assert!(
after.has(
holger_inspect::FindingKind::ZipBomb,
holger_inspect::Severity::Block
),
"{:?}",
after.findings
);
assert_eq!(
after.findings, before.findings,
"the worker reaches the server's own verdict"
);
}
#[test]
fn ingest_and_scanned_share_one_worker_run() {
let w = off();
let s = ScanSubject::new("notes.txt", "raw", b"hello").with_repo("incoming");
w.inspect(&s);
for p in WorkerScanner::all(&w) {
p.scan(&s).unwrap();
}
assert_eq!(w.runs(), 1);
}
#[test]
fn a_timeout_is_a_refusal_by_name() {
let runs = Arc::new(AtomicUsize::new(0));
let w = contained(Scripted {
answer: Mutex::new(Box::new(|_| Err(Refusal::Timeout { secs: 2 }))),
runs: runs.clone(),
});
let f = w.inspect(&ScanSubject::new("x.whl", "pip", b"PK"));
assert_eq!(f.verdict(), holger_inspect::Verdict::Fail);
assert!(
f.findings[0]
.detail
.starts_with("sandbox-timeout: [holger-sandbox-2]"),
"{:?}",
f.findings
);
}
#[test]
fn the_server_trusts_nothing_from_the_worker_beyond_the_checked_report() {
// A hostile worker answers "pass" for other bytes, a kind nobody asked for,
// a control character in a detail, and a hold without a warn.
type Spoil = Box<dyn Fn(&mut serde_json::Value) + Send + Sync>;
let cases: Vec<(&str, Spoil)> = vec![
(
"other bytes",
Box::new(|v| v["sha256"] = "00".repeat(32).into()),
),
(
"an unasked kind",
Box::new(|v| v["records"][0]["kind"] = "secrets".into()),
),
(
"a control character",
Box::new(|v| v["records"][0]["detail"] = "ok\u{1b}[2J".into()),
),
(
"a hold on a pass",
Box::new(|v| v["records"][0]["hold"] = true.into()),
),
(
"an unknown field",
Box::new(|v| v["path_to_open"] = "/etc/shadow".into()),
),
];
for (what, tamper) in cases {
let tamper = Arc::new(tamper);
let t = Arc::clone(&tamper);
let runs = Arc::new(AtomicUsize::new(0));
let w = contained(Scripted {
answer: Mutex::new(Box::new(move |args: &[String]| {
let job = job_of(args);
let bytes = artifact_of(args);
let dir = tempfile::tempdir().unwrap();
let r = worker::run(&job, &bytes, dir.path());
let mut v = serde_json::to_value(&r).unwrap();
t(&mut v);
answer(args, &serde_json::to_vec(&v).unwrap())
})),
runs,
});
let f = w.inspect(&ScanSubject::new("notes.txt", "raw", b"hello"));
assert_eq!(
f.verdict(),
holger_inspect::Verdict::Fail,
"{what}: {:?}",
f.findings
);
assert!(
f.findings[0].detail.contains("[holger-sandbox-5]"),
"{what}: {:?}",
f.findings
);
}
}
#[test]
fn an_honest_worker_report_is_used_as_is() {
let runs = Arc::new(AtomicUsize::new(0));
let w = contained(Scripted {
answer: Mutex::new(Box::new(|args: &[String]| {
let job = job_of(args);
assert_eq!(job.schema, super::wire::JOB_SCHEMA);
let dir = tempfile::tempdir().unwrap();
let r = worker::run(&job, &artifact_of(args), dir.path());
answer(args, &serde_json::to_vec(&r).unwrap())
})),
runs: runs.clone(),
});
let png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR";
let s = ScanSubject::new("demo-1.0-py3-none-any.whl", "pip", png);
let insp = WorkerScanner::all(&w)
.into_iter()
.find(|s| s.kind() == ScannerKind::Inspection)
.unwrap();
let o = insp.scan(&s).unwrap();
assert_eq!(o.verdict, ScanVerdict::Block);
assert_eq!(o.findings[0].id, "mime-mismatch");
assert_eq!(runs.load(Ordering::SeqCst), 1);
}
/// **The worker names the package a raw tarball is** (lane HOL-FIELD-2 P16): its own
/// manifest (`package/package.json`), so the vulnerability slot matches it as npm, never as raw.
#[test]
fn the_worker_names_the_package_inside_a_raw_tarball() {
let mut b = tar::Builder::new(Vec::new());
let json = br#"{"name":"lodash","version":"4.17.15"}"#;
let mut h = tar::Header::new_gnu();
h.set_path("package/package.json").unwrap();
h.set_size(json.len() as u64);
h.set_mode(0o644);
h.set_cksum();
b.append(&h, &json[..]).unwrap();
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
std::io::Write::write_all(&mut gz, &b.into_inner().unwrap()).unwrap();
let bytes = gz.finish().unwrap();
let job = Job {
schema: super::wire::JOB_SCHEMA.into(),
sha256: crate::attest::ArtifactDigest::of_sha256(&bytes)
.hex()
.to_string(),
file_name: "payload.dat".into(),
ecosystem: "raw".into(),
name: None,
version: None,
repo: None,
source: None,
format_name: "raw".into(),
inspect: Default::default(),
kinds: vec![],
license: Default::default(),
allow_pickle: vec![],
provenance: None,
secrets: None,
};
let dir = tempfile::tempdir().unwrap();
let r = worker::run(&job, &bytes, dir.path());
assert!(
r.packages
.iter()
.any(|p| p.ecosystem == "npm" && p.name == "lodash" && p.version == "4.17.15"),
"{:?}",
r.packages
);
let back = Report::parse(&serde_json::to_vec(&r).unwrap(), &job).unwrap();
assert_eq!(back.packages, r.packages, "the wire takes it");
}
#[test]
fn a_report_is_capped_at_parse_by_the_wire() {
let job = Job {
schema: super::wire::JOB_SCHEMA.into(),
sha256: "ab".repeat(32),
file_name: "x".into(),
ecosystem: "raw".into(),
name: None,
version: None,
repo: None,
source: None,
format_name: "raw".into(),
inspect: Default::default(),
kinds: vec![],
license: Default::default(),
allow_pickle: vec![],
provenance: None,
secrets: None,
};
assert!(Report::parse(b"not json", &job)
.unwrap_err()
.contains(REPORT_SCHEMA));
}
/// **The index row rides in the report, checked like the rest** (lane HOL-PERF-2): an
/// honest row reads back as sent; a row out of its bounds, or of the old report schema,
/// refuses the whole report (`holger-sandbox-5` at the caller), never a silent drop.
#[test]
fn the_index_row_rides_in_the_report_and_is_checked_at_parse() {
let bytes = b"hello";
let job = Job {
schema: super::wire::JOB_SCHEMA.into(),
sha256: crate::attest::ArtifactDigest::of_sha256(bytes)
.hex()
.to_string(),
file_name: "notes.txt".into(),
ecosystem: "raw".into(),
name: None,
version: None,
repo: None,
source: None,
format_name: "raw".into(),
inspect: Default::default(),
kinds: vec![],
license: Default::default(),
allow_pickle: vec![],
provenance: None,
secrets: None,
};
assert_eq!(
REPORT_SCHEMA, "holger.scan-worker-report/v2",
"a report with a row is the second report schema"
);
let dir = tempfile::tempdir().unwrap();
let mut r = worker::run(&job, bytes, dir.path());
let honest = holger_index_rows::IndexRow::Deb {
control: "Package: hello\nVersion: 1.0\n".into(),
};
r.index_row = Some(honest.clone());
let back = Report::parse(&serde_json::to_vec(&r).unwrap(), &job).unwrap();
assert_eq!(back.index_row, Some(honest));
r.index_row = Some(holger_index_rows::IndexRow::Deb {
control: "Package: a\u{1b}[2J\n".into(),
});
let e = Report::parse(&serde_json::to_vec(&r).unwrap(), &job).unwrap_err();
assert!(
e.contains("index row") && e.contains("control"),
"the refusal names the row and its field: {e}"
);
r.index_row = None;
let mut v = serde_json::to_value(&r).unwrap();
v["schema"] = "holger.scan-worker-report/v1".into();
assert!(
Report::parse(&serde_json::to_vec(&v).unwrap(), &job).is_err(),
"a v1 report is not this wire"
);
}
/// **The worker answers the index row the server used to parse itself** (lane HOL-PERF-2):
/// the same `.deb` control stanza `znippy-deb` read in-process, through the same wire, in a
/// test build (`sandbox: off`) exactly as in the container.
#[test]
fn the_worker_answers_the_index_row_the_server_used_to_parse() {
let deb = holger_index_rows::deb::tests_support::sample_deb();
let r = off()
.report(&ScanSubject::new("hello_2.10-2_amd64.deb", "deb", &deb))
.unwrap();
let want = holger_index_rows::deb::control_from_deb(&deb).unwrap();
assert_eq!(
r.index_row,
Some(holger_index_rows::IndexRow::Deb { control: want })
);
let none = off()
.report(&ScanSubject::new("notes.txt", "raw", b"hello"))
.unwrap();
assert_eq!(none.index_row, None, "a file with no index has no row");
}
/// **A worker older than this server is refused by name** (lane HOL-PERF-2): an image built
/// before the report schema moved to v2 answers `holger.scan-worker-report/v1`; the refusal
/// says the worker is older than the server and names the command that rebuilds it, never
/// a bare schema mismatch or an exit code.
#[test]
fn a_worker_older_than_this_server_is_refused_by_name_with_the_rebuild_command() {
// The scan program of a worker built before v2: the same scan, its report spelled v1.
let old = |job: &holger_job_runner::wire::WireJob, dirs: &holger_job_runner::wire::Dirs| {
let answer = crate::job_runner::programs::run(job, dirs);
let p = dirs.out.join(REPORT_FILE);
let mut v: serde_json::Value = serde_json::from_slice(&std::fs::read(&p).unwrap()).unwrap();
v.as_object_mut().unwrap().remove("index_row");
v["schema"] = "holger.scan-worker-report/v1".into();
std::fs::write(&p, serde_json::to_vec(&v).unwrap()).unwrap();
answer
};
let runner = Arc::new(holger_job_runner::InProcessRunner::new(Arc::new(old)));
let w = ScanWorker::with_jobs(
Arc::new(crate::job_runner::Jobs::new(runner)),
None,
template(),
)
.unwrap();
let f = w.inspect(&ScanSubject::new("notes.txt", "raw", b"hello"));
assert_eq!(f.verdict(), holger_inspect::Verdict::Fail);
let d = &f.findings[0].detail;
assert!(
d.contains("[holger-sandbox-5]") && d.contains("older than this server"),
"{d}"
);
assert!(
d.contains("cargo xtask scan-worker image") && d.contains("cargo xtask scan-worker stage"),
"the refusal names the rebuild: {d}"
);
}
// ── the image CVE scanner ───────────────────────────────────────────────────
fn tar_of(files: &[(&str, &[u8])]) -> Vec<u8> {
let mut b = tar::Builder::new(Vec::new());
for (p, d) in files {
// `x:<path>` is an executable file.
let (p, mode) = match p.strip_prefix("x:") {
Some(p) => (p, 0o755),
None => (*p, 0o644),
};
let mut h = tar::Header::new_ustar();
h.set_size(d.len() as u64);
h.set_mode(mode);
h.set_entry_type(tar::EntryType::Regular);
b.append_data(&mut h, p, *d).unwrap();
}
b.into_inner().unwrap()
}
fn sha(b: &[u8]) -> String {
crate::attest::ArtifactDigest::of_sha256(b)
.hex()
.to_string()
}
/// An image layout with one gzip layer holding `files`.
fn image(files: &[(&str, &[u8])]) -> Vec<u8> {
use std::io::Write as _;
let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
e.write_all(&tar_of(files)).unwrap();
let layer = e.finish().unwrap();
let manifest = serde_json::to_vec(&serde_json::json!({"schemaVersion": 2, "layers": [
{"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": format!("sha256:{}", sha(&layer))}]}))
.unwrap();
let index = serde_json::to_vec(
&serde_json::json!({"manifests": [{"digest": format!("sha256:{}", sha(&manifest))}]}),
)
.unwrap();
tar_of(&[
("index.json", &index),
(&format!("blobs/sha256/{}", sha(&manifest)), &manifest),
(&format!("blobs/sha256/{}", sha(&layer)), &layer),
])
}
fn advisories() -> Arc<dyn Scanner> {
use crate::security::{Advisory, AdvisoryDb, Severity};
Arc::new(crate::scanner::osv::OsvVulnScanner::new(AdvisoryDb::new(
"test-db",
vec![
Advisory::basic(
"CGA-test-1",
"wolfi",
"openssl",
vec!["3.0.13-r2".into()],
Severity::Critical,
"a test CVE",
),
Advisory::basic(
"CGA-test-2",
"wolfi",
"zlib",
vec!["1.3-r0".into()],
Severity::Low,
"another",
),
],
)))
}
#[test]
fn an_image_gets_a_cve_list_with_severities_from_its_own_package_database() {
let img = image(&[
("etc/os-release", b"ID=wolfi\n"),
(
"usr/lib/apk/db/installed",
b"P:openssl\nV:3.0.13-r2\no:openssl\n\nP:zlib\nV:1.3.1-r0\n",
),
]);
let w = off();
let v = VulnFromWorker::new(advisories(), Arc::clone(&w));
let o = v
.scan(&ScanSubject::new(
"chainguard/demo@sha256_x.oci.tar",
"docker",
&img,
))
.unwrap();
assert_eq!(o.findings.len(), 1, "{o:?}");
assert_eq!(o.findings[0].id, "CGA-test-1");
assert_eq!(
o.findings[0].severity,
crate::scanner::FindingSeverity::Critical
);
assert!(
o.findings[0].summary.contains("wolfi openssl@3.0.13-r2"),
"{o:?}"
);
assert!(
o.detail.contains("2 package(s) from layers (os wolfi)"),
"{}",
o.detail
);
assert!(
o.verdict != ScanVerdict::Error,
"an image's vulnerability scan is a verdict now: {o:?}"
);
}
/// A little-endian ELF64 whose `.dep-v0` section is `data` (cargo-auditable's).
fn elf_with_dep_v0(data: &[u8]) -> Vec<u8> {
let strtab = b"\0.dep-v0\0.shstrtab\0";
let (data_off, str_off) = (64usize, 64 + data.len());
let sh_off = (str_off + strtab.len() + 7) & !7;
let mut b = vec![0u8; sh_off + 3 * 64];
b[..7].copy_from_slice(b"\x7fELF\x02\x01\x01");
b[0x28..0x30].copy_from_slice(&(sh_off as u64).to_le_bytes());
b[0x3a..0x3c].copy_from_slice(&64u16.to_le_bytes());
b[0x3c..0x3e].copy_from_slice(&3u16.to_le_bytes());
b[0x3e..0x40].copy_from_slice(&2u16.to_le_bytes());
b[data_off..str_off].copy_from_slice(data);
b[str_off..str_off + strtab.len()].copy_from_slice(strtab);
for (i, name, off, size) in [
(1usize, 1u32, data_off, data.len()),
(2, 9, str_off, strtab.len()),
] {
let h = sh_off + i * 64;
b[h..h + 4].copy_from_slice(&name.to_le_bytes());
b[h + 0x18..h + 0x20].copy_from_slice(&(off as u64).to_le_bytes());
b[h + 0x20..h + 0x28].copy_from_slice(&(size as u64).to_le_bytes());
}
b
}
/// An ELF carrying Go 1.18+ build info for `go_version` and one dependency.
fn go_elf(go_version: &str, dep: &str, dep_version: &str) -> Vec<u8> {
let mut b = b"\x7fELF\x02\x01\x01".to_vec();
b.resize(64, 0);
b.extend_from_slice(holger_inspect::binmeta::GO_MAGIC);
b.extend_from_slice(&[8, 2]);
b.extend_from_slice(&[0u8; 16]);
let info = format!(
"{}path\tapp\ndep\t{dep}\tv{dep_version}\th1:x=\n{}",
"0".repeat(16),
"1".repeat(16)
);
for s in [go_version, info.as_str()] {
assert!(s.len() < 128);
b.push(s.len() as u8);
b.extend_from_slice(s.as_bytes());
}
b.extend_from_slice(&[0u8; 32]);
b
}
/// rpm, Go and Rust packages of an image reach the advisory match: the rpm
/// database (a real UBI 9 micro `rpmdb.sqlite`), a Go binary's build info and a
/// `cargo auditable` binary's `.dep-v0`, each matched in its own ecosystem.
#[test]
fn an_images_rpm_go_and_cargo_packages_reach_the_vulnerability_match() {
use crate::security::{Advisory, AdvisoryDb, Severity};
use std::io::{Read as _, Write as _};
let gz = include_bytes!("../../../inspect/tests/fixtures/rpm/ubi9-micro-rpmdb.sqlite.gz");
let mut rpmdb = Vec::new();
flate2::read::GzDecoder::new(&gz[..])
.read_to_end(&mut rpmdb)
.unwrap();
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
z.write_all(br#"{"packages":[{"name":"time","version":"0.3.20","source":"crates.io"}]}"#)
.unwrap();
let rust = elf_with_dep_v0(&z.finish().unwrap());
let go = go_elf("go1.22.1", "golang.org/x/net", "0.17.0");
let img = image(&[
("etc/os-release", b"ID=\"rhel\"\n"),
("var/lib/rpm/rpmdb.sqlite", &rpmdb),
("x:usr/bin/goapp", &go),
("x:usr/bin/rustapp", &rust),
]);
let db: Arc<dyn Scanner> = Arc::new(crate::scanner::osv::OsvVulnScanner::new(AdvisoryDb::new(
"test-db",
vec![
Advisory::basic(
"RHSA-test-1",
"rpm",
"glibc",
vec!["2.34-283.el9_8".into()],
Severity::High,
"glibc",
),
Advisory::basic(
"GO-test-1",
"go",
"stdlib",
vec!["1.22.1".into()],
Severity::Medium,
"net/http",
),
Advisory::basic(
"GO-test-2",
"go",
"golang.org/x/net",
vec!["0.17.0".into()],
Severity::High,
"x/net",
),
Advisory::basic(
"RUSTSEC-test-1",
"cargo",
"time",
vec!["0.3.20".into()],
Severity::Low,
"time",
),
],
)));
let o = VulnFromWorker::new(db, off())
.scan(&ScanSubject::new("ubi/demo.oci.tar", "docker", &img))
.unwrap();
let ids: Vec<&str> = o.findings.iter().map(|f| f.id.as_str()).collect();
for want in ["RHSA-test-1", "GO-test-1", "GO-test-2", "RUSTSEC-test-1"] {
assert!(ids.contains(&want), "{want} in {ids:?}: {}", o.detail);
}
let doc = o.document.as_ref().unwrap();
let pkgs = doc["packages"].as_array().unwrap();
for src in ["rpmdb-sqlite", "go-buildinfo", "cargo-auditable"] {
assert!(pkgs.iter().any(|p| p["source"] == src), "{src}: {doc}");
}
assert!(
o.detail.contains("23 package(s)"),
"20 rpms, stdlib, x/net and time: {}",
o.detail
);
}
/// HOL-OCI-EVIDENCE item 1: **an image's CycloneDX SBOM is made in the scan worker**, from
/// its layers (the OS package database and the language manifests), never in the server.
/// The scripted engine runs the worker's own code, as the container does; the server's
/// SBOM slot holds only a proxy that reads the worker's record. With no engine, the slot
/// is a refusal and carries no document: the server never builds one itself.
#[test]
fn an_images_cyclonedx_sbom_is_made_in_the_scan_worker_from_its_layers() {
let img = image(&[
("etc/os-release", b"ID=wolfi\n"),
(
"usr/lib/apk/db/installed",
b"P:openssl\nV:3.0.13-r2\no:openssl\n\nP:libcrypto3\nV:3.0.13-r2\no:openssl\n",
),
(
"usr/lib/python3.12/site-packages/idna-3.7.dist-info/METADATA",
b"Metadata-Version: 2.1\nName: idna\nVersion: 3.7\n\n",
),
(
"app/node_modules/@scope/x/package.json",
br#"{"name":"@scope/x","version":"1.2.3"}"#,
),
(
"x:usr/bin/goapp",
&go_elf("go1.22.1", "golang.org/x/net", "0.17.0"),
),
]);
let runs = Arc::new(AtomicUsize::new(0));
let w = contained(Scripted {
answer: Mutex::new(Box::new(|args: &[String]| {
let dir = tempfile::tempdir().unwrap();
let r = worker::run(&job_of(args), &artifact_of(args), dir.path());
answer(args, &serde_json::to_vec(&r).unwrap())
})),
runs: runs.clone(),
});
let subject = ScanSubject::new("demo/app/sha256-x.oci.tar", "docker", &img);
let all = WorkerScanner::all(&w);
let sbom = all
.iter()
.find(|s| s.id() == "holger-image-sbom")
.expect("the image SBOM scanner fills the SBOM slot, in the worker");
assert_eq!(sbom.kind(), ScannerKind::Sbom);
let o = sbom.scan(&subject).unwrap();
assert_eq!(
runs.load(Ordering::SeqCst),
1,
"made by the worker's one run"
);
assert_eq!(o.verdict, ScanVerdict::Pass, "{o:?}");
let doc = o.document.clone().expect("a CycloneDX document");
assert_eq!(doc["bomFormat"], "CycloneDX", "{doc}");
assert_eq!(doc["specVersion"], "1.5", "{doc}");
assert!(
doc["serialNumber"]
.as_str()
.is_some_and(|s| s.starts_with("urn:uuid:")),
"{doc}"
);
let top = doc["metadata"]["component"].clone();
assert_eq!(top["type"], "container", "{doc}");
assert!(
top["bom-ref"]
.as_str()
.is_some_and(|r| r.starts_with("sha256:")),
"the image digest: {doc}"
);
assert_eq!(top["hashes"][0]["alg"], "SHA-256", "{doc}");
let purls: Vec<String> = doc["components"]
.as_array()
.unwrap()
.iter()
.filter_map(|c| c["purl"].as_str().map(str::to_string))
.collect();
for want in [
"pkg:apk/wolfi/openssl@3.0.13-r2?distro=wolfi",
"pkg:apk/wolfi/libcrypto3@3.0.13-r2?distro=wolfi",
"pkg:pypi/idna@3.7",
"pkg:npm/%40scope/x@1.2.3",
"pkg:golang/golang.org/x/net@v0.17.0",
"pkg:golang/stdlib@v1.22.1",
] {
assert!(purls.iter().any(|p| p == want), "{want} not in {purls:?}");
}
let os = doc["components"]
.as_array()
.unwrap()
.iter()
.find(|c| c["type"] == "operating-system")
.cloned()
.unwrap_or_default();
assert_eq!(os["name"], "wolfi", "{doc}");
// Each package names where in the image it was read.
let openssl = doc["components"]
.as_array()
.unwrap()
.iter()
.find(|c| c["name"] == "openssl")
.unwrap()
.clone();
assert!(
openssl["properties"]
.as_array()
.unwrap()
.iter()
.any(|p| p["name"] == "holger:source" && p["value"] == "apk-db"),
"{openssl}"
);
// Byte-stable: the same image folds to the same document.
let again = WorkerScanner::all(&off())
.into_iter()
.find(|s| s.id() == "holger-image-sbom")
.unwrap()
.scan(&subject)
.unwrap();
assert_eq!(
again.document,
Some(doc.clone()),
"deterministic, in-process (a test build) or in the container alike"
);
// A wheel is not an image: no document from this scanner.
let wheel = sbom
.scan(&ScanSubject::new(
"demo-1.0-py3-none-any.whl",
"pip",
b"PK\x03\x04",
))
.unwrap();
assert!(wheel.document.is_none(), "{wheel:?}");
// No engine: a refusal by name, and no document made here instead.
let none = WorkerScanner::all(&contained(Absent))
.into_iter()
.find(|s| s.id() == "holger-image-sbom")
.unwrap()
.scan(&subject)
.unwrap();
assert_eq!(none.verdict, ScanVerdict::Block);
assert!(
none.document.is_none(),
"the server never builds the SBOM itself: {none:?}"
);
}
#[test]
fn a_clean_image_scans_clean_rather_than_error() {
let img = image(&[
("etc/os-release", b"ID=wolfi\n"),
("lib/apk/db/installed", b"P:tzdata\nV:2026e-r0\n"),
]);
let o = VulnFromWorker::new(advisories(), off())
.scan(&ScanSubject::new("static.oci.tar", "docker", &img))
.unwrap();
assert_eq!(o.verdict, ScanVerdict::Pass, "{o:?}");
assert!(o.findings.is_empty());
}
#[test]
fn the_vulnerability_slot_never_reads_a_wheel_itself() {
// No coordinates anywhere: an error, not a METADATA read in this process.
let o = VulnFromWorker::new(advisories(), off())
.scan(&ScanSubject::new("blob", "raw", b"\0\0"))
.unwrap();
assert_eq!(o.verdict, ScanVerdict::Error, "{o:?}");
}
// ── G3: the pinned APK keys reach verify_artifact inside the worker ──
const PROV_FX: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../provenance/tests/fixtures"
);
fn fx(p: &str) -> Vec<u8> {
let b = std::fs::read(format!("{PROV_FX}/{p}")).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(
!b.starts_with(b"version https://git-lfs"),
"{p} is an LFS pointer: run `git lfs pull`"
);
b
}
#[test]
fn the_worker_verifies_a_wolfi_apk_with_the_apk_keys_it_is_handed() {
let w = off();
let apk = fx("wolfi/stage3/libffi-3.4.2-r0.apk");
let m = vec![(
"APKINDEX.tar.gz".to_string(),
fx("wolfi/stage3/APKINDEX.tar.gz"),
)];
let policy = holger_provenance::effective(
&holger_provenance::ProvenanceFloor {
require_provenance: true,
..Default::default()
},
&holger_provenance::ProvenanceGate::Off,
);
let key = [(
"wolfi-signing.rsa.pub".to_string(),
fx("wolfi/wolfi-signing.rsa.pub"),
)];
let root = fx("sigstore/trusted_root.json");
let r = w
.verify_provenance(&apk, &m, &policy, Some(&root), &key)
.unwrap();
assert_eq!(r.verdict, holger_provenance::Verdict::Pass, "{}", r.detail);
// The keys alone (no Sigstore root) are enough for an APK.
let r = w.verify_provenance(&apk, &m, &policy, None, &key).unwrap();
assert_eq!(r.verdict, holger_provenance::Verdict::Pass, "{}", r.detail);
// No key handed in: refused by name inside the worker.
let r = w
.verify_provenance(&apk, &m, &policy, Some(&root), &[])
.unwrap();
assert_eq!(
r.code.as_deref(),
Some("holger-provenance-4"),
"{}",
r.detail
);
}
// ── T9: the pinned OpenPGP keys reach verify_artifact inside the worker ──
#[test]
fn the_worker_verifies_a_maven_central_asc_with_the_pgp_keys_it_is_handed() {
let w = off();
let pom = fx("maven/guava-33.3.1-jre.pom");
let m = vec![(
"guava-33.3.1-jre.pom.asc".to_string(),
fx("maven/guava-33.3.1-jre.pom.asc"),
)];
let policy = holger_provenance::effective(
&holger_provenance::ProvenanceFloor {
require_provenance: true,
..Default::default()
},
&holger_provenance::ProvenanceGate::Off,
);
let povirk = "povirk-BDB5FA4FE719D787FB3D3197F6D4A1D411E9D1AE.asc";
let ceki = "ceki-60200AC4AE761F1614D6C46766D68DAA073BE985.asc";
let key = |n: &str| (n.to_string(), fx(&format!("maven/{n}")));
let root = fx("sigstore/trusted_root.json");
if !holger_provenance::pgp::BUILT {
// T9b: a worker built without `maven-pgp` refuses the keys by name, never a pass.
let e = w
.verify_provenance_with_keys(&pom, &m, &policy, Some(&root), &[], &[key(povirk)])
.map(|r| r.detail)
.unwrap_or_else(|e| format!("{e:#}"));
assert!(e.contains("[provenance-pgp-not-built]"), "{e}");
let r = w
.verify_provenance(&pom, &m, &policy, Some(&root), &[])
.unwrap();
assert_eq!(
r.code.as_deref(),
Some("holger-provenance-4"),
"{}",
r.detail
);
return;
}
let r = w
.verify_provenance_with_keys(
&pom,
&m,
&policy,
Some(&root),
&[],
&[key(ceki), key(povirk)],
)
.unwrap();
assert_eq!(r.verdict, holger_provenance::Verdict::Pass, "{}", r.detail);
// The pgp keys alone (no Sigstore root) are enough for an .asc.
let r = w
.verify_provenance_with_keys(&pom, &m, &policy, None, &[], &[key(povirk)])
.unwrap();
assert_eq!(r.verdict, holger_provenance::Verdict::Pass, "{}", r.detail);
// The signer's key not handed in: unverified inside the worker, never a pass.
let r = w
.verify_provenance_with_keys(&pom, &m, &policy, Some(&root), &[], &[key(ceki)])
.unwrap();
assert_eq!(
r.code.as_deref(),
Some("holger-provenance-4"),
"{}",
r.detail
);
let r = w
.verify_provenance(&pom, &m, &policy, Some(&root), &[])
.unwrap();
assert_eq!(
r.code.as_deref(),
Some("holger-provenance-4"),
"{}",
r.detail
);
}
// ── every scan is a job (lane HOL-SANDBOX-TRAIT) ────────────────────────────
struct SpyRunner {
inner: Arc<dyn crate::job_runner::heimdall_core::JobRunner>,
argv: Mutex<Vec<Vec<String>>>,
}
impl crate::job_runner::heimdall_core::JobRunner for SpyRunner {
fn run(
&self,
job: crate::job_runner::heimdall_core::Job,
) -> Result<
crate::job_runner::heimdall_core::SignedResult,
crate::job_runner::heimdall_core::RunError,
> {
if let crate::job_runner::heimdall_core::JobKind::Native { argv, .. } = &job.kind {
self.argv.lock().unwrap().push(argv.clone());
}
self.inner.run(job)
}
fn verifying_key(&self) -> crate::job_runner::heimdall_core::VerifyingKey {
self.inner.verifying_key()
}
fn features(&self) -> crate::job_runner::heimdall_core::Features {
self.inner.features()
}
}
#[test]
fn every_scan_is_a_job_of_the_servers_runner() {
let inner = Arc::new(holger_job_runner::InProcessRunner::new(Arc::new(
crate::job_runner::programs::run,
)));
let spy = Arc::new(SpyRunner {
inner,
argv: Mutex::new(Vec::new()),
});
let w = ScanWorker::with_jobs(
Arc::new(crate::job_runner::Jobs::new(spy.clone())),
None,
template(),
)
.unwrap();
let bytes = b"hello from a job\n";
let s = ScanSubject::new("hello.txt", "raw", bytes);
let r = w.report(&s).unwrap();
assert_eq!(
r.sha256,
s.sha256_hex(),
"the worker read the bytes the server wrote"
);
assert_eq!(
*spy.argv.lock().unwrap(),
vec![vec!["scan".to_string()]],
"one scan job"
);
assert_eq!(
r.inspect.verdict(),
off().report(&s).unwrap().inspect.verdict()
);
assert_eq!(w.runs(), 1);
// The INGEST inspection of the same bytes reuses the report: no second job.
let _ = w.inspect(&s);
assert_eq!(spy.argv.lock().unwrap().len(), 1);
}
/// NCSC P1 (lane HOL-NCSC): a real build talks to ClamAV only over the sandboxed clamd's unix
/// socket. TCP, `clamscan`, no socket (the host's defaults) and a host clamd's socket are
/// refused by name; a test build may use any of them.
#[test]
fn a_real_build_talks_only_to_the_sandboxed_clamd_socket() {
use holger_inspect::malware::ClamAvConfig;
let sock = |s: &str| ClamAvConfig {
socket: Some(s.into()),
required: true,
..Default::default()
};
check_clamav(None, false).unwrap();
check_clamav(Some(&sock("/data/holger/sandbox/clamd/clamd.sock")), false).unwrap();
for (bad, why) in [
(sock("127.0.0.1:3310"), "TCP"),
(sock("clamd.local:3310"), "TCP"),
(sock("run/clamd.sock"), "absolute"),
(sock("/run/clamav/clamd.ctl"), "host"),
(
ClamAvConfig {
required: true,
..Default::default()
},
"no socket",
),
(
ClamAvConfig {
socket: Some("/s/clamd.sock".into()),
clamscan: Some("/usr/bin/clamscan".into()),
..Default::default()
},
"clamscan",
),
] {
let e = check_clamav(Some(&bad), false).unwrap_err();
assert_eq!(e.code().code, "holger-sandbox-15", "{bad:?}: {e}");
assert!(e.to_string().contains(why), "{why}: {e}");
check_clamav(Some(&bad), true).unwrap();
}
}
/// A real build's worker refuses such a block at the start, before a scan.
#[test]
fn a_tcp_clamd_refuses_the_worker_in_a_real_build() {
use holger_inspect::malware::ClamAvConfig;
let mut t = template();
t.inspect.clamav = Some(ClamAvConfig {
socket: Some("127.0.0.1:3310".into()),
required: true,
..Default::default()
});
let jobs = Arc::new(crate::job_runner::Jobs::new(Arc::new(
holger_job_runner::InProcessRunner::new(Arc::new(crate::job_runner::programs::run)),
)));
let e = ScanWorker::with_jobs_in(jobs, None, t, false).unwrap_err();
assert_eq!(e.code().code, "holger-sandbox-15", "{e}");
}
/// clamd flags what it could not scan whole (a file past `MaxFileSize`, a nest past
/// `MaxRecursion`) as FOUND, so the intake blocks it, never passes it unscanned.
#[test]
fn the_sandboxed_clamd_alerts_what_exceeds_its_limits() {
assert!(
super::media::CLAMD_CONF
.lines()
.any(|l| l.trim() == "AlertExceedsMax yes"),
"{}",
super::media::CLAMD_CONF
);
}
/// **A worker that is not this server's build is refused by name** (lane HOL-FIELD-TEST P1). In
/// the field, oden's `localhost/holger-scan-worker:0.1.0` was built before `holger-image-sbom`
/// existed: it answered the same report schema without that record, and every artifact a
/// required sbom scan saw was BLOCKED as "the scan worker sent no sbom record from
/// holger-image-sbom". The report names the worker's build (its version and its scanners), and a
/// report that names none, another version, or fewer scanners than this server runs is refused,
/// saying which and how to rebuild.
#[test]
fn a_worker_that_is_not_this_servers_build_is_refused_by_name() {
let bytes = b"hello";
let job = Job {
schema: super::wire::JOB_SCHEMA.into(),
sha256: crate::attest::ArtifactDigest::of_sha256(bytes)
.hex()
.to_string(),
file_name: "notes.txt".into(),
ecosystem: "raw".into(),
name: None,
version: None,
repo: None,
source: None,
format_name: "raw".into(),
inspect: Default::default(),
kinds: vec![ScannerKind::Sbom],
license: Default::default(),
allow_pickle: vec![],
provenance: None,
secrets: None,
};
let dir = tempfile::tempdir().unwrap();
let r = worker::run(&job, bytes, dir.path());
let mut v = serde_json::to_value(&r).unwrap();
// The field's stale worker: the same schema, and no word of its build.
v.as_object_mut().unwrap().remove("worker");
let e = Report::parse(&serde_json::to_vec(&v).unwrap(), &job)
.expect_err("a worker that names no build is refused");
assert!(
e.contains("older than this server") && e.contains("cargo xtask scan-worker image"),
"the refusal says what is wrong and how to rebuild: {e}"
);
// A worker of this version that lacks a scanner this server runs: refused, naming it.
v["worker"] = serde_json::json!({ "version": env!("CARGO_PKG_VERSION"), "scanners": ["holger-wheel-sbom"] });
let e = Report::parse(&serde_json::to_vec(&v).unwrap(), &job)
.expect_err("a worker without the image SBOM generator is refused");
assert!(e.contains("holger-image-sbom"), "{e}");
// Another version: refused, naming both.
let mut other = serde_json::to_value(&r).unwrap();
other["worker"]["version"] = "0.1.0".into();
let e = Report::parse(&serde_json::to_vec(&other).unwrap(), &job)
.expect_err("a worker of another version is refused");
assert!(
e.contains("0.1.0") && e.contains(env!("CARGO_PKG_VERSION")),
"{e}"
);
// This server's own worker passes.
Report::parse(&serde_json::to_vec(&r).unwrap(), &job).expect("this build's worker");
}
// ── the probe door looks into Heimdall's runner (HOL-HEIMDALL-06) ─────────────
/// A runner that says it is Heimdall's, over the in-process one: what the probe door sees
/// when `engine: heimdall` runs the jobs (no sandbox of this crate to look into).
struct HeimdallLike {
inner: Arc<dyn crate::job_runner::heimdall_core::JobRunner>,
argv: Mutex<Vec<Vec<String>>>,
}
impl crate::job_runner::heimdall_core::JobRunner for HeimdallLike {
fn run(
&self,
job: crate::job_runner::heimdall_core::Job,
) -> Result<
crate::job_runner::heimdall_core::SignedResult,
crate::job_runner::heimdall_core::RunError,
> {
if let crate::job_runner::heimdall_core::JobKind::Native { argv, .. } = &job.kind {
self.argv.lock().unwrap().push(argv.clone());
}
self.inner.run(job)
}
fn verifying_key(&self) -> crate::job_runner::heimdall_core::VerifyingKey {
self.inner.verifying_key()
}
fn features(&self) -> crate::job_runner::heimdall_core::Features {
crate::job_runner::heimdall_core::Features {
runner: "heimdall".into(),
..self.inner.features()
}
}
}
/// The worker's `probe` program as the test stands it in: what the box looks like from
/// inside, into `/out/probe.json`, and the scratch fill the job asked for echoed back.
fn probe_stand_in(
job: &holger_job_runner::wire::WireJob,
dirs: &holger_job_runner::wire::Dirs,
) -> holger_job_runner::wire::Answer {
if job.program() != PROBE_PROGRAM {
return crate::job_runner::programs::run(job, dirs);
}
let v = serde_json::json!({
"uid": "65532 65532 65532 65532",
"no_new_privs": "1",
"fill_mib": job.params["fill_mib"],
});
std::fs::write(dirs.out.join(PROBE_FILE), v.to_string()).unwrap();
holger_job_runner::wire::Answer::accepted(vec![PROBE_FILE.into()])
}
#[test]
fn the_probe_door_looks_into_heimdalls_runner() {
let runner = Arc::new(HeimdallLike {
inner: Arc::new(holger_job_runner::InProcessRunner::new(Arc::new(
probe_stand_in,
))),
argv: Mutex::new(Vec::new()),
});
let jobs = Arc::new(crate::job_runner::Jobs::new(runner.clone()));
let w = ScanWorker::with_jobs(jobs, None, template()).unwrap();
assert!(w.is_isolated(), "Heimdall's runner is not this process");
let v = w
.probe()
.expect("the probe runs as a job of Heimdall's runner, not a refusal");
assert_eq!(
*runner.argv.lock().unwrap(),
vec![vec![PROBE_PROGRAM.to_string()]],
"one probe job, the worker's probe program"
);
assert_eq!(v["uid"], "65532 65532 65532 65532", "{v}");
assert_eq!(v["no_new_privs"], "1", "{v}");
assert_eq!(
v["fill_mib"],
holger_sandbox::Limits::default().scratch_mib + 1,
"the probe writes one MiB past the scratch cap: {v}"
);
assert!(
v["engine"].as_str().is_some_and(|e| e.contains("Heimdall")),
"the answer names the engine it looked into: {v}"
);
assert_eq!(v["runs"], 1, "{v}");
assert!(v["elapsed_ms"].is_u64(), "{v}");
}