Skip to main content

deps_core/osv/
severity.rs

1//! Derives [`VulnSeverity`] from a raw OSV record, and maps it to
2//! [`DiagnosticSeverity`].
3//!
4//! See `architecture.md` §6 for the precedence rules and the rationale for
5//! capping at `WARNING` rather than `ERROR`.
6
7use tower_lsp_server::ls_types::DiagnosticSeverity;
8
9use super::types::{OsvAffected, VulnSeverity};
10
11/// Parses an OSV severity label (`database_specific.severity` or
12/// `ecosystem_specific.severity`) case-insensitively.
13fn parse_severity_label(s: &str) -> Option<VulnSeverity> {
14    match s.to_ascii_uppercase().as_str() {
15        "CRITICAL" => Some(VulnSeverity::Critical),
16        "HIGH" => Some(VulnSeverity::High),
17        "MODERATE" | "MEDIUM" => Some(VulnSeverity::Medium),
18        "LOW" => Some(VulnSeverity::Low),
19        _ => None,
20    }
21}
22
23fn severity_from_json(value: &serde_json::Value) -> Option<VulnSeverity> {
24    value
25        .get("severity")?
26        .as_str()
27        .and_then(parse_severity_label)
28}
29
30/// Classifies a record's severity, first hit wins:
31///
32/// 1. `database_specific.severity` (GHSA-sourced records carry this).
33/// 2. `relevant_affected[].ecosystem_specific.severity` (some RUSTSEC records).
34/// 3. [`VulnSeverity::Unknown`].
35///
36/// `relevant_affected` must already be filtered to the entries describing
37/// the package actually queried (`OsvVulnRecord::into_advisory`) — an
38/// unfiltered record can cover several unrelated packages sharing one
39/// advisory id, and this must never pick up a stranger's `ecosystem_specific`
40/// severity.
41pub(super) fn classify(
42    database_specific: Option<&serde_json::Value>,
43    relevant_affected: &[&OsvAffected],
44) -> VulnSeverity {
45    if let Some(v) = database_specific.and_then(severity_from_json) {
46        return v;
47    }
48
49    for affected in relevant_affected {
50        if let Some(v) = affected
51            .ecosystem_specific
52            .as_ref()
53            .and_then(severity_from_json)
54        {
55            return v;
56        }
57    }
58
59    VulnSeverity::Unknown
60}
61
62/// Maps a [`VulnSeverity`] to the [`DiagnosticSeverity`] used to render it.
63///
64/// `Critical`/`High` and `Unknown` cap at `WARNING` rather than `ERROR`:
65/// `ERROR` conventionally means "this file is broken", and a valid manifest
66/// declaring a real-but-vulnerable dependency is not a parse error.
67/// `Unknown -> WARNING` (not `HINT`) reflects that a record this could not
68/// grade is not evidence of low risk.
69///
70/// # Examples
71///
72/// ```
73/// // `severity` is a private module; this function is re-exported publicly
74/// // as `deps_core::osv::diagnostic_severity_for`.
75/// use deps_core::osv::VulnSeverity;
76/// use deps_core::osv::diagnostic_severity_for as to_diagnostic_severity;
77/// use tower_lsp_server::ls_types::DiagnosticSeverity;
78///
79/// assert_eq!(to_diagnostic_severity(VulnSeverity::Critical), DiagnosticSeverity::WARNING);
80/// assert_eq!(to_diagnostic_severity(VulnSeverity::Low), DiagnosticSeverity::INFORMATION);
81/// assert_eq!(to_diagnostic_severity(VulnSeverity::Unknown), DiagnosticSeverity::WARNING);
82/// ```
83#[must_use]
84pub const fn to_diagnostic_severity(severity: VulnSeverity) -> DiagnosticSeverity {
85    match severity {
86        VulnSeverity::Critical | VulnSeverity::High | VulnSeverity::Unknown => {
87            DiagnosticSeverity::WARNING
88        }
89        VulnSeverity::Medium | VulnSeverity::Low => DiagnosticSeverity::INFORMATION,
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::osv::types::OsvAffected;
97
98    #[test]
99    fn database_specific_severity_wins() {
100        let json = serde_json::json!({ "severity": "CRITICAL" });
101        assert_eq!(classify(Some(&json), &[]), VulnSeverity::Critical);
102    }
103
104    #[test]
105    fn database_specific_moderate_maps_to_medium() {
106        let json = serde_json::json!({ "severity": "MODERATE" });
107        assert_eq!(classify(Some(&json), &[]), VulnSeverity::Medium);
108    }
109
110    #[test]
111    fn falls_back_to_ecosystem_specific_severity() {
112        let affected = OsvAffected {
113            package: None,
114            ecosystem_specific: Some(serde_json::json!({ "severity": "LOW" })),
115            ranges: vec![],
116        };
117        assert_eq!(classify(None, &[&affected]), VulnSeverity::Low);
118    }
119
120    #[test]
121    fn cvss_vector_only_record_is_unknown() {
122        assert_eq!(classify(None, &[]), VulnSeverity::Unknown);
123    }
124
125    #[test]
126    fn no_severity_at_all_is_unknown() {
127        assert_eq!(classify(None, &[]), VulnSeverity::Unknown);
128    }
129
130    #[test]
131    fn to_diagnostic_severity_mapping() {
132        assert_eq!(
133            to_diagnostic_severity(VulnSeverity::Critical),
134            DiagnosticSeverity::WARNING
135        );
136        assert_eq!(
137            to_diagnostic_severity(VulnSeverity::High),
138            DiagnosticSeverity::WARNING
139        );
140        assert_eq!(
141            to_diagnostic_severity(VulnSeverity::Medium),
142            DiagnosticSeverity::INFORMATION
143        );
144        assert_eq!(
145            to_diagnostic_severity(VulnSeverity::Low),
146            DiagnosticSeverity::INFORMATION
147        );
148        assert_eq!(
149            to_diagnostic_severity(VulnSeverity::Unknown),
150            DiagnosticSeverity::WARNING
151        );
152    }
153}