server/lib/src/scanner/inspect.rs
//! Lane S3 in the scanner framework: the file and archive inspector and the
//! ClamAV / YARA malware adapters as [`Scanner`]s.
//!
//! * [`InspectionScanner`] fills the `inspection` slot: zip bombs, nesting,
//! traversal, magic bytes against the declared format, executables and
//! scripts per format ([`holger_inspect`]).
//! * [`MalwareAdapter`] fills the `malware` slot, one per configured engine.
//! YARA is handed every member of an archive as well as the artifact; ClamAV
//! unpacks archives itself.
//!
//! The same inspector already ran at INGESTED and refused anything that blocks;
//! here it runs again so the SCANNED evidence record (append-only, signed when a
//! signer is configured) carries what it found. [`install`] puts the scanners
//! the `promotion.intake.inspect` block declares into a [`ScannerSet`]. An engine
//! that is not installed is left out of the set, so its slot reports the
//! framework's own "not configured", and a required one blocks.
use std::sync::Arc;
use holger_inspect::malware::{ClamAv, MalwareScanner, Yara};
use holger_inspect::{FindingKind, Findings, Format, InspectConfig, Inspector, Severity};
use super::{
FindingSeverity, ScanFinding, ScanOutcome, ScanSubject, Scanner, ScannerKind, ScannerSet,
};
fn version() -> String {
format!(
"holger-server-lib {}; holger-inspect",
env!("CARGO_PKG_VERSION")
)
}
fn declared<'a>(subject: &ScanSubject<'a>) -> (Format, &'a str) {
let file = subject
.file_name
.rsplit('/')
.next()
.unwrap_or(subject.file_name);
(Format::declared(file, subject.ecosystem), file)
}
fn finding(f: &holger_inspect::Finding) -> ScanFinding {
let severity = match f.severity {
Severity::Block => FindingSeverity::High,
Severity::Warn => FindingSeverity::Medium,
Severity::Info => FindingSeverity::Info,
};
ScanFinding::new(f.kind.name(), severity, format!("{}: {}", f.path, f.detail))
}
fn outcome(findings: &Findings, kinds: impl Fn(FindingKind) -> bool) -> ScanOutcome {
let mine: Vec<&holger_inspect::Finding> =
findings.findings.iter().filter(|f| kinds(f.kind)).collect();
let list: Vec<ScanFinding> = mine.iter().map(|f| finding(f)).collect();
let worst = mine.iter().map(|f| f.severity).max();
match worst {
Some(Severity::Block) => ScanOutcome::block(list, findings.summary()),
Some(Severity::Warn) => ScanOutcome::warn(list, findings.summary()),
_ => ScanOutcome {
findings: list,
..ScanOutcome::pass(findings.summary())
},
}
}
/// The `inspection` slot.
pub struct InspectionScanner {
inspector: Inspector,
}
impl InspectionScanner {
pub const ID: &'static str = "holger-inspect";
/// The limits and policies of `cfg`, without its malware engines.
pub fn new(cfg: &InspectConfig) -> Self {
let bare = InspectConfig {
clamav: None,
yara: None,
..cfg.clone()
};
Self {
inspector: Inspector::from_config(&bare),
}
}
}
impl Scanner for InspectionScanner {
fn id(&self) -> &str {
Self::ID
}
fn version(&self) -> String {
version()
}
fn kind(&self) -> ScannerKind {
ScannerKind::Inspection
}
fn scan(&self, subject: &ScanSubject<'_>) -> anyhow::Result<ScanOutcome> {
let (format, file) = declared(subject);
let findings = self.inspector.inspect(subject.bytes, format, file);
Ok(outcome(&findings, |_| true))
}
}
/// One malware engine in the `malware` slot.
pub struct MalwareAdapter {
engine: Arc<dyn MalwareScanner>,
/// The default inspector with only this engine, so a member-reading engine
/// (YARA) sees every member.
inspector: Inspector,
}
impl MalwareAdapter {
pub fn new(engine: Arc<dyn MalwareScanner>, cfg: &InspectConfig) -> Self {
let bare = InspectConfig {
clamav: None,
yara: None,
..cfg.clone()
};
let inspector = Inspector::from_config(&bare).with_scanner(Arc::clone(&engine));
Self { engine, inspector }
}
}
impl Scanner for MalwareAdapter {
fn id(&self) -> &str {
self.engine.name()
}
fn version(&self) -> String {
version()
}
fn kind(&self) -> ScannerKind {
ScannerKind::Malware
}
fn scan(&self, subject: &ScanSubject<'_>) -> anyhow::Result<ScanOutcome> {
let (format, file) = declared(subject);
let findings = self.inspector.inspect(subject.bytes, format, file);
let errors: Vec<String> = findings
.findings
.iter()
.filter(|f| {
matches!(
f.kind,
FindingKind::ScannerNotConfigured | FindingKind::ScannerError
)
})
.map(|f| f.detail.clone())
.collect();
if findings.has(FindingKind::Malware, Severity::Block) {
return Ok(outcome(&findings, |k| k == FindingKind::Malware));
}
if !errors.is_empty() {
return Ok(ScanOutcome::error(errors.join("; ")));
}
Ok(ScanOutcome::pass(format!("{}: clean", self.engine.name())))
}
}
/// Install the inspection scanner and every configured malware engine. An
/// engine that is not installed on this host is left out (its slot then says
/// "not configured"); one marked `required` makes the `malware` kind required.
pub fn install(mut set: ScannerSet, cfg: &InspectConfig) -> ScannerSet {
set = set.with(Arc::new(InspectionScanner::new(cfg)));
if let Some(c) = &cfg.clamav {
let clam = ClamAv::from_config(c);
if c.required {
set = set.require(ScannerKind::Malware);
}
match clam.engine() {
Ok(_) => set = set.with(Arc::new(MalwareAdapter::new(Arc::new(clam), cfg))),
Err(why) => log::warn!("intake scanners: clamav not configured: {why}"),
}
}
if let Some(y) = &cfg.yara {
let yara = Yara::from_config(y);
if y.required {
set = set.require(ScannerKind::Malware);
}
match yara.engine_name() {
Ok(_) => set = set.with(Arc::new(MalwareAdapter::new(Arc::new(yara), cfg))),
Err(why) => log::warn!("intake scanners: yara not configured: {why}"),
}
}
set
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scanner::ScanVerdict;
fn eicar() -> Vec<u8> {
format!(
"{}{}",
"X5O!P%@AP[4\\PZX54(P^)7CC)7}$", "EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"
)
.into_bytes()
}
fn rules_dir() -> tempfile::TempDir {
let d = tempfile::tempdir().unwrap();
std::fs::write(
d.path().join("eicar.yar"),
"rule EICAR_Test_File { strings: $a = \"EICAR-STANDARD-ANTIVIRUS-TEST-FILE\" condition: $a }",
)
.unwrap();
d
}
#[test]
fn the_inspection_slot_blocks_a_png_named_whl() {
let set = install(ScannerSet::new(), &InspectConfig::default());
let png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR";
let ev = set.scan(&ScanSubject::new("demo-1.0-py3-none-any.whl", "pip", png));
let rec = ev.records_of(ScannerKind::Inspection).next().unwrap();
assert_eq!(
(rec.scanner_id.as_str(), rec.verdict),
(InspectionScanner::ID, ScanVerdict::Block)
);
assert_eq!(rec.findings[0].id, "mime-mismatch");
assert_eq!(ev.verdict, ScanVerdict::Block);
}
#[test]
fn the_yara_adapter_fills_the_malware_slot() {
let dir = rules_dir();
let cfg = InspectConfig {
yara: Some(holger_inspect::malware::YaraConfig {
rules: Some(dir.path().display().to_string()),
required: true,
..Default::default()
}),
..Default::default()
};
let set = install(ScannerSet::new(), &cfg);
assert!(set.is_configured(ScannerKind::Malware));
let ev = set.scan(&ScanSubject::new("eicar.com", "raw", &eicar()));
let rec = ev.records_of(ScannerKind::Malware).next().unwrap();
assert_eq!(
(rec.scanner_id.as_str(), rec.verdict),
("yara", ScanVerdict::Block)
);
let clean = set.scan(&ScanSubject::new("notes.txt", "raw", b"hello"));
assert_eq!(clean.verdict, ScanVerdict::Pass, "{:?}", clean.reasons);
}
#[test]
fn a_required_engine_that_is_not_installed_blocks_through_the_fold() {
let cfg = InspectConfig {
clamav: Some(holger_inspect::malware::ClamAvConfig {
socket: Some("/nonexistent/holger-test/clamd.ctl".into()),
required: true,
..Default::default()
}),
..Default::default()
};
let set = install(ScannerSet::new(), &cfg);
assert!(!set.is_configured(ScannerKind::Malware));
let ev = set.scan(&ScanSubject::new("notes.txt", "raw", b"hello"));
assert_eq!(ev.verdict, ScanVerdict::Block, "{:?}", ev.reasons);
assert!(
ev.reasons.iter().any(|r| r.contains("malware")),
"{:?}",
ev.reasons
);
}
}