use std::{collections::BTreeSet as Set, io, path::Path};
use std::{io::Write as _, string::ToString as _};
use abscissa_core::terminal::{
self,
Color::{self, Red, Yellow},
};
use rustsec::{
Vulnerability, Warning, WarningKind,
advisory::License,
cargo_lock::{
Lockfile, Package,
dependency::{Dependency, Tree, graph::EdgeDirection},
},
};
#[cfg(feature = "binary-scanning")]
use rustsec::{advisory::affected::FunctionPath, binary_scanning::BinaryReport};
#[cfg(feature = "binary-scanning")]
use crate::binary_scanning::SymbolSet;
use crate::{
config::{DenyOption, OutputConfig, OutputFormat},
prelude::*,
};
#[derive(Clone, Debug)]
pub struct Presenter {
displayed_packages: Set<Dependency>,
deny_warning_kinds: Set<WarningKind>,
config: OutputConfig,
#[cfg(feature = "binary-scanning")]
binary_contents: Option<Vec<u8>>,
}
impl Presenter {
pub fn new(config: &OutputConfig) -> Self {
Self {
displayed_packages: Set::new(),
deny_warning_kinds: config
.deny
.iter()
.flat_map(|k| k.get_warning_kind())
.copied()
.collect(),
config: config.clone(),
#[cfg(feature = "binary-scanning")]
binary_contents: None,
}
}
#[cfg(feature = "binary-scanning")]
pub fn set_binary_contents(&mut self, contents: Vec<u8>) {
self.binary_contents = Some(contents);
}
pub fn before_report(&mut self, path: &Path, lockfile: &Lockfile) {
if !self.config.is_quiet() {
status_ok!(
"Scanning",
"{} for vulnerabilities ({} crate dependencies)",
path.display(),
lockfile.packages.len(),
);
}
}
#[cfg(feature = "binary-scanning")]
pub fn binary_scan_report(&mut self, report: &BinaryReport, path: &Path) {
use rustsec::binary_scanning::BinaryReport::*;
if !self.config.is_quiet() {
match report {
Complete(lockfile) => status_ok!(
"Found",
"'cargo auditable' data in {} ({} dependencies)",
path.display(),
lockfile.packages.len()
),
Incomplete(lockfile) => {
status_warn!(
"{} was not built with 'cargo auditable', the report will be incomplete ({} dependencies recovered)",
path.display(),
lockfile.packages.len()
);
}
None => status_err!(
"No dependency information found in {}! Is it a Rust program built with cargo?",
path.display(),
),
}
}
}
fn warning_word(&self, count: u64) -> &str {
if count != 1 { "warnings" } else { "warning" }
}
pub fn print_report(
&mut self,
report: &rustsec::Report,
lockfile: &Lockfile,
path: Option<&Path>,
) {
match self.config.format {
OutputFormat::Json => {
let mut stdout = io::stdout().lock();
serde_json::to_writer(&mut stdout, &report).unwrap();
writeln!(&mut stdout).unwrap();
return;
}
OutputFormat::Sarif => {
let cargo_lock_path = path
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| "Cargo.lock".to_string());
let sarif_log = crate::sarif::SarifLog::from_report(report, &cargo_lock_path);
let mut stdout = io::stdout().lock();
serde_json::to_writer(&mut stdout, &sarif_log).unwrap();
writeln!(&mut stdout).unwrap();
return;
}
OutputFormat::Terminal => {
}
}
let tree = lockfile
.dependency_tree()
.expect("invalid Cargo.lock dependency tree");
#[cfg(feature = "binary-scanning")]
let symbols = match &self.binary_contents {
Some(binary_contents) => {
let packages = report
.vulnerabilities
.list
.iter()
.map(|v| &v.package)
.chain(report.warnings.values().flatten().map(|w| &w.package));
match SymbolSet::from_file(binary_contents, packages) {
Ok(symbols) => Some(symbols),
Err(e) => {
status_warn!(
"Failed to extract symbols from binary for affected-function analysis: {}",
e
);
None
}
}
}
None => None,
};
for vulnerability in &report.vulnerabilities.list {
self.print_vulnerability(vulnerability);
#[cfg(feature = "binary-scanning")]
if let Some(symbols) = &symbols {
self.print_affected(
Red,
symbols.filter(vulnerability.affected_functions().unwrap_or_default()),
);
}
self.print_tree(Red, &vulnerability.package, &tree);
println!();
}
for warnings in report.warnings.values() {
for warning in warnings.iter() {
let color = self.warning_color(self.deny_warning_kinds.contains(&warning.kind));
self.print_warning(warning, color);
#[cfg(feature = "binary-scanning")]
if let Some(symbols) = &symbols {
self.print_affected(
color,
symbols.filter(
warning
.affected
.as_ref()
.map(|affected| affected.functions.iter())
.unwrap_or_default()
.filter_map(|(path, version_reqs)| {
if version_reqs
.iter()
.any(|req| req.matches(&warning.package.version))
{
Some(path.clone())
} else {
None
}
}),
),
);
}
self.print_tree(color, &warning.package, &tree);
println!();
}
}
if report.vulnerabilities.found {
if report.vulnerabilities.count == 1 {
match path {
Some(path) => status_err!("1 vulnerability found in {}", path.display()),
None => status_err!("1 vulnerability found!"),
}
} else {
match path {
Some(path) => status_err!(
"{} vulnerabilities found in {}",
report.vulnerabilities.count,
path.display()
),
None => status_err!("{} vulnerabilities found!", report.vulnerabilities.count),
}
}
}
let (num_denied, num_not_denied) = self.count_warnings(report);
if num_denied > 0 || num_not_denied > 0 {
if num_denied > 0 {
match path {
Some(path) => status_err!(
"{} denied {} found in {}",
num_denied,
self.warning_word(num_denied),
path.display(),
),
None => status_err!(
"{} denied {} found!",
num_denied,
self.warning_word(num_denied)
),
}
}
if num_not_denied > 0 {
match path {
Some(path) => status_warn!(
"{} allowed {} found in {}",
num_not_denied,
self.warning_word(num_not_denied),
path.display(),
),
None => status_warn!(
"{} allowed {} found",
num_not_denied,
self.warning_word(num_not_denied)
),
}
}
}
}
pub fn print_self_report(&mut self, self_advisories: &[rustsec::Advisory]) {
if self_advisories.is_empty() {
return;
}
let msg = "This copy of cargo-audit has known advisories! Upgrade cargo-audit to the \
latest version: cargo install --force cargo-audit";
if self.config.deny.contains(&DenyOption::Warnings) {
status_err!(msg);
} else {
status_warn!(msg);
}
for advisory in self_advisories {
self.print_metadata(
&advisory.metadata,
self.warning_color(self.config.deny.contains(&DenyOption::Warnings)),
);
}
println!();
}
#[must_use]
pub fn should_exit_with_failure(&self, report: &rustsec::Report) -> bool {
if report.vulnerabilities.found {
return true;
}
let (denied, _allowed) = self.count_warnings(report);
if denied != 0 {
return true;
}
false
}
#[must_use]
pub fn should_exit_with_failure_due_to_self(
&self,
self_advisories: &[rustsec::Advisory],
) -> bool {
!self_advisories.is_empty() && self.config.deny.contains(&DenyOption::Warnings)
}
fn count_warnings(&self, report: &rustsec::Report) -> (u64, u64) {
let mut num_denied: u64 = 0;
let mut num_not_denied: u64 = 0;
for (kind, warnings) in report.warnings.iter() {
if self.deny_warning_kinds.contains(kind) {
num_denied += warnings.len() as u64;
} else {
num_not_denied += warnings.len() as u64;
}
}
(num_denied, num_not_denied)
}
fn print_vulnerability(&self, vulnerability: &Vulnerability) {
self.print_attr(Red, "Crate: ", &vulnerability.package.name);
self.print_attr(Red, "Version: ", vulnerability.package.version.to_string());
self.print_metadata(&vulnerability.advisory, Red);
if vulnerability.versions.patched().is_empty() {
self.print_attr(Red, "Solution: ", "No fixed upgrade is available!");
} else {
self.print_attr(
Red,
"Solution: ",
format!(
"Upgrade to {}",
vulnerability
.versions
.patched()
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.as_slice()
.join(" OR ")
),
);
}
}
fn print_warning(&self, warning: &Warning, color: Color) {
self.print_attr(color, "Crate: ", &warning.package.name);
self.print_attr(color, "Version: ", warning.package.version.to_string());
self.print_attr(color, "Warning: ", warning.kind.as_str());
if let Some(metadata) = &warning.advisory {
self.print_metadata(metadata, color)
}
}
fn warning_color(&self, deny_warning: bool) -> Color {
if deny_warning { Red } else { Yellow }
}
fn print_metadata(&self, metadata: &rustsec::advisory::Metadata, color: Color) {
self.print_attr(color, "Title: ", &metadata.title);
self.print_attr(color, "Date: ", &metadata.date);
self.print_attr(color, "ID: ", &metadata.id);
if metadata.license == License::CcBy40 {
if let Some(url) = &metadata.url {
self.print_attr(color, "URL: ", url);
} else if let Some(url) = &metadata.id.url() {
self.print_attr(color, "URL: ", url);
}
} else {
if let Some(url) = &metadata.id.url() {
self.print_attr(color, "URL: ", url);
} else if let Some(url) = &metadata.url {
self.print_attr(color, "URL: ", url);
}
}
if let Some(cvss) = &metadata.cvss {
self.print_attr(
color,
"Severity: ",
format!("{} ({})", cvss.score(), cvss.severity()),
);
}
}
fn print_attr(&self, color: Color, attr: &str, content: impl AsRef<str>) {
terminal::status::Status::new()
.bold()
.color(color)
.status(attr)
.print_stdout(content.as_ref())
.unwrap();
}
#[cfg(feature = "binary-scanning")]
fn print_affected(&self, color: Color, funcs: impl IntoIterator<Item = FunctionPath>) {
let mut funcs = funcs.into_iter().peekable();
if funcs.peek().is_none() {
return;
}
self.print_attr(
color,
"Affected: ",
funcs
.map(|func| func.to_string())
.collect::<Vec<_>>()
.join(", "),
);
}
fn print_tree(&mut self, color: Color, package: &Package, tree: &Tree) {
if !self.displayed_packages.insert(Dependency::from(package)) {
return;
}
if !self.config.show_tree {
return;
}
terminal::status::Status::new()
.bold()
.color(color)
.status("Dependency tree:\n")
.print_stdout("")
.unwrap();
let package_node = tree.nodes()[&Dependency::from(package)];
tree.render(
&mut io::stdout(),
package_node,
EdgeDirection::Incoming,
false,
)
.unwrap();
}
}
#[cfg(test)]
mod tests {
use abscissa_core::testing::{CmdRunner, process::Process};
use once_cell::sync::Lazy;
use std::{
collections::{BTreeMap, BTreeSet},
io::Read,
path::Path,
str::from_utf8,
};
use tempfile::TempDir;
#[test]
fn affected_functions() {
let binary_path = Path::new("tests/support/binaries/binary-with-affected-functions");
let mut cmd = RUNNER.clone();
let mut process = cmd.arg(binary_path).capture_stdout().capture_stderr().run();
let reports = read_process_stdout(&mut process);
let status = process.wait().unwrap();
assert_eq!(1, status.code());
for (id, function_paths) in EXPECTED_FUNCTION_PATHS {
let report = reports
.iter()
.find(|map| map.get("ID").unwrap() == id)
.unwrap_or_else(|| panic!("failed to find {id}"));
let affected = report
.get("Affected")
.unwrap_or_else(|| panic!("{id} has no 'Affected' line"));
assert_eq!(function_paths.join(", "), *affected);
}
for report in reports {
if !report.contains_key("Affected") {
continue;
}
let id = report.get("ID").unwrap();
assert!(
EXPECTED_FUNCTION_PATHS.iter().any(|(key, _)| key == id),
"{id} has unexpected 'Affected' line"
);
}
}
fn read_process_stdout(process: &mut Process<'_>) -> BTreeSet<BTreeMap<String, String>> {
let stdout = process.stdout();
let mut buf = Vec::new();
stdout.read_to_end(&mut buf).unwrap();
let s = from_utf8(&buf).unwrap();
let mut reports = BTreeSet::new();
let mut report = BTreeMap::new();
for line in s.lines() {
let Some(index) = line.as_bytes().iter().position(|&x| x == b':') else {
continue;
};
let key = &line[..index];
let value = line[index + 1..].trim_start();
if key == "Crate" && !report.is_empty() {
reports.insert(report);
report = BTreeMap::new();
}
report.insert(key.to_owned(), value.to_owned());
}
if !report.is_empty() {
reports.insert(report);
}
reports
}
#[rustfmt::skip]
const EXPECTED_FUNCTION_PATHS: &[(&str, &[&str])] = &[
("RUSTSEC-2019-0036", &["failure::Fail::__private_get_type_id__"]),
("RUSTSEC-2020-0071", &["time::OffsetDateTime::now_local", "time::OffsetDateTime::try_now_local", "time::UtcOffset::current_local_offset", "time::UtcOffset::local_offset_at", "time::UtcOffset::try_current_local_offset", "time::UtcOffset::try_local_offset_at"]),
("RUSTSEC-2020-0075", &["branca::Branca::decode", "branca::decode"]),
("RUSTSEC-2021-0041", &["parse_duration::parse"]),
("RUSTSEC-2022-0004", &["rustc_serialize::json::Json::from_str"]),
("RUSTSEC-2022-0067", &["lzf::compress", "lzf::decompress"]),
("RUSTSEC-2023-0032", &["ntru::types::PrivateKey::export", "ntru::types::PublicKey::export"]),
("RUSTSEC-2023-0054", &["mail_internals::utils::vec_insert_bytes"]),
("RUSTSEC-2024-0018", &["crayon::utils::object_pool::ObjectPool<H,T>::free"]),
("RUSTSEC-2024-0020", &["whoami::realname", "whoami::realname_os", "whoami::username", "whoami::username_os"]),
("RUSTSEC-2024-0360", &["xmp_toolkit::XmpFile::close"]),
("RUSTSEC-2024-0401", &["zlib_rs::inflate::inflate"]),
("RUSTSEC-2024-0404", &["anstream::adapter::strip_str"]),
("RUSTSEC-2024-0442", &["wasmtime_jit_debug::perf_jitdump::JitDumpFile::dump_code_load_record"]),
("RUSTSEC-2025-0027", &["mp3_metadata::read_from_slice"]),
("RUSTSEC-2025-0113", &["shaman::cryptoutil::read_u32v_be", "shaman::cryptoutil::read_u32v_le", "shaman::cryptoutil::read_u64v_be", "shaman::cryptoutil::read_u64v_le", "shaman::cryptoutil::write_u32v_le", "shaman::cryptoutil::write_u64v_le"]),
("RUSTSEC-2025-0131", &["rtvm_interpreter::Interpreter::program_counter"]),
("RUSTSEC-2025-0136", &["sequoia_openpgp::crypto::ecdh::aes_key_unwrap"]),
("RUSTSEC-2025-0137", &["ruint::algorithms::div::reciprocal_mg10"]),
("RUSTSEC-2025-0140", &["gix_date::parse::TimeBuf::as_str"]),
("RUSTSEC-2026-0097", &["rand::rng"]),
];
static RUNNER: Lazy<CmdRunner> = Lazy::new(|| {
let mut runner = CmdRunner::default();
runner
.arg("audit")
.arg("--color=never")
.arg("--db")
.arg(ADVISORY_DB_DIR.path())
.arg("bin");
runner
});
static ADVISORY_DB_DIR: Lazy<TempDir> = Lazy::new(|| TempDir::new().unwrap());
}