server/lib/src/sandbox/mod.rs
//! # The scan sandbox in the server (lane SANDBOX-SCAN, owner 2026-10-23)
//!
//! **Every scan that can have an artifact exploded or handled runs in a
//! container.** This module is the server's half: it builds the scan worker's
//! [`wire::Job`], runs it in the [`holger_sandbox::Sandbox`] (or, in a test
//! build only, in-process), validates the [`wire::Report`] and hands each part
//! to the code that used to compute it here:
//!
//! | Who asks | What runs in the worker | What stays here |
//! |---|---|---|
//! | `IntakeChain::ingest`, `land_imported` (`inspect`) | the walk (bombs, nesting, traversal, MIME, executables) and YARA | ClamAV: the bytes are STREAMED to clamd, which runs in its own sandboxed container |
//! | SCANNED: inspection, model, licence, SBOM, YARA | the scanners themselves ([`WorkerScanner`]) | the record, its signature, the store |
//! | SCANNED: vulnerability | the package's coordinates; an image's package list (its verified SBOM attestation, else its layers) | the match against the local advisory DB ([`VulnFromWorker`]): validated strings only |
//! | SCANNED, the airgap import, the pull-through: provenance | `holger_provenance::verify_artifact` (DSSE, Sigstore, DER/X.509, Rekor) | the policy, the verified trust root's bytes, the material's bytes |
//!
//! `promotion.intake.rules: (sandbox: required)` is the default. `off` runs the
//! same worker code in this process and is allowed only in a test build
//! (`cfg(test)` or the `sandbox-off` feature, which no shipped row names); a
//! release build refuses it at boot as `holger-sandbox-6`. With `required` and
//! no engine, every scan is refused as `holger-sandbox-1`: never a silent
//! in-process fallback.
pub mod clamd;
pub mod media;
pub mod wire;
pub mod worker;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use holger_sandbox::{Refusal, Sandbox, SandboxConfig};
use crate::lock::LockOrRecover as _;
use crate::scanner::{
FindingSeverity, ScanFinding, ScanOutcome, ScanSubject, ScanVerdict, Scanner, ScannerKind,
};
use wire::{Job, ProvenanceJob, Report, JOB_SCHEMA};
// The rule and its check live with the job runner (lane HOL-SANDBOX-TRAIT), so a
// downstream (intake without scanners) has them too.
pub use crate::job_runner::{check_in, IntakeRules, SandboxRule, IN_PROCESS_ALLOWED};
/// May this build talk to a TCP clamd, a host clamd or `clamscan`? Only a test build; a real
/// build talks to ClamAV over the sandboxed clamd's unix socket alone (NCSC P1, lane HOL-NCSC).
pub const HOST_CLAMD_ALLOWED: bool = IN_PROCESS_ALLOWED;
/// **Refuse a `clamav:` block a real build may not use**, by name (`holger-sandbox-15`): a TCP
/// socket (`host:port`), `clamscan`, no socket (the host's clamd sockets, then `clamscan`, would
/// be tried), a relative path, or a host clamd's own socket. `host_allowed` (a test build)
/// passes everything.
pub fn check_clamav(
cfg: Option<&holger_inspect::malware::ClamAvConfig>,
host_allowed: bool,
) -> Result<(), Refusal> {
let Some(c) = cfg else { return Ok(()) };
if host_allowed {
// A test build may name a TCP clamd: a dial like any other (HOL-NCSC).
if let Some(s) = c
.socket
.as_deref()
.filter(|s| !s.contains('/') && s.contains(':'))
{
crate::egress::check("clamd over TCP", s)
.map_err(|e| Refusal::ClamdNotSandboxed(e.to_string()))?;
}
return Ok(());
}
let refuse = |why: String| {
Err(Refusal::ClamdNotSandboxed(format!(
"`promotion.intake.inspect.clamav`: {why}"
)))
};
if let Some(bin) = &c.clamscan {
return refuse(format!("`clamscan: {bin:?}` runs ClamAV on the host, outside the sandbox; name the sandboxed clamd's `socket` alone"));
}
let Some(s) = c.socket.as_deref() else {
return refuse("no socket is named: the host's clamd sockets, then `clamscan`, would be tried; name the sandboxed clamd's unix socket (`<data_root>/sandbox/clamd/clamd.sock` on the box, `scripts/clamd-sandbox.sh` elsewhere)".into());
};
if !s.contains('/') && s.contains(':') {
return refuse(format!(
"{s:?} is a TCP clamd; only the sandboxed clamd's unix socket (it has no network)"
));
}
if !s.starts_with('/') {
return refuse(format!("socket {s:?} is not an absolute path"));
}
if holger_inspect::malware::CLAMD_SOCKETS.contains(&s) {
return refuse(format!(
"{s} is a host clamd's own socket, outside the sandbox"
));
}
Ok(())
}
/// What every job of one server carries, whatever the artifact.
#[derive(Debug, Clone, Default)]
pub struct JobTemplate {
/// The intake's incoming format name (`pip`, `raw`, …).
pub format_name: String,
pub inspect: holger_inspect::InspectConfig,
pub kinds: Vec<ScannerKind>,
pub license: crate::scanner::license::LicensePolicy,
pub allow_pickle: Vec<String>,
/// The secrets scanner's rules (`promotion.intake.secrets`), read at boot.
pub secrets: Option<crate::scanner::secrets::SecretRules>,
}
/// The reports kept for reuse: the INGEST inspection and SCANNED of the same
/// bytes and subject share one container run.
const MEMO: usize = 32;
/// **The scan worker of one server.**
pub struct ScanWorker {
/// The server's ONE job runner (lane HOL-SANDBOX-TRAIT): every scan is a job.
jobs: Arc<crate::job_runner::Jobs>,
/// The sandbox the probe door and `/readyz` look into; `None` for a runner with
/// no sandbox of its own (the in-process test runner).
probe: Option<Arc<holger_job_runner::ContainerRunner>>,
/// Each scan job's limits (the `sandbox:` block's).
limits: holger_sandbox::Limits,
/// Where each scan job's directories are made (the `sandbox:` block's `work_dir`).
work_dir: Option<PathBuf>,
template: JobTemplate,
/// The YARA rules on this host, the scan job's first read path (`/in/r0`).
yara_rules: Option<PathBuf>,
/// ClamAV, server-side: the bytes are streamed to clamd's socket.
clam: Vec<Arc<dyn holger_inspect::malware::MalwareScanner>>,
memo: Mutex<VecDeque<(blake3::Hash, Arc<Report>)>>,
runs: AtomicU64,
}
impl std::fmt::Debug for ScanWorker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ScanWorker")
.field("isolated", &self.is_isolated())
.field("runs", &self.runs())
.finish()
}
}
/// The scan program's name in a job (`job_runner::programs`).
pub const SCAN_PROGRAM: &str = "scan";
/// In a scan job's input: the artifact, the scan document and the job's own files.
pub const ARTIFACT_FILE: &str = "artifact";
pub const SCAN_FILE: &str = "scan.json";
pub const FILES_DIR: &str = "m";
/// In its output: the report.
pub const REPORT_FILE: &str = "report.json";
/// The probe program's name in a job (HOL-HEIMDALL-06): the worker's `probe`, run as a job
/// of the server's runner when no sandbox of this crate stands between (Heimdall's runner).
pub const PROBE_PROGRAM: &str = "probe";
/// In a probe job's output: what the box looks like from inside, as JSON.
pub const PROBE_FILE: &str = "probe.json";
/// Where the scan document names the YARA rules: the job's first read path.
pub const YARA_READ: &str = "/in/r0";
impl ScanWorker {
/// The worker the config declares: `rule` and the `sandbox:` block, over a runner
/// of its own (the server's shares one: [`Self::with_jobs`]).
pub fn new(
rule: SandboxRule,
cfg: Option<SandboxConfig>,
template: JobTemplate,
) -> Result<Self, Refusal> {
let jobs = Arc::new(crate::job_runner::Jobs::from_config(rule, cfg.clone())?);
Self::with_jobs_config(jobs, rule, cfg, template)
}
/// The worker over the server's `jobs`, the probe sandbox of `rule` and `cfg`.
pub fn with_jobs_config(
jobs: Arc<crate::job_runner::Jobs>,
rule: SandboxRule,
cfg: Option<SandboxConfig>,
template: JobTemplate,
) -> Result<Self, Refusal> {
let cfg = cfg.unwrap_or_default();
if rule == SandboxRule::Required && cfg.engine == holger_sandbox::EngineKind::Heimdall {
// Lane HOL-HEIMDALL-02: no sandbox of this crate to probe; the jobs' limits and
// directory are the block's own. When the jobs fell back to crun (HOL-HEIMDALL-03),
// the probe door looks into that crun sandbox, the one the jobs run in.
let probe = match cfg.crun_fallback() {
Some(c) if jobs.features().runner == "holger-crun" => Some(Arc::new(
holger_job_runner::ContainerRunner::new(Sandbox::from_config(c)?),
)),
_ => None,
};
let mut w = Self::with_jobs(jobs, probe, template)?;
w.limits = cfg.limits.clone();
w.work_dir = cfg.work_dir.clone().map(PathBuf::from);
return Ok(w);
}
let probe = match rule {
SandboxRule::Required => Some(Arc::new(holger_job_runner::ContainerRunner::new(
Sandbox::from_config(cfg)?,
))),
SandboxRule::Off => None,
};
Self::with_jobs(jobs, probe, template)
}
/// ★ **A worker whose every scan is a job of `jobs`** (lane HOL-SANDBOX-TRAIT): the
/// server's ONE runner, shared with the import door. `probe` is the sandbox the
/// probe door and `/readyz` look into (none for an in-process runner).
pub fn with_jobs(
jobs: Arc<crate::job_runner::Jobs>,
probe: Option<Arc<holger_job_runner::ContainerRunner>>,
template: JobTemplate,
) -> Result<Self, Refusal> {
Self::with_jobs_in(jobs, probe, template, HOST_CLAMD_ALLOWED)
}
/// [`ScanWorker::with_jobs`] for a build that may (`true`, a test build) or may not talk to
/// a host clamd ([`check_clamav`], lane HOL-NCSC).
fn with_jobs_in(
jobs: Arc<crate::job_runner::Jobs>,
probe: Option<Arc<holger_job_runner::ContainerRunner>>,
mut template: JobTemplate,
host_clamd_allowed: bool,
) -> Result<Self, Refusal> {
check_clamav(template.inspect.clamav.as_ref(), host_clamd_allowed)?;
let clam = template
.inspect
.clamav
.take()
.map(|c| {
vec![Arc::new(holger_inspect::malware::ClamAv::from_config(&c))
as Arc<dyn holger_inspect::malware::MalwareScanner>]
})
.unwrap_or_default();
// The worker reads the rules at the job's first read path; the host's is mounted there.
let mut yara_rules = None;
if let Some(y) = template.inspect.yara.as_mut() {
if let Some(r) = y.rules.take() {
yara_rules = Some(std::fs::canonicalize(&r).unwrap_or_else(|_| PathBuf::from(&r)));
y.rules = Some(YARA_READ.into());
}
}
let (limits, work_dir) = match &probe {
Some(p) => (
p.sandbox().config().limits.clone(),
p.sandbox().config().work_dir.clone().map(PathBuf::from),
),
None => (holger_sandbox::Limits::default(), None),
};
Ok(Self {
jobs,
probe,
limits,
work_dir,
template,
yara_rules,
clam,
memo: Mutex::new(VecDeque::new()),
runs: AtomicU64::new(0),
})
}
/// A worker over an engine a test supplies.
pub fn with_sandbox(sandbox: Sandbox, template: JobTemplate) -> Result<Self, Refusal> {
let r = Arc::new(holger_job_runner::ContainerRunner::new(sandbox));
let jobs = Arc::new(crate::job_runner::Jobs::new(r.clone()));
Self::with_jobs(jobs, Some(r), template)
}
/// The server's job runner this worker runs its scans through.
pub fn jobs(&self) -> &Arc<crate::job_runner::Jobs> {
&self.jobs
}
/// Does the worker run outside this process?
pub fn is_isolated(&self) -> bool {
!self.jobs.is_in_process()
}
/// Which engine, for the boot log and the probe door.
pub fn engine_words(&self) -> &'static str {
match &self.probe {
Some(p) => p.sandbox().engine_words(),
None if !self.is_isolated() => "IN-PROCESS (`sandbox: off`, a test build)",
None if !self.jobs.is_interim() => "Heimdall's runner (engine: heimdall)",
None => "the server's job runner",
}
}
/// **The sandbox looked at from inside** (`GET /-/sandbox/probe`, the install
/// test's row): the worker's `probe` mode through the same sandbox every scan
/// runs in, as JSON, with the engine's words and this worker's run count
/// beside it. An in-process worker answers its refusal: there is no inside.
///
/// With no sandbox of this crate between (HOL-HEIMDALL-06, `engine: heimdall`), the probe
/// is one job of the server's runner: the worker's [`PROBE_PROGRAM`] under the limits every
/// scan job gets, its answer read from [`PROBE_FILE`].
pub fn probe(&self) -> Result<serde_json::Value, Refusal> {
let Some(p) = &self.probe else {
if !self.is_isolated() {
return Err(Refusal::NoEngine(
"the worker runs in-process (a test build); nothing to probe".into(),
));
}
return self.probe_job();
};
let sb = p.sandbox();
let out = sb.run(&holger_sandbox::Request {
artifact: b"x",
job: b"{}",
mounts: vec![],
sha256: "probe",
command: vec!["probe".into()],
})?;
self.runs.fetch_add(1, Ordering::Relaxed);
let mut v: serde_json::Value = serde_json::from_slice(&out.report)
.map_err(|e| Refusal::ReportInvalid(format!("the probe's answer: {e}")))?;
v["engine"] = serde_json::Value::String(self.engine_words().into());
v["runs"] = serde_json::json!(self.runs());
v["elapsed_ms"] = serde_json::json!(out.elapsed.as_millis() as u64);
Ok(v)
}
/// The probe as one job of the server's runner (HOL-HEIMDALL-06): one MiB past the scratch
/// cap is the write it tries, as the container probe does.
fn probe_job(&self) -> Result<serde_json::Value, Refusal> {
let io = |e: std::io::Error| Refusal::Io(format!("the probe job: {e}"));
let started = std::time::Instant::now();
let (spec, dirs) = self
.job_spec(
PROBE_PROGRAM,
serde_json::json!({ "fill_mib": self.limits.scratch_mib + 1 }),
Vec::new(),
)
.map_err(io)?;
self.runs.fetch_add(1, Ordering::Relaxed);
self.run_spec(spec)?;
let out = std::fs::read(dirs.output.join(PROBE_FILE))
.map_err(|e| Refusal::ReportInvalid(format!("the probe job's {PROBE_FILE}: {e}")))?;
let mut v: serde_json::Value = serde_json::from_slice(&out)
.map_err(|e| Refusal::ReportInvalid(format!("the probe's answer: {e}")))?;
if !v.is_object() {
return Err(Refusal::ReportInvalid(
"the probe's answer is not a JSON object".into(),
));
}
v["engine"] = serde_json::Value::String(self.engine_words().into());
v["runs"] = serde_json::json!(self.runs());
v["elapsed_ms"] = serde_json::json!(started.elapsed().as_millis() as u64);
Ok(v)
}
/// One job of the worker's `program` under this worker's limits, its fresh directories
/// under the jobs' base; `read` the policy's read paths.
fn job_spec(
&self,
program: &str,
params: serde_json::Value,
read: Vec<PathBuf>,
) -> std::io::Result<(
crate::job_runner::heimdall_core::Job,
crate::job_runner::JobDirs,
)> {
use crate::job_runner::heimdall_core::{Job as JobSpec, JobId, Limits, Nonce, Policy};
let base = self
.work_dir
.clone()
.unwrap_or_else(std::env::temp_dir)
.join(crate::job_runner::JOBS_DIR);
let id = JobId::random();
let dirs = crate::job_runner::JobDirs::new(&base, &id)?;
let l = &self.limits;
let spec = JobSpec {
id,
nonce: Nonce::random(),
kind: holger_job_runner::worker(&[program]),
input_dir: dirs.input.clone(),
output_dir: dirs.output.clone(),
params,
policy: Policy {
read,
limits: Limits {
wall: std::time::Duration::from_secs(l.timeout_secs),
memory_bytes: l.memory_mib << 20,
cpus: l.cpu_millis as f32 / 1000.0,
pids: l.pids.min(u64::from(u32::MAX)) as u32,
..Default::default()
},
..Default::default()
},
};
Ok((spec, dirs))
}
/// Run `spec` through the server's runner, believed only as its signed result: a refused
/// verdict is the sandbox's refusal by its code.
fn run_spec(
&self,
spec: crate::job_runner::heimdall_core::Job,
) -> Result<crate::job_runner::heimdall_core::JobResult, Refusal> {
let r = self.jobs.run(spec).map_err(|f| {
if f.code == &holger_errcode::SANDBOX_JOB_RUN_FAILED {
Refusal::NoEngine(f.message)
} else {
Refusal::ReportInvalid(f.to_string())
}
})?;
if let crate::job_runner::heimdall_core::Verdict::Refused { code, reason } = &r.verdict {
return Err(refusal_of(code, reason, &r.exit, &self.limits));
}
Ok(r)
}
/// **Is the worker's engine ready?** (`/readyz`, lane B4.) A container
/// engine answers once and is remembered; a sidecar is asked every time. An
/// in-process worker (a test build) is always ready.
pub fn ready(&self) -> Result<(), Refusal> {
match &self.probe {
Some(p) => p.sandbox().ready(),
None => Ok(()),
}
}
/// How many times the worker ran (container starts, or in-process runs).
pub fn runs(&self) -> u64 {
self.runs.load(Ordering::Relaxed)
}
pub fn template(&self) -> &JobTemplate {
&self.template
}
fn job(
&self,
subject: &ScanSubject<'_>,
kinds: Vec<ScannerKind>,
provenance: Option<ProvenanceJob>,
) -> Job {
Job {
schema: JOB_SCHEMA.into(),
sha256: subject.sha256_hex(),
file_name: subject.file_name.to_string(),
ecosystem: subject.ecosystem.to_string(),
name: subject.name.map(str::to_string),
version: subject.version.map(str::to_string),
repo: subject.repo.map(str::to_string),
source: subject.source.map(str::to_string),
format_name: self.template.format_name.clone(),
inspect: self.template.inspect.clone(),
kinds,
license: self.template.license.clone(),
allow_pickle: self.template.allow_pickle.clone(),
provenance,
secrets: self.template.secrets.clone(),
}
}
/// **Run one job** (or reuse the report of the same job over the same bytes).
fn run(
&self,
job: Job,
bytes: &[u8],
files: Vec<(String, Vec<u8>)>,
) -> Result<Arc<Report>, Refusal> {
let doc = serde_json::to_vec(&job).map_err(|e| Refusal::Io(format!("the job: {e}")))?;
let mut h = blake3::Hasher::new();
h.update(&doc);
for (n, b) in &files {
h.update(n.as_bytes());
h.update(blake3::hash(b).as_bytes());
}
let key = h.finalize();
if let Some((_, r)) = self.memo.lock_or_recover().iter().find(|(k, _)| *k == key) {
return Ok(Arc::clone(r));
}
self.runs.fetch_add(1, Ordering::Relaxed);
let report = self.run_job(&job, &doc, bytes, &files)?;
let report = Arc::new(report);
let mut memo = self.memo.lock_or_recover();
memo.push_back((key, Arc::clone(&report)));
while memo.len() > MEMO {
memo.pop_front();
}
Ok(report)
}
/// ★ **One scan job** (lane HOL-SANDBOX-TRAIT): the artifact, the scan document and
/// the job's own files into a fresh input, the YARA rules as its one read path, run
/// through the server's runner and believed only as its signed result; the report
/// read back from its one output and checked field by field.
fn run_job(
&self,
job: &Job,
doc: &[u8],
bytes: &[u8],
files: &[(String, Vec<u8>)],
) -> Result<Report, Refusal> {
let io = |e: std::io::Error| Refusal::Io(format!("the scan job: {e}"));
let (spec, dirs) = self
.job_spec(
SCAN_PROGRAM,
serde_json::Value::Null,
self.yara_rules.iter().cloned().collect(),
)
.map_err(io)?;
std::fs::write(dirs.input.join(ARTIFACT_FILE), bytes).map_err(io)?;
std::fs::write(dirs.input.join(SCAN_FILE), doc).map_err(io)?;
if !files.is_empty() {
let m = dirs.input.join(FILES_DIR);
std::fs::create_dir(&m).map_err(io)?;
for (n, b) in files {
std::fs::write(m.join(n), b).map_err(io)?;
}
}
self.run_spec(spec)?;
let out = std::fs::read(dirs.output.join(REPORT_FILE))
.map_err(|e| Refusal::ReportInvalid(format!("the scan job's {REPORT_FILE}: {e}")))?;
Report::parse(&out, job).map_err(Refusal::ReportInvalid)
}
/// The full scan of one subject: the INGEST inspection and every
/// in-worker scanner of the template's kinds.
pub fn report(&self, subject: &ScanSubject<'_>) -> Result<Arc<Report>, Refusal> {
self.run(
self.job(subject, self.template.kinds.clone(), None),
subject.bytes,
Vec::new(),
)
}
/// **The INGEST inspection** of one artifact: the worker's walk and YARA,
/// then ClamAV over the bytes (streamed to clamd). A refusal is a blocking
/// finding that names it.
pub fn inspect(&self, subject: &ScanSubject<'_>) -> holger_inspect::Findings {
let file = subject
.file_name
.rsplit('/')
.next()
.unwrap_or(subject.file_name);
let format = holger_inspect::Format::declared(file, &self.template.format_name);
let mut f = match self.report(subject) {
Ok(r) => r.inspect.clone(),
Err(e) => refused_findings(format, file, &e),
};
holger_inspect::malware::scan_artifact(&self.clam, subject.bytes, file, &mut f);
f
}
/// `verify_artifact` in the worker: the provenance of `artifact` against
/// `materials` under `policy`, with the verified trust root.
pub fn verify_provenance(
&self,
artifact: &[u8],
materials: &[(String, Vec<u8>)],
policy: &holger_provenance::EffectivePolicy,
trust: Option<&[u8]>,
apk_keys: &[(String, Vec<u8>)],
) -> Result<holger_provenance::ProvenanceReport, Refusal> {
self.verify_provenance_with_keys(artifact, materials, policy, trust, apk_keys, &[])
}
/// [`verify_provenance`](Self::verify_provenance) with the pinned OpenPGP keys
/// too (`(name, armored key)`, Maven Central's `.asc` signers).
pub fn verify_provenance_with_keys(
&self,
artifact: &[u8],
materials: &[(String, Vec<u8>)],
policy: &holger_provenance::EffectivePolicy,
trust: Option<&[u8]>,
apk_keys: &[(String, Vec<u8>)],
pgp_keys: &[(String, Vec<u8>)],
) -> Result<holger_provenance::ProvenanceReport, Refusal> {
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
let mut mats = Vec::new();
for (i, (name, bytes)) in materials.iter().enumerate() {
let f = format!("material-{i}");
files.push((f.clone(), bytes.clone()));
mats.push((name.clone(), f));
}
if let Some(t) = &trust {
files.push(("trusted_root.json".into(), t.to_vec()));
}
let mut keys = Vec::new();
for (i, (name, pem)) in apk_keys.iter().enumerate() {
let f = format!("apk-key-{i}");
files.push((f.clone(), pem.clone()));
keys.push((name.clone(), f));
}
let mut pgp = Vec::new();
for (i, (name, armored)) in pgp_keys.iter().enumerate() {
let f = format!("pgp-key-{i}");
files.push((f.clone(), armored.clone()));
pgp.push((name.clone(), f));
}
let pj = ProvenanceJob {
by: String::new(),
policy: policy.clone(),
trust_root: trust.is_some().then(|| "trusted_root.json".to_string()),
materials: mats,
apk_keys: keys,
pgp_keys: pgp,
};
let subject = ScanSubject::new("provenance-subject", "raw", artifact);
let job = Job {
inspect: holger_inspect::InspectConfig::default(),
..self.job(&subject, Vec::new(), Some(pj))
};
let r = self.run(job, artifact, files)?;
r.provenance
.clone()
.ok_or_else(|| Refusal::ReportInvalid("no provenance report".into()))
}
}
/// A scan job's refusal as the sandbox's own, by its code.
fn refusal_of(
code: &str,
reason: &str,
exit: &crate::job_runner::heimdall_core::ExitInfo,
l: &holger_sandbox::Limits,
) -> Refusal {
use holger_errcode as ec;
match code {
c if c == ec::SANDBOX_TIMEOUT.code => Refusal::Timeout {
secs: l.timeout_secs,
},
c if c == ec::SANDBOX_KILLED.code => Refusal::Killed {
exit: exit.code,
oom: exit.killed == Some(crate::job_runner::heimdall_core::KillReason::Memory),
stderr: reason.to_string(),
},
c if c == ec::SANDBOX_REPORT_TOO_LARGE.code => Refusal::ReportTooLarge {
cap: l.report_max_bytes,
},
c if c == ec::SANDBOX_WORKER_FAILED.code => Refusal::WorkerFailed {
exit: exit.code,
stderr: reason.to_string(),
},
_ => Refusal::ReportInvalid(reason.to_string()),
}
}
/// ★ **The scan program** (`scan`, run by `holger-scan-worker` in the sandbox, or by
/// the test runner): the scan document and the artifact from the job's input, the
/// worker's own scanners over them, ONE report into the job's output.
pub fn scan_program(
job: &holger_job_runner::wire::WireJob,
dirs: &holger_job_runner::wire::Dirs,
) -> holger_job_runner::wire::Answer {
use holger_job_runner::wire::Answer;
const MAX_DOC: u64 = 4 << 20;
let bad = |why: String| {
Answer::refused(
holger_errcode::SANDBOX_REPORT_INVALID.code,
holger_errcode::SANDBOX_REPORT_INVALID.tag(why),
)
};
let _ = job;
let doc_path = dirs.input.join(SCAN_FILE);
match std::fs::metadata(&doc_path) {
Ok(m) if m.len() <= MAX_DOC => {}
Ok(_) => return bad("the scan document is larger than its cap".into()),
Err(e) => return bad(format!("the scan document: {e}")),
}
let mut scan: Job = match std::fs::read(&doc_path)
.map_err(|e| e.to_string())
.and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string()))
{
Ok(j) => j,
Err(e) => return bad(format!("the scan document: {e}")),
};
if scan.schema != JOB_SCHEMA {
return bad(format!("scan document schema {:?}", scan.schema));
}
if let Some(r) = scan.inspect.yara.as_mut().and_then(|y| y.rules.as_mut()) {
if r == YARA_READ {
match dirs.read.first() {
Some(p) => *r = p.display().to_string(),
None => {
return bad(
"the scan names YARA rules and the job has no read path for them".into(),
)
}
}
}
}
let f = match std::fs::File::open(dirs.input.join(ARTIFACT_FILE)) {
Ok(f) => f,
Err(e) => return bad(format!("the artifact: {e}")),
};
let len = f.metadata().map(|m| m.len()).unwrap_or(0);
// The artifact is mapped, not read: an image of a few GB stays out of the heap.
let map;
let bytes: &[u8] = if len == 0 {
&[]
} else {
// SAFETY: the artifact is the job's read-only input nobody writes while the job
// runs; a change under the map could only alter this job's own answer, which the
// server checks against the sha256 it hashed.
map = match unsafe { memmap2::Mmap::map(&f) } {
Ok(m) => m,
Err(e) => return bad(format!("mapping the artifact: {e}")),
};
&map[..]
};
let report = worker::run(&scan, bytes, &dirs.input.join(FILES_DIR));
if report.sha256 != scan.sha256 {
return bad("the artifact is not the bytes the scan names".into());
}
let out = match serde_json::to_vec(&report) {
Ok(o) => o,
Err(e) => return bad(format!("the report: {e}")),
};
if let Err(e) = std::fs::write(dirs.out.join(REPORT_FILE), out) {
return bad(format!("writing the report: {e}"));
}
Answer::accepted(vec![REPORT_FILE.into()])
}
/// The findings of a scan the sandbox refused: one blocking finding, by name.
pub fn refused_findings(
format: holger_inspect::Format,
file: &str,
e: &Refusal,
) -> holger_inspect::Findings {
holger_inspect::Findings {
format,
detected: holger_inspect::Kind::Unknown,
findings: vec![holger_inspect::Finding {
kind: holger_inspect::FindingKind::ScannerError,
severity: holger_inspect::Severity::Block,
path: file.to_string(),
detail: format!("{}: {e}", e.name()),
}],
entries: 0,
expanded_bytes: 0,
max_depth_seen: 0,
scanners: vec!["holger-sandbox".into()],
}
}
fn refused(e: &Refusal) -> ScanOutcome {
ScanOutcome::block(
vec![ScanFinding::new(
e.name(),
FindingSeverity::High,
e.to_string(),
)],
e.to_string(),
)
}
// ── the scanners the server's set holds in sandbox mode ─────────────────────
/// One in-worker scanner, as the server's set sees it: its id and version are
/// the real scanner's (so stored evidence stays comparable), its answer is the
/// worker's record.
pub struct WorkerScanner {
worker: Arc<ScanWorker>,
kind: ScannerKind,
id: String,
version: String,
}
impl WorkerScanner {
/// The proxies for every scanner the worker runs for `template`.
pub fn all(worker: &Arc<ScanWorker>) -> Vec<Arc<dyn Scanner>> {
let probe = Job {
schema: JOB_SCHEMA.into(),
sha256: String::new(),
file_name: String::new(),
ecosystem: String::new(),
name: None,
version: None,
repo: None,
source: None,
format_name: worker.template.format_name.clone(),
// The real scanners' ids and versions, so YARA's rules are read
// from this host's path (operator files, not an artifact).
inspect: {
let mut i = worker.template.inspect.clone();
if let (Some(y), Some(host)) = (i.yara.as_mut(), &worker.yara_rules) {
y.rules = Some(host.display().to_string());
}
i
},
kinds: worker.template.kinds.clone(),
license: worker.template.license.clone(),
allow_pickle: worker.template.allow_pickle.clone(),
provenance: None,
secrets: worker.template.secrets.clone(),
};
worker::scanners(&probe)
.into_iter()
.map(|s| {
Arc::new(WorkerScanner {
worker: Arc::clone(worker),
kind: s.kind(),
id: s.id().to_string(),
version: s.version(),
}) as Arc<dyn Scanner>
})
.collect()
}
}
impl Scanner for WorkerScanner {
fn id(&self) -> &str {
&self.id
}
fn version(&self) -> String {
self.version.clone()
}
fn kind(&self) -> ScannerKind {
self.kind
}
fn scan(&self, subject: &ScanSubject<'_>) -> anyhow::Result<ScanOutcome> {
let r = match self.worker.report(subject) {
Ok(r) => r,
Err(e) => return Ok(refused(&e)),
};
Ok(match r.record(self.kind, &self.id) {
Some(rec) => ScanOutcome {
verdict: rec.verdict,
findings: rec.findings.clone(),
document: rec.document.clone(),
detail: rec.detail.clone(),
hold: rec.hold,
},
None => ScanOutcome::error(format!(
"the scan worker sent no {} record from {}",
self.kind, self.id
)),
})
}
}
/// ClamAV in the `malware` slot: the bytes are streamed to clamd (which runs in
/// its own sandboxed container); nothing is unpacked here.
pub struct ClamStream {
engine: Arc<dyn holger_inspect::malware::MalwareScanner>,
}
impl ClamStream {
pub fn new(engine: Arc<dyn holger_inspect::malware::MalwareScanner>) -> Self {
Self { engine }
}
}
impl Scanner for ClamStream {
fn id(&self) -> &str {
self.engine.name()
}
fn version(&self) -> String {
format!(
"holger-server-lib {}; holger-inspect",
env!("CARGO_PKG_VERSION")
)
}
fn kind(&self) -> ScannerKind {
ScannerKind::Malware
}
fn scan(&self, subject: &ScanSubject<'_>) -> anyhow::Result<ScanOutcome> {
use holger_inspect::malware::ScanOutcome as M;
let file = subject
.file_name
.rsplit('/')
.next()
.unwrap_or(subject.file_name);
Ok(match self.engine.scan(subject.bytes, file) {
M::Clean => ScanOutcome::pass(format!("{}: clean", self.engine.name())),
M::Detected(sigs) => ScanOutcome::block(
sigs.iter()
.map(|s| {
ScanFinding::new(
"malware",
FindingSeverity::High,
format!("{}: {s}", self.engine.name()),
)
})
.collect(),
format!("{}: {}", self.engine.name(), sigs.join(", ")),
),
M::NotConfigured(why) => {
ScanOutcome::error(format!("{} not configured: {why}", self.engine.name()))
}
M::Error(e) => ScanOutcome::error(format!("{}: {e}", self.engine.name())),
})
}
}
/// **The vulnerability slot over the worker's coordinates.** The match against
/// the local advisory DB runs here (the DB, its staleness and its intel
/// sequence stay where the import door verified them); the artifact's bytes are
/// read only by the worker. An image is matched package by package, and each
/// advisory carries its severity, so `cve_max` and auto-approval hold for images.
pub struct VulnFromWorker {
inner: Arc<dyn Scanner>,
worker: Arc<ScanWorker>,
}
impl VulnFromWorker {
pub fn new(inner: Arc<dyn Scanner>, worker: Arc<ScanWorker>) -> Self {
Self { inner, worker }
}
}
impl Scanner for VulnFromWorker {
fn id(&self) -> &str {
self.inner.id()
}
fn version(&self) -> String {
self.inner.version()
}
fn kind(&self) -> ScannerKind {
ScannerKind::Vulnerability
}
fn intel_sequence(&self) -> Option<u64> {
self.inner.intel_sequence()
}
fn scan(&self, subject: &ScanSubject<'_>) -> anyhow::Result<ScanOutcome> {
let r = match self.worker.report(subject) {
Ok(r) => r,
Err(e) => return Ok(refused(&e)),
};
if !r.packages.is_empty() {
return image_outcome(self.inner.as_ref(), subject, &r);
}
if subject.file_name.ends_with(".oci.tar") {
return Ok(ScanOutcome::error(format!(
"no package of this image could be read ({})",
r.packages_from.as_deref().unwrap_or("no catalog")
)));
}
// The inner scanner is never handed a subject without coordinates: it
// would read a wheel's METADATA itself.
let coords = match (subject.name, subject.version) {
(Some(n), Some(v)) => Some((n.to_string(), v.to_string())),
_ => r.coords.clone(),
};
let own = match coords {
Some((n, v)) => self.inner.scan(&ScanSubject {
name: Some(&n),
version: Some(&v),
pinned: &[],
..*subject
})?,
None => {
return Ok(ScanOutcome::error(format!(
"no package name and version could be read from {:?}",
subject.file_name
)))
}
};
// HOL-CARGO-PUBLISH-DOOR: the dependency versions a crate pins (its row, read by the
// worker at intake) are matched beside it.
let pinned: &[holger_index_rows::Pinned] = match (&r.index_row, subject.pinned) {
(_, p) if !p.is_empty() => p,
(Some(holger_index_rows::IndexRow::Crate { pinned, .. }), _) => pinned,
_ => &[],
};
pinned_outcome(self.inner.as_ref(), subject, own, pinned)
}
}
/// **The pinned dependency versions, matched beside the artifact** (lane
/// HOL-CARGO-PUBLISH-DOOR): each one is judged as its own package in the artifact's
/// ecosystem; a finding names the dependency and where it is pinned, and the verdict is the
/// worst of all (a dependency that could not be matched is an error, so a required scan holds
/// it). The detail names every dependency with an advisory, so the refusal does.
pub fn pinned_outcome(
inner: &dyn Scanner,
subject: &ScanSubject<'_>,
mut own: ScanOutcome,
pinned: &[holger_index_rows::Pinned],
) -> anyhow::Result<ScanOutcome> {
if pinned.is_empty() {
return Ok(own);
}
let mut hits: Vec<String> = Vec::new();
let mut unmatched = 0usize;
for dep in pinned {
let o = inner.scan(&ScanSubject {
name: Some(&dep.name),
version: Some(&dep.version),
pinned: &[],
..*subject
})?;
if o.verdict == ScanVerdict::Error {
unmatched += 1;
}
if o.verdict > own.verdict {
own.verdict = o.verdict;
}
if o.findings.is_empty() {
continue;
}
hits.push(format!(
"{}@{} (pinned in {}): {}",
dep.name,
dep.version,
dep.from,
o.findings
.iter()
.map(|f| f.id.as_str())
.collect::<Vec<_>>()
.join(", ")
));
own.findings.extend(o.findings.into_iter().map(|f| {
ScanFinding::new(
f.id,
f.severity,
format!(
"dependency {}@{} (pinned in {}): {}",
dep.name, dep.version, dep.from, f.summary
),
)
}));
}
own.detail = format!(
"{}; {} pinned dependency version(s){}{}",
own.detail,
pinned.len(),
if hits.is_empty() {
", none with an advisory".to_string()
} else {
format!(", with advisories: {}", hits.join("; "))
},
if unmatched > 0 {
format!("; {unmatched} could not be matched")
} else {
String::new()
}
);
Ok(own)
}
fn image_outcome(
inner: &dyn Scanner,
subject: &ScanSubject<'_>,
r: &Report,
) -> anyhow::Result<ScanOutcome> {
let mut findings: Vec<ScanFinding> = Vec::new();
let mut seen = std::collections::BTreeSet::new();
let mut worst = ScanVerdict::Pass;
let mut errors = 0usize;
for p in &r.packages {
let s = ScanSubject {
ecosystem: &p.ecosystem,
name: Some(&p.name),
version: Some(&p.version),
..*subject
};
let o = inner.scan(&s)?;
match o.verdict {
ScanVerdict::Error => errors += 1,
v if v > worst => worst = v,
_ => {}
}
for f in o.findings {
if seen.insert((f.id.clone(), p.name.clone())) {
findings.push(ScanFinding::new(
f.id,
f.severity,
format!("{} {}@{}: {}", p.ecosystem, p.name, p.version, f.summary),
));
}
}
}
findings.sort_by(|a, b| b.severity.cmp(&a.severity).then(a.id.cmp(&b.id)));
let by_sev = |s: FindingSeverity| findings.iter().filter(|f| f.severity == s).count();
let detail = format!(
"{}: {} package(s) from {}; {} advisory finding(s): {} critical, {} high, {} medium, {} low{}",
if subject.file_name.ends_with(".oci.tar") {
"image"
} else {
"archive"
},
r.packages.len(),
r.packages_from.as_deref().unwrap_or("the layers"),
findings.len(),
by_sev(FindingSeverity::Critical),
by_sev(FindingSeverity::High),
by_sev(FindingSeverity::Medium),
by_sev(FindingSeverity::Low),
if errors > 0 { format!("; {errors} package(s) could not be matched") } else { String::new() }
);
// The refusal names the worst advisories by id, as a single package's refusal does
// (lane HOL-FIELD-2: an image refused with counts only).
let detail = if findings.is_empty() {
detail
} else {
let mut ids: Vec<&str> = Vec::new();
for f in &findings {
if !ids.contains(&f.id.as_str()) {
ids.push(&f.id);
}
}
let more = if ids.len() > 5 { ", ..." } else { "" };
format!("{detail} ({}{more})", ids[..ids.len().min(5)].join(", "))
};
let doc = serde_json::json!({
"schema": "holger.image-packages/v1",
"from": r.packages_from,
"packages": r.packages,
});
Ok(match worst {
ScanVerdict::Block => ScanOutcome::block(findings, detail),
ScanVerdict::Warn => ScanOutcome::warn(findings, detail),
_ => ScanOutcome {
findings,
..ScanOutcome::pass(detail)
},
}
.with_document(doc))
}
/// The provenance verifier's seam, in the sandbox, with the VERIFIED
/// `trusted_root.json` the server loaded (its signed bundle checked at boot).
pub struct RemoteProvenance {
worker: Arc<ScanWorker>,
trust: Option<Arc<Vec<u8>>>,
/// The pinned APK keys the same verified bundle carries (`(name, PEM)`).
apk_keys: Arc<Vec<(String, Vec<u8>)>>,
/// The pinned OpenPGP keys the same verified bundle carries (`(name, armored)`).
pgp_keys: Arc<Vec<(String, Vec<u8>)>>,
}
impl RemoteProvenance {
pub fn new(worker: Arc<ScanWorker>, trust: Option<Vec<u8>>) -> Self {
Self {
worker,
trust: trust.map(Arc::new),
apk_keys: Arc::new(Vec::new()),
pgp_keys: Arc::new(Vec::new()),
}
}
/// The same verifier with the pinned APK keys handed to the worker.
pub fn with_apk_keys(mut self, keys: Vec<(String, Vec<u8>)>) -> Self {
self.apk_keys = Arc::new(keys);
self
}
/// The same verifier with the pinned OpenPGP keys handed to the worker.
pub fn with_pgp_keys(mut self, keys: Vec<(String, Vec<u8>)>) -> Self {
self.pgp_keys = Arc::new(keys);
self
}
}
impl crate::scanner::provenance::RemoteVerify for RemoteProvenance {
fn verify(
&self,
artifact: &[u8],
materials: &[(String, Vec<u8>)],
policy: &holger_provenance::EffectivePolicy,
) -> holger_provenance::ProvenanceReport {
match self.worker.verify_provenance_with_keys(
artifact,
materials,
policy,
self.trust.as_deref().map(Vec::as_slice),
&self.apk_keys,
&self.pgp_keys,
) {
Ok(r) => r,
Err(e) => {
// A refused scan is a refused verdict, by name; no parse here.
let d = holger_provenance::ArtifactDigests::of(artifact);
holger_provenance::ProvenanceReport {
schema: holger_provenance::EVIDENCE_SCHEMA.into(),
verdict: holger_provenance::Verdict::Block,
code: Some(e.code().code.to_string()),
detail: e.to_string(),
digests: d,
provenance: policy.provenance,
signed: policy.signed,
policy: policy.summary(),
trust_root_sha256: None,
candidates: Vec::new(),
unreadable: Vec::new(),
not_checked: vec!["the scan sandbox refused the run".into()],
}
}
}
}
}
/// The pull-through's verifier, in the sandbox: the demand becomes the policy
/// exactly as `HolgerProvenance` makes it; the parse runs in the worker.
#[cfg(feature = "jobs")]
pub struct SandboxVerifier {
policy_of: crate::pull_intake::provenance::HolgerProvenance,
remote: RemoteProvenance,
}
#[cfg(feature = "jobs")]
impl SandboxVerifier {
pub fn new(
policy_of: crate::pull_intake::provenance::HolgerProvenance,
remote: RemoteProvenance,
) -> Self {
Self { policy_of, remote }
}
}
#[cfg(feature = "jobs")]
impl crate::pull_intake::provenance::Verifier for SandboxVerifier {
fn verify(
&self,
subject: &[u8],
materials: &[crate::pull_intake::provenance::Material],
demand: &crate::pull_intake::provenance::Demand,
) -> crate::pull_intake::provenance::Outcome {
use crate::pull_intake::provenance::{Outcome, Verdict};
let m: Vec<(String, Vec<u8>)> = materials
.iter()
.map(|m| (m.name.clone(), m.bytes.clone()))
.collect();
let r = crate::scanner::provenance::RemoteVerify::verify(
&self.remote,
subject,
&m,
&self.policy_of.policy(demand),
);
let verdict = match r.verdict {
holger_provenance::Verdict::Pass => Verdict::Pass,
holger_provenance::Verdict::Warn => Verdict::Warn,
holger_provenance::Verdict::Block => Verdict::Block,
};
Outcome {
verdict,
detail: r.detail.clone(),
code: r.code.clone(),
verified: r.candidates.iter().filter(|c| c.verified).count(),
}
}
}
#[cfg(test)]
mod tests;