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

nordisk/holger

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;