cargo_audit/
presenter.rs

1//! Presenter for `rustsec::Report` information.
2
3use crate::{
4    config::{DenyOption, OutputConfig, OutputFormat},
5    prelude::*,
6};
7use abscissa_core::terminal::{
8    self,
9    Color::{self, Red, Yellow},
10};
11use rustsec::{
12    advisory::License,
13    cargo_lock::{
14        dependency::{self, graph::EdgeDirection, Dependency},
15        Lockfile, Package,
16    },
17    WarningKind,
18};
19use std::{collections::BTreeSet as Set, io, path::Path};
20use std::{io::Write as _, string::ToString as _};
21
22#[cfg(feature = "binary-scanning")]
23use rustsec::binary_scanning::BinaryReport;
24
25/// Vulnerability information presenter
26#[derive(Clone, Debug)]
27pub struct Presenter {
28    /// Keep track packages we've displayed once so we don't show the same dep tree
29    // TODO(tarcieri): group advisories about the same package?
30    displayed_packages: Set<Dependency>,
31
32    /// Keep track of the warning kinds that correspond to deny-warnings options
33    deny_warning_kinds: Set<WarningKind>,
34
35    /// Output configuration
36    config: OutputConfig,
37}
38
39impl Presenter {
40    /// Create a new vulnerability information presenter
41    pub fn new(config: &OutputConfig) -> Self {
42        Self {
43            displayed_packages: Set::new(),
44            deny_warning_kinds: config
45                .deny
46                .iter()
47                .flat_map(|k| k.get_warning_kind())
48                .copied()
49                .collect(),
50            config: config.clone(),
51        }
52    }
53
54    /// Information to display before a report is generated
55    pub fn before_report(&mut self, path: &Path, lockfile: &Lockfile) {
56        if !self.config.is_quiet() {
57            status_ok!(
58                "Scanning",
59                "{} for vulnerabilities ({} crate dependencies)",
60                path.display(),
61                lockfile.packages.len(),
62            );
63        }
64    }
65
66    #[cfg(feature = "binary-scanning")]
67    /// Information to display before a binary file is scanned
68    pub fn binary_scan_report(&mut self, report: &BinaryReport, path: &Path) {
69        use rustsec::binary_scanning::BinaryReport::*;
70        if !self.config.is_quiet() {
71            match report {
72                Complete(lockfile) => status_ok!(
73                    "Found",
74                    "'cargo auditable' data in {} ({} dependencies)",
75                    path.display(),
76                    lockfile.packages.len()
77                ),
78                Incomplete(lockfile) => {
79                    status_warn!(
80                        "{} was not built with 'cargo auditable', the report will be incomplete ({} dependencies recovered)",
81                        path.display(), lockfile.packages.len());
82                }
83                None => status_err!(
84                    "No dependency information found in {}! Is it a Rust program built with cargo?",
85                    path.display(),
86                ),
87            }
88        }
89    }
90
91    fn warning_word(&self, count: u64) -> &str {
92        if count != 1 {
93            "warnings"
94        } else {
95            "warning"
96        }
97    }
98
99    /// Print the vulnerability report generated by an audit
100    pub fn print_report(
101        &mut self,
102        report: &rustsec::Report,
103        lockfile: &Lockfile,
104        path: Option<&Path>,
105    ) {
106        if self.config.format == OutputFormat::Json {
107            serde_json::to_writer(io::stdout(), &report).unwrap();
108            io::stdout().flush().unwrap();
109            return;
110        }
111
112        let tree = lockfile
113            .dependency_tree()
114            .expect("invalid Cargo.lock dependency tree");
115
116        // NOTE: when modifying the following logic, be sure to also update should_exit_with_failure()
117
118        // Print out vulnerabilities and warnings
119        for vulnerability in &report.vulnerabilities.list {
120            self.print_vulnerability(vulnerability, &tree);
121        }
122
123        for warnings in report.warnings.values() {
124            for warning in warnings.iter() {
125                self.print_warning(warning, &tree)
126            }
127        }
128
129        if report.vulnerabilities.found {
130            if report.vulnerabilities.count == 1 {
131                match path {
132                    Some(path) => status_err!("1 vulnerability found in {}", path.display()),
133                    None => status_err!("1 vulnerability found!"),
134                }
135            } else {
136                match path {
137                    Some(path) => status_err!(
138                        "{} vulnerabilities found in {}",
139                        report.vulnerabilities.count,
140                        path.display()
141                    ),
142                    None => status_err!("{} vulnerabilities found!", report.vulnerabilities.count),
143                }
144            }
145        }
146
147        let (num_denied, num_not_denied) = self.count_warnings(report);
148
149        if num_denied > 0 || num_not_denied > 0 {
150            if num_denied > 0 {
151                match path {
152                    Some(path) => status_err!(
153                        "{} denied {} found in {}",
154                        num_denied,
155                        self.warning_word(num_denied),
156                        path.display(),
157                    ),
158                    None => status_err!(
159                        "{} denied {} found!",
160                        num_denied,
161                        self.warning_word(num_denied)
162                    ),
163                }
164            }
165            if num_not_denied > 0 {
166                match path {
167                    Some(path) => status_warn!(
168                        "{} allowed {} found in {}",
169                        num_not_denied,
170                        self.warning_word(num_not_denied),
171                        path.display(),
172                    ),
173                    None => status_warn!(
174                        "{} allowed {} found",
175                        num_not_denied,
176                        self.warning_word(num_not_denied)
177                    ),
178                }
179            }
180        }
181    }
182
183    /// Print the vulnerability report for cargo-audit
184    pub fn print_self_report(&mut self, self_advisories: &[rustsec::Advisory]) {
185        if self_advisories.is_empty() {
186            return;
187        }
188        // Print out any self-advisories
189        let msg = "This copy of cargo-audit has known advisories! Upgrade cargo-audit to the \
190        latest version: cargo install --force cargo-audit --locked";
191
192        if self.config.deny.contains(&DenyOption::Warnings) {
193            status_err!(msg);
194        } else {
195            status_warn!(msg);
196        }
197
198        for advisory in self_advisories {
199            self.print_metadata(
200                &advisory.metadata,
201                self.warning_color(self.config.deny.contains(&DenyOption::Warnings)),
202            );
203        }
204        println!();
205    }
206
207    /// Determines whether the process should exit with failure based on configuration
208    /// such as --deny=warnings
209    #[must_use]
210    pub fn should_exit_with_failure(&self, report: &rustsec::Report) -> bool {
211        if report.vulnerabilities.found {
212            return true;
213        }
214        let (denied, _allowed) = self.count_warnings(report);
215        if denied != 0 {
216            return true;
217        }
218        false
219    }
220
221    /// Determines whether the process should exit with failure based on configuration
222    /// such as --deny=warnings
223    #[must_use]
224    pub fn should_exit_with_failure_due_to_self(
225        &self,
226        self_advisories: &[rustsec::Advisory],
227    ) -> bool {
228        !self_advisories.is_empty() && self.config.deny.contains(&DenyOption::Warnings)
229    }
230
231    /// Count up the warnings, sorting into denied and allowed.
232    /// Returns `(denied, allowed)`
233    fn count_warnings(&self, report: &rustsec::Report) -> (u64, u64) {
234        let mut num_denied: u64 = 0;
235        let mut num_not_denied: u64 = 0;
236
237        for (kind, warnings) in report.warnings.iter() {
238            if self.deny_warning_kinds.contains(kind) {
239                num_denied += warnings.len() as u64;
240            } else {
241                num_not_denied += warnings.len() as u64;
242            }
243        }
244        (num_denied, num_not_denied)
245    }
246
247    /// Print information about the given vulnerability
248    fn print_vulnerability(
249        &mut self,
250        vulnerability: &rustsec::Vulnerability,
251        tree: &dependency::Tree,
252    ) {
253        self.print_attr(Red, "Crate:    ", &vulnerability.package.name);
254        self.print_attr(Red, "Version:  ", vulnerability.package.version.to_string());
255        self.print_metadata(&vulnerability.advisory, Red);
256
257        if vulnerability.versions.patched().is_empty() {
258            self.print_attr(Red, "Solution: ", "No fixed upgrade is available!");
259        } else {
260            self.print_attr(
261                Red,
262                "Solution: ",
263                format!(
264                    "Upgrade to {}",
265                    vulnerability
266                        .versions
267                        .patched()
268                        .iter()
269                        .map(ToString::to_string)
270                        .collect::<Vec<_>>()
271                        .as_slice()
272                        .join(" OR ")
273                ),
274            );
275        }
276
277        self.print_tree(Red, &vulnerability.package, tree);
278        println!();
279    }
280
281    /// Print information about a given warning
282    fn print_warning(&mut self, warning: &rustsec::Warning, tree: &dependency::Tree) {
283        let color = self.warning_color(self.deny_warning_kinds.contains(&warning.kind));
284
285        self.print_attr(color, "Crate:    ", &warning.package.name);
286        self.print_attr(color, "Version:  ", warning.package.version.to_string());
287        self.print_attr(color, "Warning:  ", warning.kind.as_str());
288
289        if let Some(metadata) = &warning.advisory {
290            self.print_metadata(metadata, color)
291        }
292
293        self.print_tree(color, &warning.package, tree);
294        println!();
295    }
296
297    /// Get the color to use when displaying warnings
298    fn warning_color(&self, deny_warning: bool) -> Color {
299        if deny_warning {
300            Red
301        } else {
302            Yellow
303        }
304    }
305
306    /// Print a warning about a particular advisory
307    fn print_metadata(&self, metadata: &rustsec::advisory::Metadata, color: Color) {
308        self.print_attr(color, "Title:    ", &metadata.title);
309        self.print_attr(color, "Date:     ", &metadata.date);
310        self.print_attr(color, "ID:       ", &metadata.id);
311
312        if metadata.license == License::CcBy40 {
313            // We must preserve the original URL from the `url` field
314            if let Some(url) = &metadata.url {
315                self.print_attr(color, "URL:      ", url);
316            } else if let Some(url) = &metadata.id.url() {
317                self.print_attr(color, "URL:      ", url);
318            }
319        } else {
320            // Prefer ID URL because the `url` field usually points to a bug tracker
321            // or any other non-canonical source rather than an actual security advisory
322            if let Some(url) = &metadata.id.url() {
323                self.print_attr(color, "URL:      ", url);
324            } else if let Some(url) = &metadata.url {
325                self.print_attr(color, "URL:      ", url);
326            }
327        }
328
329        if let Some(cvss) = &metadata.cvss {
330            self.print_attr(
331                color,
332                "Severity: ",
333                format!("{} ({})", cvss.score().value(), cvss.score().severity()),
334            );
335        }
336    }
337
338    /// Display an attribute of a particular vulnerability
339    fn print_attr(&self, color: Color, attr: &str, content: impl AsRef<str>) {
340        terminal::status::Status::new()
341            .bold()
342            .color(color)
343            .status(attr)
344            .print_stdout(content.as_ref())
345            .unwrap();
346    }
347
348    /// Print the inverse dependency tree to standard output
349    fn print_tree(&mut self, color: Color, package: &Package, tree: &dependency::Tree) {
350        // Only show the tree once per package
351        if !self.displayed_packages.insert(Dependency::from(package)) {
352            return;
353        }
354
355        if !self.config.show_tree.unwrap_or(true) {
356            return;
357        }
358
359        terminal::status::Status::new()
360            .bold()
361            .color(color)
362            .status("Dependency tree:\n")
363            .print_stdout("")
364            .unwrap();
365
366        let package_node = tree.nodes()[&Dependency::from(package)];
367        tree.render(
368            &mut io::stdout(),
369            package_node,
370            EdgeDirection::Incoming,
371            false,
372        )
373        .unwrap();
374    }
375}