use super::response::{OsvAffected, OsvSeverity, OsvVulnerability};
use crate::model::{
CvssScore, CvssVersion, Remediation, RemediationType, Severity, VulnerabilityRef,
VulnerabilitySource,
};
use chrono::{DateTime, Utc};
pub fn map_osv_to_vulnerability_ref(osv: &OsvVulnerability) -> VulnerabilityRef {
VulnerabilityRef {
id: osv.id.clone(),
source: VulnerabilitySource::Osv,
severity: extract_severity(&osv.severity)
.or_else(|| extract_database_severity(osv.database_specific.as_ref())),
cvss: extract_cvss_scores(&osv.severity),
affected_versions: extract_affected_versions(&osv.affected),
remediation: extract_remediation(&osv.affected),
description: osv.details.clone().or_else(|| osv.summary.clone()),
cwes: extract_cwes(osv.database_specific.as_ref()),
published: parse_datetime(osv.published.as_ref()),
modified: parse_datetime(osv.modified.as_ref()),
is_kev: false, kev_info: None,
epss_score: None, epss_percentile: None,
vex_status: None,
}
}
fn extract_severity(severities: &[OsvSeverity]) -> Option<Severity> {
for sev in severities {
if let Some(score) = parse_cvss_score(&sev.score) {
return Some(Severity::from_cvss(score));
}
}
None
}
fn extract_cvss_scores(severities: &[OsvSeverity]) -> Vec<CvssScore> {
severities
.iter()
.filter_map(|sev| {
let version = match sev.severity_type.as_str() {
"CVSS_V2" => Some(CvssVersion::V2),
"CVSS_V3" => Some(CvssVersion::V3),
"CVSS_V31" => Some(CvssVersion::V31),
"CVSS_V4" => Some(CvssVersion::V4),
_ => None,
}?;
let base_score = parse_cvss_score(&sev.score)?;
Some(CvssScore {
version,
base_score,
vector: if sev.score.contains(':') {
Some(sev.score.clone())
} else {
None
},
exploitability_score: None,
impact_score: None,
})
})
.collect()
}
fn parse_cvss_score(score_str: &str) -> Option<f32> {
if let Ok(score) = score_str.parse::<f32>() {
return Some(score);
}
if score_str.starts_with("CVSS:3") {
return cvss_v3_base_score(score_str);
}
if score_str.contains('/') {
for part in score_str.split('/') {
if part.to_lowercase().starts_with("score:")
&& let Ok(score) = part[6..].parse::<f32>()
{
return Some(score);
}
}
}
None
}
fn cvss_v3_base_score(vector: &str) -> Option<f32> {
let mut metrics = std::collections::HashMap::new();
for part in vector.split('/').skip(1) {
let (name, value) = part.split_once(':')?;
metrics.insert(name, value);
}
let metric = |name: &str| metrics.get(name).copied();
let scope_changed = match metric("S")? {
"C" => true,
"U" => false,
_ => return None,
};
let attack_vector = match metric("AV")? {
"N" => 0.85,
"A" => 0.62,
"L" => 0.55,
"P" => 0.2,
_ => return None,
};
let attack_complexity = match metric("AC")? {
"L" => 0.77,
"H" => 0.44,
_ => return None,
};
let privileges_required = match (metric("PR")?, scope_changed) {
("N", _) => 0.85,
("L", false) => 0.62,
("L", true) => 0.68,
("H", false) => 0.27,
("H", true) => 0.5,
_ => return None,
};
let user_interaction = match metric("UI")? {
"N" => 0.85,
"R" => 0.62,
_ => return None,
};
let impact_weight = |value: &str| -> Option<f64> {
match value {
"H" => Some(0.56),
"L" => Some(0.22),
"N" => Some(0.0),
_ => None,
}
};
let conf = impact_weight(metric("C")?)?;
let integ = impact_weight(metric("I")?)?;
let avail = impact_weight(metric("A")?)?;
let iss = 1.0 - (1.0 - conf) * (1.0 - integ) * (1.0 - avail);
let impact = if scope_changed {
7.52 * (iss - 0.029) - 3.25 * (iss - 0.02).powi(15)
} else {
6.42 * iss
};
if impact <= 0.0 {
return Some(0.0);
}
let exploitability =
8.22 * attack_vector * attack_complexity * privileges_required * user_interaction;
let base = if scope_changed {
1.08 * (impact + exploitability)
} else {
impact + exploitability
};
Some(round_up(base.min(10.0)) as f32)
}
fn round_up(value: f64) -> f64 {
let scaled = (value * 100_000.0).round() as i64;
if scaled % 10_000 == 0 {
scaled as f64 / 100_000.0
} else {
(scaled / 10_000 + 1) as f64 / 10.0
}
}
fn extract_database_severity(database_specific: Option<&serde_json::Value>) -> Option<Severity> {
let label = database_specific?.get("severity")?.as_str()?;
label
.parse::<Severity>()
.ok()
.filter(|severity| *severity != Severity::Unknown)
}
fn extract_affected_versions(affected: &[OsvAffected]) -> Vec<String> {
let mut versions = Vec::new();
for aff in affected {
versions.extend(aff.versions.iter().cloned());
for range in &aff.ranges {
for event in &range.events {
if let Some(ref introduced) = event.introduced
&& introduced != "0"
{
versions.push(format!(">= {introduced}"));
}
if let Some(ref fixed) = event.fixed {
versions.push(format!("< {fixed} (fixed)"));
}
if let Some(ref last) = event.last_affected {
versions.push(format!("<= {last}"));
}
}
}
}
versions
}
fn extract_remediation(affected: &[OsvAffected]) -> Option<Remediation> {
for aff in affected {
for range in &aff.ranges {
for event in &range.events {
if let Some(ref fixed) = event.fixed {
return Some(Remediation {
remediation_type: RemediationType::Upgrade,
description: Some(format!("Upgrade to version {fixed} or later")),
fixed_version: Some(fixed.clone()),
});
}
}
}
}
None
}
fn extract_cwes(database_specific: Option<&serde_json::Value>) -> Vec<String> {
let mut cwes = Vec::new();
if let Some(db_specific) = database_specific {
if let Some(cwe_ids) = db_specific.get("cwe_ids").and_then(|v| v.as_array()) {
for cwe in cwe_ids {
if let Some(cwe_str) = cwe.as_str() {
cwes.push(cwe_str.to_string());
}
}
}
if let Some(cwes_arr) = db_specific.get("cwes").and_then(|v| v.as_array()) {
for cwe in cwes_arr {
if let Some(cwe_id) = cwe.get("cweId").and_then(|v| v.as_str()) {
cwes.push(cwe_id.to_string());
}
}
}
}
cwes
}
fn parse_datetime(dt_str: Option<&String>) -> Option<DateTime<Utc>> {
dt_str.map(String::as_str).and_then(|s| {
DateTime::parse_from_rfc3339(s)
.map(|dt| dt.with_timezone(&Utc))
.ok()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_cvss_score() {
assert_eq!(parse_cvss_score("7.5"), Some(7.5));
assert_eq!(parse_cvss_score("10.0"), Some(10.0));
assert_eq!(parse_cvss_score("invalid"), None);
}
#[test]
fn test_severity_from_score() {
assert_eq!(Severity::from_cvss(9.5), Severity::Critical);
assert_eq!(Severity::from_cvss(7.5), Severity::High);
assert_eq!(Severity::from_cvss(5.0), Severity::Medium);
assert_eq!(Severity::from_cvss(2.0), Severity::Low);
}
fn assert_score(vector: &str, expected: f32) {
let score = parse_cvss_score(vector).unwrap();
assert!(
(score - expected).abs() < 1e-4,
"{vector}: expected {expected}, got {score}"
);
}
#[test]
fn test_parse_cvss_v31_vector_scope_unchanged() {
assert_score("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", 9.8);
assert_score("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N", 5.3);
}
#[test]
fn test_parse_cvss_v31_vector_scope_changed() {
assert_score("CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N", 6.4);
assert_score("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", 10.0);
}
#[test]
fn test_parse_cvss_v30_vector() {
assert_score("CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", 9.8);
}
#[test]
fn test_parse_cvss_v3_vector_zero_impact() {
assert_score("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N", 0.0);
}
#[test]
fn test_parse_cvss_v3_vector_invalid() {
assert_eq!(
parse_cvss_score("CVSS:3.1/AV:X/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"),
None
);
assert_eq!(parse_cvss_score("CVSS:3.1/AV:N/AC:L"), None);
assert_eq!(
parse_cvss_score("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N"),
None
);
}
#[test]
fn test_map_vector_severity_and_cvss() {
let json = r#"{
"id": "GHSA-test-1234",
"severity": [
{"type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}
]
}"#;
let osv: OsvVulnerability = serde_json::from_str(json).unwrap();
let vuln = map_osv_to_vulnerability_ref(&osv);
assert_eq!(vuln.severity, Some(Severity::Critical));
assert_eq!(vuln.cvss.len(), 1);
assert!((vuln.cvss[0].base_score - 9.8).abs() < 1e-4);
assert_eq!(
vuln.cvss[0].vector.as_deref(),
Some("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H")
);
}
#[test]
fn test_database_specific_severity_fallback() {
let json = r#"{
"id": "GHSA-test-5678",
"database_specific": {"severity": "MODERATE"}
}"#;
let osv: OsvVulnerability = serde_json::from_str(json).unwrap();
let vuln = map_osv_to_vulnerability_ref(&osv);
assert_eq!(vuln.severity, Some(Severity::Medium));
assert!(vuln.cvss.is_empty());
}
#[test]
fn test_database_specific_severity_unknown_label() {
let json = r#"{
"id": "GHSA-test-9999",
"database_specific": {"severity": "bogus"}
}"#;
let osv: OsvVulnerability = serde_json::from_str(json).unwrap();
let vuln = map_osv_to_vulnerability_ref(&osv);
assert_eq!(vuln.severity, None);
}
}