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",
}
}