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/wire.rs

//! **The scan worker's wire.** The server writes one [`Job`]; the worker answers
//! one [`Report`] on stdout. The report is untrusted: [`Report::parse`] caps it,
//! reads it strictly (`deny_unknown_fields`) and checks every field the server
//! will use (the artifact's sha256 echoed back, the kinds it was asked for,
//! sizes, counts and characters). Nothing else from the worker is used: no path
//! it names is opened, no byte it returns is stored as an artifact.

use serde::{Deserialize, Serialize};

use crate::scanner::{FindingSeverity, ScanFinding, ScanVerdict, ScannerKind};

/// The job schema label.
pub const JOB_SCHEMA: &str = "holger.scan-worker-job/v1";
/// The report schema label.
/// `v2` (lane HOL-PERF-2): the report carries the package's [`Report::index_row`].
pub const REPORT_SCHEMA: &str = "holger.scan-worker-report/v2";

/// The longest id (a finding id, a scanner id, a package name).
pub const MAX_ID: usize = 512;
/// The longest sentence (a detail, a summary).
pub const MAX_TEXT: usize = 16 << 10;
/// The most findings one record may carry.
pub const MAX_FINDINGS: usize = 20_000;
/// The most packages one image may list.
pub const MAX_PACKAGES: usize = 100_000;
/// The largest document (an SBOM) one record may carry, serialized.
pub const MAX_DOCUMENT: usize = 4 << 20;

/// What the worker is asked to do, over the artifact at `/in/artifact`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Job {
    pub schema: String,
    /// The artifact's sha256 as the server computed it. The worker checks it
    /// first and refuses other bytes.
    pub sha256: String,
    /// The subject, as the scanner framework sees it.
    pub file_name: String,
    pub ecosystem: String,
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default)]
    pub version: Option<String>,
    #[serde(default)]
    pub repo: Option<String>,
    #[serde(default)]
    pub source: Option<String>,
    /// The intake's incoming format (`pip`, `raw`, …): with the file name, the
    /// format the INGEST inspection declares.
    pub format_name: String,
    /// The inspector: limits, policies and YARA (its rules at `/in/d-yara`).
    /// ClamAV is never here: clamd is its own sandboxed process.
    pub inspect: holger_inspect::InspectConfig,
    /// The scanner kinds the worker runs (each a record in the report).
    pub kinds: Vec<ScannerKind>,
    /// The licence policy (the licence slot's).
    #[serde(default)]
    pub license: crate::scanner::license::LicensePolicy,
    /// `models.allow_pickle`.
    #[serde(default)]
    pub allow_pickle: Vec<String>,
    /// The provenance check, when the provenance slot is configured.
    #[serde(default)]
    pub provenance: Option<ProvenanceJob>,
    /// The secrets scanner's rules (the server read and checked them at boot).
    /// Absent: the built-in set.
    #[serde(default)]
    pub secrets: Option<crate::scanner::secrets::SecretRules>,
}

/// The provenance half of a job: the policy the server resolved for this
/// subject, the verified trust root and the material, all as files under `/in/m`
/// (the job's own files, written by the server).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProvenanceJob {
    /// Which gate applied (`<default>`, a source, a repository).
    pub by: String,
    pub policy: holger_provenance::EffectivePolicy,
    /// The verified `trusted_root.json`, by its file name under `/in/m`.
    #[serde(default)]
    pub trust_root: Option<String>,
    /// `(material name, file name under /in/m)`.
    #[serde(default)]
    pub materials: Vec<(String, String)>,
    /// The pinned APK keys from the verified trust bundle: `(key name, file name
    /// under /in/m)`, each a PEM public key.
    #[serde(default)]
    pub apk_keys: Vec<(String, String)>,
    /// The pinned OpenPGP keys from the verified trust bundle: `(key name, file
    /// name under /in/m)`, each an armored public key.
    #[serde(default)]
    pub pgp_keys: Vec<(String, String)>,
}

/// One scanner's answer, as the framework will record it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Record {
    pub kind: ScannerKind,
    pub scanner_id: String,
    pub scanner_version: String,
    pub verdict: ScanVerdict,
    #[serde(default)]
    pub findings: Vec<ScanFinding>,
    #[serde(default)]
    pub document: Option<serde_json::Value>,
    pub detail: String,
    #[serde(default)]
    pub hold: bool,
}

/// One package found inside an image.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Package {
    /// The advisory DB's ecosystem label (`wolfi`, `apk`, `deb`, `rpm`, `pypi`, `npm`, `go`, `cargo`).
    pub ecosystem: String,
    pub name: String,
    pub version: String,
    /// Where it was read (`sbom-attestation`, `apk-db`, `dpkg-status`, `rpmdb-sqlite`,
    /// `go-buildinfo`, `cargo-auditable`, …). `rpm` versions are `[epoch:]version-release`;
    /// `go` versions carry no leading `v`, and the Go toolchain is `stdlib`.
    pub source: String,
}

/// **Which build a worker is** (lane HOL-FIELD-TEST P1): its version and the scanners it runs,
/// so a server never takes the answer of a worker older than itself under the same schema.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerBuild {
    pub version: String,
    /// The ids of every scanner the worker runs, sorted.
    pub scanners: Vec<String>,
}

/// The worker's whole answer.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Report {
    pub schema: String,
    /// The worker's build ([`WorkerBuild`]); a worker older than P1 names none.
    #[serde(default)]
    pub worker: Option<WorkerBuild>,
    /// The sha256 of the bytes the worker read.
    pub sha256: String,
    /// The INGEST inspection (the walk and YARA).
    pub inspect: holger_inspect::Findings,
    /// One record per kind asked for.
    #[serde(default)]
    pub records: Vec<Record>,
    /// The package's own `(name, version)`, when the worker could read them.
    #[serde(default)]
    pub coords: Option<(String, String)>,
    /// An image's packages (empty for anything else).
    #[serde(default)]
    pub packages: Vec<Package>,
    /// Where an image's package list came from (`sbom-attestation`, `layers`).
    #[serde(default)]
    pub packages_from: Option<String>,
    /// What the package says about its licence, as the licence scanner reads it.
    #[serde(default)]
    pub license: Option<crate::scanner::license::DeclaredLicense>,
    /// The provenance report, when provenance was asked for.
    #[serde(default)]
    pub provenance: Option<holger_provenance::ProvenanceReport>,
    /// What the package says about itself for its format's index (lane HOL-PERF-2): the
    /// deb control stanza, the rpm header, the Chart.yaml, the wheel METADATA, the npm
    /// package.json. The intake stores it; the server never parses a package for an index.
    #[serde(default)]
    pub index_row: Option<holger_index_rows::IndexRow>,
    /// The worker's own time, ms.
    #[serde(default)]
    pub elapsed_ms: u64,
}

/// How a worker is rebuilt from this server's tree.
const REBUILD: &str =
    "rebuild the worker from this server's tree (`cargo xtask scan-worker image` for podman, \
     `cargo xtask scan-worker stage` for a medium's rootfs)";

/// **Is this worker this server's build?** The same version, and every scanner this server
/// runs ([`super::worker::build`]); refused by name otherwise (lane HOL-FIELD-TEST P1).
fn check_build(w: Option<&WorkerBuild>) -> Result<(), String> {
    let own = super::worker::build();
    let Some(w) = w else {
        return Err(format!(
            "the scan worker is older than this server: its report names no build, this server is {} \
             with the scanners {}; {REBUILD}",
            own.version,
            own.scanners.join(", ")
        ));
    };
    if w.scanners.len() > 64 || !text_ok(&w.version, MAX_ID) || !w.scanners.iter().all(|s| id_ok(s))
    {
        return Err("the worker's build is out of its bounds".into());
    }
    if w.version != own.version {
        return Err(format!(
            "the scan worker is version {:?}, this server is {}; {REBUILD}",
            w.version, own.version
        ));
    }
    let missing: Vec<&str> = own
        .scanners
        .iter()
        .filter(|s| !w.scanners.contains(s))
        .map(String::as_str)
        .collect();
    if !missing.is_empty() {
        return Err(format!(
            "the scan worker is older than this server: it has no {}; {REBUILD}",
            missing.join(", ")
        ));
    }
    Ok(())
}

fn text_ok(s: &str, max: usize) -> bool {
    s.len() <= max && !s.chars().any(|c| c.is_control() && c != '\n' && c != '\t')
}

fn id_ok(s: &str) -> bool {
    !s.is_empty() && s.len() <= MAX_ID && !s.chars().any(|c| c.is_control())
}

impl Report {
    /// **Read and check a report.** `job` is what was asked. Every refusal says
    /// which field failed; the caller turns it into `holger-sandbox-5`.
    pub fn parse(bytes: &[u8], job: &Job) -> Result<Report, String> {
        let r: Report = serde_json::from_slice(bytes)
            .map_err(|e| format!("not a {REPORT_SCHEMA} document: {e}"))?;
        if r.schema != REPORT_SCHEMA {
            // An older worker image or medium rootfs than this server (lane HOL-PERF-2: v2
            // carries the index row): said by name, with the command that rebuilds it.
            if r.schema.starts_with("holger.scan-worker-report/") {
                return Err(format!(
                    "the scan worker is older than this server: it answers {:?}, this server reads {REPORT_SCHEMA}; \
                     rebuild the worker from this server's tree (`cargo xtask scan-worker image` for podman, \
                     `cargo xtask scan-worker stage` for a medium's rootfs)",
                    r.schema
                ));
            }
            return Err(format!("schema {:?}, not {REPORT_SCHEMA}", r.schema));
        }
        check_build(r.worker.as_ref())?;
        if r.sha256 != job.sha256 {
            return Err(format!(
                "it is about sha256 {}, not the artifact's {}",
                r.sha256, job.sha256
            ));
        }
        let i = &r.inspect;
        if i.findings.len() > MAX_FINDINGS || i.scanners.len() > 64 {
            return Err("the inspection carries too many findings".into());
        }
        for f in &i.findings {
            if !text_ok(&f.path, MAX_TEXT) || !text_ok(&f.detail, MAX_TEXT) {
                return Err("an inspection finding's path or detail is too long or carries control characters".into());
            }
        }
        if i.scanners.iter().any(|s| !id_ok(s)) {
            return Err("an inspection scanner name is not an id".into());
        }
        let mut seen = std::collections::BTreeSet::new();
        for rec in &r.records {
            if !job.kinds.contains(&rec.kind) {
                return Err(format!("a {} record that was not asked for", rec.kind));
            }
            if !seen.insert((rec.kind, rec.scanner_id.clone())) {
                return Err(format!("two {} records from {}", rec.kind, rec.scanner_id));
            }
            if !id_ok(&rec.scanner_id)
                || !text_ok(&rec.scanner_version, MAX_ID)
                || !text_ok(&rec.detail, MAX_TEXT)
            {
                return Err(format!(
                    "the {} record's id, version or detail is not valid",
                    rec.kind
                ));
            }
            if rec.findings.len() > MAX_FINDINGS {
                return Err(format!(
                    "the {} record carries more than {MAX_FINDINGS} findings",
                    rec.kind
                ));
            }
            if rec
                .findings
                .iter()
                .any(|f| !id_ok(&f.id) || !text_ok(&f.summary, MAX_TEXT))
            {
                return Err(format!(
                    "a {} finding's id or summary is not valid",
                    rec.kind
                ));
            }
            if rec.hold && rec.verdict != ScanVerdict::Warn {
                return Err(format!("the {} record holds without a warn", rec.kind));
            }
            if let Some(d) = &rec.document {
                if serde_json::to_vec(d).map(|v| v.len()).unwrap_or(usize::MAX) > MAX_DOCUMENT {
                    return Err(format!(
                        "the {} record's document is larger than {MAX_DOCUMENT} bytes",
                        rec.kind
                    ));
                }
            }
        }
        if let Some((n, v)) = &r.coords {
            if !id_ok(n) || !id_ok(v) {
                return Err("the coordinates are not valid".into());
            }
        }
        if let Some(l) = &r.license {
            if !text_ok(&l.format, MAX_ID)
                || !text_ok(&l.declared, MAX_TEXT)
                || !l.expression.as_deref().is_none_or(|e| text_ok(e, MAX_TEXT))
            {
                return Err("the declared licence is not valid".into());
            }
        }
        if r.packages.len() > MAX_PACKAGES {
            return Err(format!("more than {MAX_PACKAGES} packages"));
        }
        for p in &r.packages {
            if !id_ok(&p.ecosystem) || !id_ok(&p.name) || !id_ok(&p.version) || !id_ok(&p.source) {
                return Err(format!(
                    "package {:?} is not valid",
                    p.name.chars().take(64).collect::<String>()
                ));
            }
        }
        if r.provenance.is_some() != job.provenance.is_some() {
            return Err("the provenance report is missing or was not asked for".into());
        }
        if let Some(p) = &r.provenance {
            if p.digests.sha256 != job.sha256 {
                return Err("the provenance report is about other bytes".into());
            }
            if !text_ok(&p.detail, MAX_TEXT) || p.candidates.len() > 1024 {
                return Err("the provenance report is too large".into());
            }
        }
        if let Some(row) = &r.index_row {
            row.check().map_err(|e| format!("the index row: {e}"))?;
        }
        Ok(r)
    }

    /// The record of one kind from one scanner.
    pub fn record(&self, kind: ScannerKind, scanner_id: &str) -> Option<&Record> {
        self.records
            .iter()
            .find(|r| r.kind == kind && r.scanner_id == scanner_id)
    }
}

/// A finding severity from its wire label (for an image's CVE list).
pub fn severity_label(s: FindingSeverity) -> &'static str {
    match s {
        FindingSeverity::Info => "info",
        FindingSeverity::Low => "low",
        FindingSeverity::Medium => "medium",
        FindingSeverity::High => "high",
        FindingSeverity::Critical => "critical",
    }
}