use std::collections::BTreeMap;
use std::fmt;
use rich::cells::cell_len;
use rich::table::Table;
use rich::{ColumnOptions, Console, ConsoleOptions, Renderable, Segment, Style, Text};
use serde_json::Value;
use super::{DepsError, check_count, check_size, clean, stack, stack_measure, theme_style};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum AdvisoryKind {
Vulnerability,
Unsound,
Unmaintained,
Yanked,
Notice,
}
impl AdvisoryKind {
pub fn name(self) -> &'static str {
match self {
AdvisoryKind::Vulnerability => "vulnerability",
AdvisoryKind::Unsound => "unsound",
AdvisoryKind::Unmaintained => "unmaintained",
AdvisoryKind::Yanked => "yanked",
AdvisoryKind::Notice => "notice",
}
}
fn parse(name: &str) -> Self {
match name {
"vulnerability" => AdvisoryKind::Vulnerability,
"unsound" => AdvisoryKind::Unsound,
"unmaintained" => AdvisoryKind::Unmaintained,
"yanked" => AdvisoryKind::Yanked,
_ => AdvisoryKind::Notice,
}
}
}
impl fmt::Display for AdvisoryKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.name())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Severity {
Critical,
High,
Medium,
Low,
Unknown,
Informational,
}
impl Severity {
pub const ALL: [Severity; 6] = [
Severity::Critical,
Severity::High,
Severity::Medium,
Severity::Low,
Severity::Unknown,
Severity::Informational,
];
pub fn from_score(score: f64) -> Self {
match score {
s if s >= 9.0 => Severity::Critical,
s if s >= 7.0 => Severity::High,
s if s >= 4.0 => Severity::Medium,
s if s > 0.0 => Severity::Low,
_ => Severity::Informational,
}
}
pub fn parse(name: &str) -> Option<Self> {
Some(match name.trim().to_ascii_lowercase().as_str() {
"critical" => Severity::Critical,
"high" => Severity::High,
"medium" | "moderate" => Severity::Medium,
"low" => Severity::Low,
"unknown" => Severity::Unknown,
"informational" | "info" | "none" => Severity::Informational,
_ => return None,
})
}
pub fn name(self) -> &'static str {
match self {
Severity::Critical => "critical",
Severity::High => "high",
Severity::Medium => "medium",
Severity::Low => "low",
Severity::Unknown => "unknown",
Severity::Informational => "informational",
}
}
fn style(self, console: &Console) -> Style {
let key = match self {
Severity::Critical => "deps.critical",
Severity::High => "deps.high",
Severity::Medium => "deps.medium",
Severity::Low => "deps.low",
Severity::Unknown => "deps.unknown",
Severity::Informational => "deps.info",
};
theme_style(console, key)
}
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.name())
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Advisory {
pub id: String,
pub kind: AdvisoryKind,
pub package: String,
pub version: String,
pub patched: Vec<String>,
pub unaffected: Vec<String>,
pub severity: Severity,
pub score: Option<f64>,
pub title: String,
pub url: Option<String>,
pub aliases: Vec<String>,
pub date: Option<String>,
}
impl Advisory {
pub fn new(
id: impl Into<String>,
kind: AdvisoryKind,
package: impl Into<String>,
version: impl Into<String>,
title: impl Into<String>,
) -> Self {
Advisory {
id: id.into(),
kind,
package: package.into(),
version: version.into(),
patched: Vec::new(),
unaffected: Vec::new(),
severity: if kind == AdvisoryKind::Vulnerability {
Severity::Unknown
} else {
Severity::Informational
},
score: None,
title: title.into(),
url: None,
aliases: Vec::new(),
date: None,
}
}
}
pub fn cvss3_score(vector: &str) -> Option<f64> {
let mut parts = vector.trim().split('/');
let version = parts.next()?;
if version != "CVSS:3.0" && version != "CVSS:3.1" {
return None;
}
let mut metrics: BTreeMap<&str, &str> = BTreeMap::new();
for (metric, value) in parts.filter_map(|part| part.split_once(':')) {
if metrics.insert(metric, value).is_some() {
return None;
}
}
let get = |key: &str| metrics.get(key).copied();
let changed = match get("S")? {
"U" => false,
"C" => true,
_ => return None,
};
let av = match get("AV")? {
"N" => 0.85,
"A" => 0.62,
"L" => 0.55,
"P" => 0.2,
_ => return None,
};
let ac = match get("AC")? {
"L" => 0.77,
"H" => 0.44,
_ => return None,
};
let pr = match (get("PR")?, changed) {
("N", _) => 0.85,
("L", false) => 0.62,
("L", true) => 0.68,
("H", false) => 0.27,
("H", true) => 0.5,
_ => return None,
};
let ui = match get("UI")? {
"N" => 0.85,
"R" => 0.62,
_ => return None,
};
let cia = |key: &str| match get(key)? {
"H" => Some(0.56),
"L" => Some(0.22),
"N" => Some(0.0),
_ => None,
};
let iss = 1.0 - (1.0 - cia("C")?) * (1.0 - cia("I")?) * (1.0 - cia("A")?);
let impact = if changed {
7.52 * (iss - 0.029) - 3.25 * (iss - 0.02f64).powi(15)
} else {
6.42 * iss
};
if impact <= 0.0 {
return Some(0.0);
}
let exploitability = 8.22 * av * ac * pr * ui;
let base = if changed {
1.08 * (impact + exploitability)
} else {
impact + exploitability
};
Some(round_up(base.min(10.0)))
}
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 severity_label(advisory: &Advisory) -> String {
let mut label = if advisory.kind == AdvisoryKind::Vulnerability {
advisory.severity.name().to_string()
} else if advisory.severity == Severity::Informational {
advisory.kind.name().to_string()
} else {
format!("{} {}", advisory.kind.name(), advisory.severity.name())
};
if let Some(score) = advisory.score {
label.push_str(&format!(" {score:.1}"));
}
label
}
#[derive(Clone, Debug, PartialEq, Default)]
pub struct AdvisoryReport {
advisories: Vec<Advisory>,
dependency_count: Option<u64>,
}
fn strings(value: Option<&Value>) -> Vec<String> {
value
.and_then(Value::as_array)
.map(|list| {
list.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
fn entry(value: &Value, kind: Option<AdvisoryKind>) -> Result<Advisory, DepsError> {
let package = value
.get("package")
.ok_or_else(|| DepsError::new("a cargo audit entry has no package"))?;
let (Some(name), Some(version)) = (
package.get("name").and_then(Value::as_str),
package.get("version").and_then(Value::as_str),
) else {
return Err(DepsError::new(
"a cargo audit entry's package has no name or version",
));
};
let advisory = value.get("advisory").filter(|a| !a.is_null());
let text = |key: &str| {
advisory
.and_then(|a| a.get(key))
.and_then(Value::as_str)
.map(str::to_string)
};
let kind = kind.unwrap_or_else(|| {
value
.get("kind")
.and_then(Value::as_str)
.or_else(|| {
advisory
.and_then(|a| a.get("informational"))
.and_then(Value::as_str)
})
.map_or(AdvisoryKind::Notice, AdvisoryKind::parse)
});
let id = text("id").unwrap_or_default();
let title = text("title").unwrap_or_else(|| match kind {
AdvisoryKind::Yanked => "this version was yanked from its registry".into(),
_ => String::new(),
});
let mut found = Advisory::new(id, kind, name, version, title);
found.url = text("url").or_else(|| {
found
.id
.starts_with("RUSTSEC-")
.then(|| format!("https://rustsec.org/advisories/{}", found.id))
});
found.date = text("date");
found.aliases = strings(advisory.and_then(|a| a.get("aliases")));
let versions = value.get("versions").filter(|v| !v.is_null());
found.patched = strings(versions.and_then(|v| v.get("patched")));
found.unaffected = strings(versions.and_then(|v| v.get("unaffected")));
if let Some(severity) = text("severity").as_deref().and_then(Severity::parse) {
found.severity = severity;
} else if let Some(score) = text("cvss").as_deref().and_then(cvss3_score) {
found.score = Some(score);
found.severity = Severity::from_score(score);
}
Ok(found)
}
impl AdvisoryReport {
pub fn new(advisories: Vec<Advisory>) -> Self {
AdvisoryReport {
advisories,
dependency_count: None,
}
}
#[must_use]
pub fn dependency_count(mut self, count: u64) -> Self {
self.dependency_count = Some(count);
self
}
pub fn from_cargo_audit(json: &str) -> Result<Self, DepsError> {
check_size(json, "the cargo audit report")?;
let value: Value = serde_json::from_str(json)
.map_err(|e| DepsError::new(format!("not cargo audit JSON: {e}")))?;
Self::from_cargo_audit_value(&value)
}
pub fn from_cargo_audit_value(value: &Value) -> Result<Self, DepsError> {
let vulnerabilities = value
.get("vulnerabilities")
.filter(|v| v.is_object())
.ok_or_else(|| DepsError::new("not cargo audit JSON: no `vulnerabilities`"))?;
let mut advisories = Vec::new();
let list = vulnerabilities
.get("list")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
check_count(list.len(), "advisories")?;
for item in list {
advisories.push(entry(item, Some(AdvisoryKind::Vulnerability))?);
}
match value.get("warnings") {
Some(Value::Object(by_kind)) => {
for (kind, list) in by_kind {
let list = list.as_array().ok_or_else(|| {
DepsError::new(format!("cargo audit warnings {kind:?} are not a list"))
})?;
check_count(advisories.len() + list.len(), "advisories")?;
for item in list {
advisories.push(entry(item, Some(AdvisoryKind::parse(kind)))?);
}
}
}
Some(Value::Array(list)) => {
check_count(advisories.len() + list.len(), "advisories")?;
for item in list {
advisories.push(entry(item, None)?);
}
}
Some(Value::Null) | None => {}
Some(_) => return Err(DepsError::new("cargo audit `warnings` is not a table")),
}
let mut report = AdvisoryReport::new(advisories);
report.dependency_count = value
.pointer("/lockfile/dependency-count")
.and_then(Value::as_u64);
Ok(report)
}
pub fn advisories(&self) -> &[Advisory] {
&self.advisories
}
pub fn vulnerabilities(&self) -> usize {
self.advisories
.iter()
.filter(|a| a.kind == AdvisoryKind::Vulnerability)
.count()
}
pub fn counts(&self) -> BTreeMap<Severity, usize> {
let mut counts = BTreeMap::new();
for advisory in &self.advisories {
*counts.entry(advisory.severity).or_insert(0) += 1;
}
counts
}
pub fn sorted(&self) -> Vec<&Advisory> {
let mut sorted: Vec<&Advisory> = self.advisories.iter().collect();
sorted.sort_by(|a, b| {
(a.severity, a.kind, &a.package, &a.version, &a.id)
.cmp(&(b.severity, b.kind, &b.package, &b.version, &b.id))
});
sorted
}
fn heading(&self, console: &Console) -> Text {
let vulnerabilities = self.vulnerabilities();
let warnings = self.advisories.len() - vulnerabilities;
let plural =
|n: usize, one: &str, many: &str| format!("{n} {}", if n == 1 { one } else { many });
let mut line = format!(
"{}, {}",
plural(vulnerabilities, "vulnerability", "vulnerabilities"),
plural(warnings, "warning", "warnings")
);
if let Some(count) = self.dependency_count {
line.push_str(&format!(" in {count} dependencies"));
}
let style = if vulnerabilities > 0 {
theme_style(console, "deps.critical")
} else {
theme_style(console, "deps.root")
};
let mut text = Text::styled(line, style);
let counts = self.counts();
if !counts.is_empty() {
text.append("\n", None);
for (index, (severity, count)) in counts.iter().enumerate() {
if index > 0 {
text.append(" · ", Some(theme_style(console, "deps.section").into()));
}
text.append(
&format!("{count} {severity}"),
Some(severity.style(console).into()),
);
}
}
text
}
fn table(&self, console: &Console) -> Option<Table> {
if self.advisories.is_empty() {
return None;
}
let sorted = self.sorted();
let widest = |cell: &dyn Fn(&Advisory) -> usize| sorted.iter().map(|a| cell(a)).max();
let fixed = |width: Option<usize>| ColumnOptions {
no_wrap: true,
min_width: width,
..ColumnOptions::default()
};
let mut table = Table::new();
table.add_column_with(
Text::new("Severity"),
fixed(widest(&|a| severity_label(a).len())),
);
table.add_column_with(Text::new("ID"), fixed(widest(&|a| cell_len(&clean(&a.id)))));
table.add_column("Crate");
table.add_column("Patched");
table.add_column("Title");
let mut previous = None;
for advisory in sorted {
if previous.is_some_and(|p| p != advisory.severity) {
table.add_section();
}
previous = Some(advisory.severity);
let severity = Text::styled(severity_label(advisory), advisory.severity.style(console));
let id_style = match &advisory.url {
Some(url) if !url.contains(char::is_control) => {
Style::default().with_link(url.clone())
}
_ => Style::default(),
};
let id = if advisory.id.is_empty() {
Text::styled("–", theme_style(console, "deps.off"))
} else {
Text::styled(clean(&advisory.id).into_owned(), id_style)
};
let patched = if advisory.patched.is_empty() {
Text::styled("no fix", theme_style(console, "deps.unknown"))
} else {
Text::new(clean(&advisory.patched.join(", ")).into_owned())
};
let mut krate = Text::new(clean(&advisory.package).into_owned());
krate.append(
&format!(" v{}", clean(&advisory.version)),
Some(theme_style(console, "deps.version").into()),
);
table.add_row_text(vec![
severity,
id,
krate,
patched,
Text::new(clean(&advisory.title).into_owned()),
]);
}
Some(table)
}
}
impl Renderable for AdvisoryReport {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let heading = self.heading(console);
match self.table(console) {
Some(table) => stack(&[&heading, &table], console, options),
None => heading.rich_render(console, options),
}
}
fn measure(&self, console: &Console, options: &ConsoleOptions) -> rich::measure::Measurement {
let heading = self.heading(console);
match self.table(console) {
Some(table) => stack_measure(&[&heading, &table], console, options),
None => heading.measure(console, options),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cvss3_scores_match_the_specification() {
let score = |v: &str| cvss3_score(v).unwrap();
assert_eq!(score("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"), 9.8);
assert_eq!(score("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H"), 10.0);
assert_eq!(score("CVSS:3.0/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N"), 5.5);
assert_eq!(score("CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N"), 6.1);
assert_eq!(score("CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H"), 5.9);
assert_eq!(score("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N"), 0.0);
assert_eq!(cvss3_score("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N"), None);
assert_eq!(cvss3_score("CVSS:3.1/AV:N"), None);
assert_eq!(
cvss3_score("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H/AV:P"),
None
);
assert_eq!(cvss3_score(""), None);
}
#[test]
fn severities_band_as_cvss_does() {
assert_eq!(Severity::from_score(9.0), Severity::Critical);
assert_eq!(Severity::from_score(8.9), Severity::High);
assert_eq!(Severity::from_score(4.0), Severity::Medium);
assert_eq!(Severity::from_score(0.1), Severity::Low);
assert_eq!(Severity::from_score(0.0), Severity::Informational);
assert_eq!(Severity::parse("Moderate"), Some(Severity::Medium));
}
#[test]
fn the_old_warning_list_is_read() {
let json = r#"{"vulnerabilities": {"found": false, "count": 0, "list": []},
"warnings": [{"kind": "unmaintained", "package": {"name": "a", "version": "1.0.0"},
"advisory": {"id": "RUSTSEC-2021-0001", "title": "a is unmaintained"}}]}"#;
let report = AdvisoryReport::from_cargo_audit(json).unwrap();
assert_eq!(report.vulnerabilities(), 0);
let a = &report.advisories()[0];
assert_eq!(a.kind, AdvisoryKind::Unmaintained);
assert_eq!(a.severity, Severity::Informational);
assert_eq!(
a.url.as_deref(),
Some("https://rustsec.org/advisories/RUSTSEC-2021-0001")
);
}
#[test]
fn malformed_reports_are_errors() {
for bad in [
"",
"[]",
"{}",
r#"{"vulnerabilities": {"list": [{}]}}"#,
r#"{"vulnerabilities": {"list": [{"package": {"name": "a"}}]}}"#,
r#"{"vulnerabilities": {}, "warnings": {"yanked": 3}}"#,
r#"{"vulnerabilities": {}, "warnings": 3}"#,
] {
assert!(AdvisoryReport::from_cargo_audit(bad).is_err(), "{bad}");
}
}
#[test]
fn an_empty_report_is_one_line() {
let report = AdvisoryReport::new(Vec::new()).dependency_count(12);
let console = Console::builder().width(60).color_system(None).build();
assert_eq!(
console.render_to_string(&report),
"0 vulnerabilities, 0 warnings in 12 dependencies"
);
}
}