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