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

//! The CVE/OSV scanner: modgunn's `OsvScanner` behind the [`Scanner`] trait.
//!
//! The verdict logic is modgunn's and stays there; this file is only the
//! adapter, the same one `promote::gate::OsvGate` uses. The artifact goes to
//! modgunn through [`security::artifact_from_coords_with_digest`] with its real
//! SHA-256, so the verdict's cache key is the bytes and not a name.

use super::{FindingSeverity, ScanFinding, ScanOutcome, ScanSubject, Scanner, ScannerKind};
use crate::advisory_index::IndexedOsvScanner;
use crate::security::{self, AdvisoryDb, Decision, OsvScanner, Policy, Severity};

/// The CVE/OSV scanner.
pub struct OsvVulnScanner {
    /// modgunn's scanner with its advisories indexed once (T10): the lookup
    /// reads one `(ecosystem, package)` bucket, the verdict is modgunn's.
    inner: IndexedOsvScanner,
}

impl OsvVulnScanner {
    /// The scanner id in the evidence.
    pub const ID: &'static str = "modgunn-osv";

    /// A vulnerability-only scanner over `advisories`. The policy's license and
    /// provenance checks are left neutral, because those are their own slots.
    pub fn new(advisories: AdvisoryDb) -> Self {
        let policy = Policy {
            // /2 (HOL-DEPSET-APPROVE): a Maven POM's advisories warn. The version is in the
            // scanner's version, so a stored /1 verdict on a POM is scanned again.
            version: "holger-scan-vulnerability/2".into(),
            ..Policy::default()
        };
        Self {
            inner: IndexedOsvScanner::new(OsvScanner::new(policy, advisories)),
        }
    }

    /// Wrap a fully configured modgunn scanner as it is.
    pub fn from_scanner(inner: OsvScanner) -> Self {
        Self {
            inner: IndexedOsvScanner::new(inner),
        }
    }
}

fn severity(s: Severity) -> FindingSeverity {
    match s {
        Severity::Info => FindingSeverity::Info,
        Severity::Low => FindingSeverity::Low,
        Severity::Medium => FindingSeverity::Medium,
        Severity::High => FindingSeverity::High,
        Severity::Critical => FindingSeverity::Critical,
    }
}

/// The artifact's `(name, version)`: a `.deb`'s control file (always, [`deb_coords`]), the
/// caller's, then a wheel's own METADATA, then the file name.
pub(crate) fn coords(subject: &ScanSubject<'_>) -> Option<(String, String)> {
    if subject.file_name.to_ascii_lowercase().ends_with(".deb") {
        return deb_coords(subject.bytes);
    }
    if let (Some(n), Some(v)) = (subject.name, subject.version) {
        return Some((n.to_string(), v.to_string()));
    }
    if super::wheel::is_wheel(subject.file_name) {
        if let Ok(m) = super::wheel::read_metadata(subject.bytes) {
            return Some((m.name, m.version));
        }
    }
    security::parse_coords(subject.file_name)
}

/// **A `.deb`'s coordinates, as Debian's advisories name them** (lane HOL-FIELD-2 P14): its
/// control file's SOURCE package and version (`Source: openssl (3.0.11-1)`, else `Source:`
/// with the binary's `Version:`, else `Package:` and `Version:`). Debian's OSV records name
/// source packages only, so `libssl3` is matched as `openssl`; and apt installs what the
/// control file names, so the file name is never read. `None` when the control file does not
/// read: the scan then errs and a required scan holds it.
pub(crate) fn deb_coords(deb: &[u8]) -> Option<(String, String)> {
    let control = holger_index_rows::deb::control_from_deb(deb)?;
    let field = |key: &str| {
        control.lines().find_map(|l| {
            let (k, v) = l.split_once(':')?;
            (k.trim().eq_ignore_ascii_case(key) && !l.starts_with([' ', '\t']))
                .then(|| v.trim().to_string())
                .filter(|v| !v.is_empty())
        })
    };
    let version = field("Version")?;
    let (name, version) = match field("Source") {
        Some(src) => match src.split_once('(') {
            Some((n, v)) => (
                n.trim().to_string(),
                v.trim_end_matches(')').trim().to_string(),
            ),
            None => (src, version),
        },
        None => (field("Package")?, version),
    };
    (!name.is_empty() && !version.is_empty()).then_some((name, version))
}

/// **The Debian releases the intel set keeps apart** (lane HOL-FIELD-2 P17): Debian's OSV
/// records give each release its own range, and `holger intel build` names each release's
/// package `debian-<n>/<source>` (holger-intel `feeds::DEBIAN_RELEASE_PREFIX`). A version
/// that names its release (`~deb12u2`, `+deb11u5`) is matched in that release only; any other
/// in every release this list names, and under the bare name of an older intel set.
const DEBIAN_RELEASES: std::ops::RangeInclusive<u32> = 7..=16;

/// The intel names a Debian source package `name` at `version` is matched under (P17).
pub(crate) fn deb_names(name: &str, version: &str) -> Vec<String> {
    let named = version.match_indices("deb").find_map(|(i, _)| {
        let before = version[..i].chars().last()?;
        let rest = &version[i + 3..];
        let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
        ((before == '~' || before == '+')
            && !digits.is_empty()
            && rest[digits.len()..].starts_with('u'))
        .then(|| digits.parse::<u32>().ok())
        .flatten()
    });
    match named {
        Some(n) => vec![format!("debian-{n}/{name}")],
        None => DEBIAN_RELEASES
            .map(|n| format!("debian-{n}/{name}"))
            .chain(std::iter::once(name.to_string()))
            .collect(),
    }
}

impl OsvVulnScanner {
    /// The worst verdict over the packages a raw archive names (P16), every finding kept.
    fn scan_named(
        &self,
        subject: &ScanSubject<'_>,
        found: &[(&'static str, String, String)],
    ) -> anyhow::Result<ScanOutcome> {
        let mut findings = Vec::new();
        let mut details = Vec::new();
        let mut worst = super::ScanVerdict::Pass;
        for (ecosystem, name, version) in found {
            let o = self.scan(&ScanSubject {
                ecosystem,
                name: Some(name),
                version: Some(version),
                ..*subject
            })?;
            if o.verdict > worst {
                worst = o.verdict;
            }
            findings.extend(o.findings);
            details.push(o.detail);
        }
        let detail = format!(
            "the raw file is a package by its own manifest: {}",
            details.join("; ")
        );
        Ok(match worst {
            super::ScanVerdict::Block => ScanOutcome::block(findings, detail),
            super::ScanVerdict::Warn => ScanOutcome::warn(findings, detail),
            super::ScanVerdict::Error => ScanOutcome::error(detail),
            super::ScanVerdict::Pass => ScanOutcome {
                findings,
                ..ScanOutcome::pass(detail)
            },
        })
    }
}

impl Scanner for OsvVulnScanner {
    fn id(&self) -> &str {
        Self::ID
    }

    fn version(&self) -> String {
        format!(
            "holger-server-lib {} over modgunn; policy {}; advisories {}",
            env!("CARGO_PKG_VERSION"),
            self.inner.scanner().policy.version,
            self.inner.scanner().advisories.rev
        )
    }

    fn kind(&self) -> ScannerKind {
        ScannerKind::Vulnerability
    }

    fn scan(&self, subject: &ScanSubject<'_>) -> anyhow::Result<ScanOutcome> {
        if let Some(held) = super::unjudgeable_package(subject) {
            return Ok(held);
        }
        // P16 (lane HOL-FIELD-2): a raw file that is a package by its own manifest is matched
        // as that package (every one it names), never as raw.
        if subject.ecosystem == "raw" && subject.name.is_none() {
            let found = super::license::packages_of_archive(subject.bytes);
            if !found.is_empty() {
                return self.scan_named(subject, &found);
            }
        }
        let Some((name, version)) = coords(subject) else {
            return Ok(ScanOutcome::error(format!(
                "no package name and version could be read from {:?}",
                subject.file_name
            )));
        };
        let artifact = security::artifact_from_coords_with_digest(
            subject.ecosystem,
            &name,
            &version,
            &subject.sha256_hex(),
            false,
        );
        let verdict = self.inner.scan(&artifact);
        let findings: Vec<ScanFinding> = verdict
            .findings
            .iter()
            .map(|f| ScanFinding::new(f.id.clone(), severity(f.severity), f.summary.clone()))
            .collect();
        let detail = format!(
            "{} {}@{}: {} advisory finding(s) against {}",
            subject.ecosystem,
            name,
            version,
            findings.len(),
            verdict.advisory_db_rev
        );
        Ok(match verdict.decision {
            Decision::Pass => ScanOutcome {
                findings,
                ..ScanOutcome::pass(detail)
            },
            Decision::Warn => ScanOutcome::warn(findings, detail),
            // HOL-DEPSET-APPROVE: a Maven POM carries no code; its advisories warn.
            Decision::Block
                if super::advisories_warn_only(subject.ecosystem, subject.file_name) =>
            {
                ScanOutcome::warn(
                    findings,
                    format!("{detail}; {}", super::POM_ADVISORIES_WARN),
                )
            }
            Decision::Block => ScanOutcome::block(findings, detail),
        })
    }
}