#[derive(Debug, Clone)]
pub struct Range {
pub matchable: bool,
pub events: Vec<Event>,
}
#[derive(Debug, Clone)]
pub struct Event {
pub introduced: Option<String>,
pub fixed: Option<String>,
pub last_affected: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParsedBound<V> {
Version(V),
Unparseable,
}
#[derive(Debug, Clone)]
pub struct ParsedEvent<V> {
pub introduced: Option<ParsedBound<V>>,
pub fixed: Option<V>,
pub last_affected: Option<V>,
}
#[derive(Debug, Clone)]
pub struct ParsedRange<V> {
pub matchable: bool,
pub events: Vec<ParsedEvent<V>>,
}
pub fn parse_range<V>(range: &Range, parse_bound: impl Fn(&str) -> Option<V>) -> ParsedRange<V> {
ParsedRange {
matchable: range.matchable,
events: range
.events
.iter()
.map(|e| ParsedEvent {
introduced: e.introduced.as_deref().map(|raw| {
parse_bound(raw).map_or(ParsedBound::Unparseable, ParsedBound::Version)
}),
fixed: e.fixed.as_deref().and_then(&parse_bound),
last_affected: e.last_affected.as_deref().and_then(&parse_bound),
})
.collect(),
}
}
pub fn affected_fixed_parsed<V: Ord + Clone>(
version: &V,
ranges: &[ParsedRange<V>],
at_or_after_introduced: impl Fn(&V, &V) -> bool,
) -> Match<V> {
for range in ranges {
if !range.matchable {
continue;
}
let mut affected = !range.events.iter().any(|e| e.introduced.is_some());
let mut patch: Option<V> = None;
for event in &range.events {
match &event.introduced {
Some(ParsedBound::Version(v)) if at_or_after_introduced(version, v) => {
affected = true
}
Some(ParsedBound::Version(_)) => {}
Some(ParsedBound::Unparseable) => affected = true,
None => {}
}
if let Some(v) = &event.fixed {
if version >= v {
affected = false;
} else {
patch = Some(patch.map_or_else(|| v.clone(), |p| p.min(v.clone())));
}
}
if let Some(v) = &event.last_affected {
if version > v {
affected = false;
}
}
}
if affected {
return Match::Affected { fixed: patch };
}
}
Match::NotAffected
}
#[derive(Debug, PartialEq, Eq)]
pub enum Match<V> {
NotAffected,
Affected { fixed: Option<V> },
}
pub fn affected_fixed<V: Ord + Clone>(
version: &V,
ranges: &[Range],
parse_bound: impl Fn(&str) -> Option<V>,
at_or_after_introduced: impl Fn(&V, &V) -> bool,
) -> Match<V> {
for range in ranges {
if !range.matchable {
continue;
}
let mut affected = !range.events.iter().any(|e| e.introduced.is_some());
let mut patch: Option<V> = None;
for event in &range.events {
if let Some(raw) = event.introduced.as_deref() {
match parse_bound(raw) {
Some(v) if at_or_after_introduced(version, &v) => affected = true,
Some(_) => {}
None => affected = true,
}
}
if let Some(v) = event.fixed.as_deref().and_then(&parse_bound) {
if *version >= v {
affected = false;
} else {
patch = Some(patch.map_or(v.clone(), |p| p.min(v)));
}
}
if let Some(v) = event.last_affected.as_deref().and_then(&parse_bound) {
if *version > v {
affected = false;
}
}
}
if affected {
return Match::Affected { fixed: patch };
}
}
Match::NotAffected
}
use std::collections::BTreeMap;
use std::io::{self, Cursor, Read as _};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::Arc;
use rayon::prelude::*;
use serde::Deserialize;
use thiserror::Error;
use crate::semver::{Version, VersionReq};
use crate::{DependencyKind, Ecosystem, Occurrence, Reachability, RepoId, Severity, VulnFinding};
#[derive(Debug, Clone)]
pub struct Advisory<V> {
pub id: String,
pub aliases: Vec<String>,
pub summary: Option<String>,
pub severity: Severity,
pub cvss_score: Option<f32>,
pub ranges: Vec<ParsedRange<V>>,
pub versions: Vec<V>,
}
#[derive(Debug)]
pub struct OsvDb<V> {
by_package: BTreeMap<String, Vec<Advisory<V>>>,
}
impl<V> Default for OsvDb<V> {
fn default() -> Self {
OsvDb {
by_package: BTreeMap::new(),
}
}
}
impl<V> OsvDb<V> {
pub fn advisories_for(&self, key: &str) -> &[Advisory<V>] {
self.by_package.get(key).map_or(&[], Vec::as_slice)
}
pub fn len(&self) -> usize {
self.by_package.values().map(Vec::len).sum()
}
pub fn is_empty(&self) -> bool {
self.by_package.is_empty()
}
}
pub struct Spec<V: 'static> {
pub ecosystem: &'static str,
pub range_type: &'static str,
pub parse_version: fn(&str) -> Option<V>,
pub normalize_name: fn(&str) -> String,
pub use_versions: bool,
pub severity: fn(&OsvRecord) -> (Severity, Option<f32>),
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum DbError {
#[error("read failed: {0}")]
Read(#[from] io::Error),
#[error("invalid JSON: {0}")]
Parse(#[from] serde_json::Error),
#[error("invalid zip archive: {0}")]
Archive(String),
}
impl From<zip::result::ZipError> for DbError {
fn from(e: zip::result::ZipError) -> Self {
match e {
zip::result::ZipError::Io(io) => DbError::Read(io),
other => DbError::Archive(other.to_string()),
}
}
}
#[derive(Debug)]
pub struct LoadError {
pub path: PathBuf,
pub source: DbError,
}
impl LoadError {
fn new(path: impl Into<PathBuf>, source: impl Into<DbError>) -> Self {
LoadError {
path: path.into(),
source: source.into(),
}
}
}
pub fn load<V>(root: &Path, spec: &Spec<V>) -> Result<OsvDb<V>, LoadError>
where
V: Ord + Clone + Send,
{
if root.is_dir() {
load_dir(root, spec)
} else {
load_zip(root, spec)
}
}
fn load_dir<V>(root: &Path, spec: &Spec<V>) -> Result<OsvDb<V>, LoadError>
where
V: Ord + Clone + Send,
{
let mut paths: Vec<PathBuf> = std::fs::read_dir(root)
.map_err(|e| LoadError::new(root, e))?
.map(|entry| entry.map(|e| e.path()).map_err(|e| LoadError::new(root, e)))
.collect::<Result<_, _>>()?;
paths.retain(|p| p.extension().and_then(|e| e.to_str()) == Some("json"));
paths.sort();
let per_file: Vec<Vec<(String, Advisory<V>)>> = paths
.par_iter()
.map(|path| {
let body = std::fs::read_to_string(path).map_err(|e| LoadError::new(path, e))?;
let osv: OsvRecord =
serde_json::from_str(&body).map_err(|e| LoadError::new(path, e))?;
Ok(advisories_from(osv, spec))
})
.collect::<Result<_, LoadError>>()?;
Ok(OsvDb {
by_package: merge(per_file),
})
}
fn load_zip<V>(path: &Path, spec: &Spec<V>) -> Result<OsvDb<V>, LoadError>
where
V: Ord + Clone + Send,
{
let bytes: Arc<[u8]> = std::fs::read(path)
.map_err(|e| LoadError::new(path, e))?
.into();
let archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| LoadError::new(path, e))?;
let per_entry: Vec<Vec<(String, Advisory<V>)>> = (0..archive.len())
.into_par_iter()
.map_init(
|| archive.clone(),
|archive, i| {
let mut entry = archive.by_index(i).map_err(|e| LoadError::new(path, e))?;
if !entry.name().ends_with(".json") {
return Ok(Vec::new());
}
let mut body = String::new();
entry
.read_to_string(&mut body)
.map_err(|e| LoadError::new(path, e))?;
let osv: OsvRecord =
serde_json::from_str(&body).map_err(|e| LoadError::new(path, e))?;
Ok(advisories_from(osv, spec))
},
)
.collect::<Result<_, LoadError>>()?;
Ok(OsvDb {
by_package: merge(per_entry),
})
}
fn merge<V>(per_file: Vec<Vec<(String, Advisory<V>)>>) -> BTreeMap<String, Vec<Advisory<V>>> {
let mut by_package: BTreeMap<String, Vec<Advisory<V>>> = BTreeMap::new();
for record in per_file {
for (name, advisory) in record {
by_package.entry(name).or_default().push(advisory);
}
}
by_package
}
pub fn advisories_from<V>(osv: OsvRecord, spec: &Spec<V>) -> Vec<(String, Advisory<V>)>
where
V: Ord + Clone,
{
let (severity, cvss_score) = (spec.severity)(&osv);
osv.affected
.iter()
.filter(|a| a.package.ecosystem.as_deref() == Some(spec.ecosystem))
.filter_map(|affected| {
let name = (spec.normalize_name)(affected.package.name.as_deref()?);
let ranges = affected
.ranges
.iter()
.map(|r| {
let range = Range {
matchable: r.kind.as_deref() == Some(spec.range_type),
events: r
.events
.iter()
.map(|e| Event {
introduced: e.introduced.clone(),
fixed: e.fixed.clone(),
last_affected: e.last_affected.clone(),
})
.collect(),
};
parse_range(&range, spec.parse_version)
})
.collect();
let mut versions: Vec<V> = if spec.use_versions {
affected
.versions
.iter()
.flatten()
.filter_map(|v| (spec.parse_version)(v))
.collect()
} else {
Vec::new()
};
versions.sort();
versions.dedup();
Some((
name,
Advisory {
id: osv.id.clone(),
aliases: osv.aliases.clone().unwrap_or_default(),
summary: osv.summary.clone(),
severity,
cvss_score,
ranges,
versions,
},
))
})
.collect()
}
pub fn default_severity(osv: &OsvRecord) -> (Severity, Option<f32>) {
let band = osv
.database_specific
.as_ref()
.and_then(|d| d.severity.as_deref())
.map(band_from_label)
.unwrap_or(Severity::Unknown);
let scored: Option<(Severity, f32)> = osv
.severity
.iter()
.filter(|s| s.kind.as_deref() == Some("CVSS_V3"))
.filter_map(|s| cvss::v3::Base::from_str(s.score.as_deref()?).ok())
.map(|base| {
let score = base.score();
(band_from_cvss(score.severity()), score.value() as f32)
})
.max_by(|a, b| a.1.total_cmp(&b.1));
let cvss_score = scored.map(|(_, v)| v);
let severity = if band != Severity::Unknown {
band
} else {
scored.map(|(b, _)| b).unwrap_or(Severity::Unknown)
};
(severity, cvss_score)
}
pub fn band_from_label(label: &str) -> Severity {
match label.to_ascii_uppercase().as_str() {
"LOW" => Severity::Low,
"MODERATE" | "MEDIUM" => Severity::Medium,
"HIGH" => Severity::High,
"CRITICAL" => Severity::Critical,
_ => Severity::Unknown,
}
}
fn band_from_cvss(sev: cvss::Severity) -> Severity {
match sev {
cvss::Severity::None => Severity::Unknown,
cvss::Severity::Low => Severity::Low,
cvss::Severity::Medium => Severity::Medium,
cvss::Severity::High => Severity::High,
cvss::Severity::Critical => Severity::Critical,
}
}
#[derive(Debug, Default, Clone)]
pub struct TierCScan {
pub findings: Vec<VulnFinding>,
pub skipped_unparseable: u32,
}
#[must_use]
pub fn advisory_url(id: &str) -> String {
format!("https://osv.dev/vulnerability/{id}")
}
#[must_use]
pub fn occ_package(v: &VulnFinding) -> &str {
match v.occurrences.first() {
Some(Occurrence::InRepo { package, .. }) => package,
_ => "",
}
}
pub fn sort_dedup_findings(out: &mut Vec<VulnFinding>) {
out.sort_by(|a, b| {
a.advisory_id
.cmp(&b.advisory_id)
.then_with(|| occ_package(a).cmp(occ_package(b)))
});
out.dedup_by(|a, b| a.advisory_id == b.advisory_id && occ_package(a) == occ_package(b));
}
pub struct TierCFinding<'a> {
pub ecosystem: Ecosystem,
pub advisory_id: String,
pub aliases: Vec<String>,
pub title: String,
pub severity: Severity,
pub cvss_score: Option<f32>,
pub package: String,
pub installed: Version,
pub patched: Vec<VersionReq>,
pub direct: bool,
pub dependency_path: Vec<String>,
pub repo: &'a RepoId,
pub reach_reason: &'static str,
}
impl TierCFinding<'_> {
#[must_use]
pub fn build(self) -> VulnFinding {
VulnFinding {
advisory_id: self.advisory_id.clone(),
aliases: self.aliases,
ecosystem: self.ecosystem,
title: self.title,
severity: self.severity,
cvss_score: self.cvss_score,
url: Some(advisory_url(&self.advisory_id)),
occurrences: vec![Occurrence::InRepo {
repo: self.repo.clone(),
package: self.package,
installed: self.installed,
patched: self.patched,
dependency_kind: if self.direct {
DependencyKind::Direct
} else {
DependencyKind::Transitive
},
dependency_path: self.dependency_path,
active: None,
source: Default::default(),
}],
affected_functions: Vec::new(),
reachable: None,
reachability: Some(Reachability::tier_c_unknown(self.reach_reason)),
exploit: Default::default(),
}
}
}
#[derive(Debug, Deserialize)]
pub struct OsvRecord {
pub id: String,
pub aliases: Option<Vec<String>>,
pub summary: Option<String>,
#[serde(default)]
pub affected: Vec<Affected>,
#[serde(default)]
pub severity: Vec<SeverityEntry>,
pub database_specific: Option<DatabaseSpecific>,
}
#[derive(Debug, Deserialize)]
pub struct SeverityEntry {
#[serde(rename = "type")]
pub kind: Option<String>,
pub score: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct DatabaseSpecific {
pub severity: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct Affected {
#[serde(default)]
pub package: Package,
#[serde(default)]
pub ranges: Vec<RawRange>,
pub versions: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Default)]
pub struct Package {
pub name: Option<String>,
pub ecosystem: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct RawRange {
#[serde(rename = "type")]
pub kind: Option<String>,
#[serde(default)]
pub events: Vec<RawEvent>,
}
#[derive(Debug, Deserialize)]
pub struct RawEvent {
pub introduced: Option<String>,
pub fixed: Option<String>,
pub last_affected: Option<String>,
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use semver::Version;
fn v(s: &str) -> Version {
Version::parse(s).unwrap()
}
fn plain(version: &Version, ranges: &[Range]) -> Match<Version> {
affected_fixed(
version,
ranges,
|raw| {
if raw == "0" {
Some(Version::new(0, 0, 0))
} else {
Version::parse(raw).ok()
}
},
|ver, bound| ver >= bound,
)
}
fn range(events: &[(&str, &str)]) -> Range {
Range {
matchable: true,
events: events
.iter()
.map(|(k, val)| Event {
introduced: (*k == "introduced").then(|| val.to_string()),
fixed: (*k == "fixed").then(|| val.to_string()),
last_affected: (*k == "last_affected").then(|| val.to_string()),
})
.collect(),
}
}
#[test]
fn affected_below_fix_reports_smallest_patch() {
let r = [
range(&[("introduced", "0"), ("fixed", "1.0.1")]),
range(&[("introduced", "2.0.0"), ("fixed", "2.0.3")]),
];
assert_eq!(
plain(&v("0.9.0"), &r),
Match::Affected {
fixed: Some(v("1.0.1"))
}
);
assert_eq!(
plain(&v("1.5.0"), &r),
Match::NotAffected,
"between windows"
);
}
#[test]
fn no_introduced_event_is_affected_from_zero() {
let r = [Range {
matchable: true,
events: vec![Event {
introduced: None,
fixed: Some("2.0.0".into()),
last_affected: None,
}],
}];
assert_eq!(
plain(&v("1.5.0"), &r),
Match::Affected {
fixed: Some(v("2.0.0"))
}
);
assert_eq!(plain(&v("2.0.0"), &r), Match::NotAffected);
}
#[test]
fn unparseable_introduced_fails_loud() {
let r = [range(&[("introduced", "garbage"), ("fixed", "99.0.0")])];
assert_eq!(
plain(&v("1.0.0"), &r),
Match::Affected {
fixed: Some(v("99.0.0"))
},
"a malformed lower bound must read affected, never clean"
);
}
#[test]
fn non_semver_ranges_are_skipped() {
let r = [Range {
matchable: false,
events: vec![Event {
introduced: Some("0".into()),
fixed: None,
last_affected: None,
}],
}];
assert_eq!(plain(&v("1.0.0"), &r), Match::NotAffected);
}
#[test]
fn last_affected_closes_an_open_interval() {
let r = [range(&[
("introduced", "1.0.0"),
("last_affected", "1.4.0"),
])];
assert_eq!(plain(&v("1.3.0"), &r), Match::Affected { fixed: None });
assert_eq!(plain(&v("1.5.0"), &r), Match::NotAffected);
}
#[test]
fn parsed_matcher_agrees_with_string_matcher() {
let parse = |raw: &str| {
if raw == "0" {
Some(Version::new(0, 0, 0))
} else {
Version::parse(raw).ok()
}
};
let ranges = [
range(&[("introduced", "0"), ("fixed", "1.0.1")]),
range(&[("introduced", "2.0.0"), ("fixed", "2.0.3")]),
range(&[("introduced", "1.0.0"), ("last_affected", "1.4.0")]),
range(&[("introduced", "garbage"), ("fixed", "99.0.0")]),
Range {
matchable: false,
events: vec![Event {
introduced: Some("0".into()),
fixed: None,
last_affected: None,
}],
},
];
let parsed: Vec<ParsedRange<Version>> =
ranges.iter().map(|r| parse_range(r, parse)).collect();
for s in [
"0.0.1", "0.9.0", "1.0.0", "1.0.1", "1.3.0", "1.5.0", "2.0.0", "2.0.3", "5.0.0",
] {
let ver = v(s);
let want = plain(&ver, &ranges);
let got = affected_fixed_parsed(&ver, &parsed, |a, b| a >= b);
assert_eq!(want, got, "disagreement at {s}");
}
}
#[test]
fn custom_introduced_comparator_is_honored() {
let r = [range(&[("introduced", "1.0.0"), ("fixed", "2.0.0")])];
let never = affected_fixed(
&v("1.5.0"),
&r,
|raw| Version::parse(raw).ok(),
|_, _| false,
);
assert_eq!(never, Match::NotAffected);
}
}