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/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
        );
    }
}