Skip to main content

kagami/
output.rs

1use crate::OutputFormat;
2use crate::advisory::{Advisory, CvssScore, Epss, Severity, TargetWithAdvisory, Vulnerability};
3use crate::error::KagamiError;
4use chrono::{DateTime, Utc};
5use console::{Color, Term, style};
6use log::trace;
7use std::error::Error;
8
9pub fn output_to_stdout(
10    output_format: &OutputFormat,
11    results: &Vec<TargetWithAdvisory>,
12) -> Result<(), Box<dyn Error>> {
13    match output_format {
14        OutputFormat::Json => {
15            let json = serde_json::to_string(&results)?;
16            println!("{json}");
17        }
18        OutputFormat::Yaml => {
19            let yaml = yaml_serde::to_string(&results)?;
20            println!("{yaml}");
21        }
22        OutputFormat::Text => output_text(results)?,
23        OutputFormat::SimpleMarkdownText => output_simple_markdown_text(results)?,
24    };
25    Ok(())
26}
27
28fn output_simple_markdown_text(targets: &[TargetWithAdvisory]) -> Result<(), Box<dyn Error>> {
29    let stdout_terminal = Term::stdout();
30    let not_vulnerable = targets
31        .iter()
32        .flat_map(|v| &v.advisories)
33        .collect::<Vec<_>>()
34        .is_empty();
35
36    if not_vulnerable {
37        stdout_terminal.write_line("✅ There is no vulnerable targets!")?;
38        return Ok(());
39    }
40
41    let vulnerable_targets = targets
42        .iter()
43        .filter(|v| !v.advisories.is_empty())
44        .collect::<Vec<&TargetWithAdvisory>>();
45
46    stdout_terminal.write_line(
47        format!(
48            "⚠️ *{}* vulnerable target(s) detected! ⚠️",
49            vulnerable_targets.len()
50        )
51        .as_str(),
52    )?;
53
54    for target in vulnerable_targets {
55        stdout_terminal.write_line(
56            format!(
57                "`{}@{}`{}",
58                target
59                    .name
60                    .as_ref()
61                    .or(target.repository_path.as_ref())
62                    .unwrap_or(&"".to_string()),
63                target.version,
64                match &target.ecosystem {
65                    Some(ecosystem) => format!(" ({})", ecosystem),
66                    None => "".to_string(),
67                }
68            )
69            .as_str(),
70        )?;
71
72        for advisory in &target.advisories {
73            stdout_terminal.write_line(
74                format!(
75                    "{} [{}] {}",
76                    emoji_severity(advisory.severity),
77                    advisory.severity,
78                    advisory.html_url
79                )
80                .as_str(),
81            )?;
82        }
83    }
84
85    Ok(())
86}
87
88fn output_text(targets: &Vec<TargetWithAdvisory>) -> Result<(), Box<dyn Error>> {
89    let stdout_terminal = Term::stdout();
90    let target_count = targets.len();
91    let critical_count = get_count_by_severity_for_target(targets, Severity::Critical);
92    let high_count = get_count_by_severity_for_target(targets, Severity::High);
93    let medium_count = get_count_by_severity_for_target(targets, Severity::Medium);
94    let low_count = get_count_by_severity_for_target(targets, Severity::Low);
95    let unknown_count = get_count_by_severity_for_target(targets, Severity::Unknown);
96    let total_count = critical_count + high_count + medium_count + low_count + unknown_count;
97
98    stdout_terminal.write_line(
99        format!(
100            "{} Advisories found for {} targets - {} {}, {} {}, {} {}, {} {}, {} {}",
101            style(total_count).bold().cyan(),
102            style(target_count).bold().cyan(),
103            styled_severity(Severity::Critical),
104            style(critical_count).bold(),
105            styled_severity(Severity::High),
106            style(high_count).bold(),
107            styled_severity(Severity::Medium),
108            style(medium_count).bold(),
109            styled_severity(Severity::Low),
110            style(low_count).bold(),
111            styled_severity(Severity::Unknown),
112            style(unknown_count).bold()
113        )
114        .as_str(),
115    )?;
116
117    for target in targets {
118        // Target header
119        print_console_divider(&stdout_terminal)?;
120        stdout_terminal.write_line(
121            format!(
122                "📦 {}@{} {} {}",
123                style(
124                    target
125                        .name
126                        .as_ref()
127                        .or(target.repository_path.as_ref())
128                        .unwrap_or(&"".to_string())
129                )
130                .cyan()
131                .bold(),
132                style(&target.version).cyan().bold(),
133                style(target.advisory_type.to_string()).dim(),
134                style(target.ecosystem.as_ref().unwrap_or(&"".to_string())).dim()
135            )
136            .as_str(),
137        )?;
138        print_console_divider(&stdout_terminal)?;
139
140        // Counts for target
141        let critical_count =
142            get_count_by_severity_for_advisories(&target.advisories, Severity::Critical);
143        let high_count = get_count_by_severity_for_advisories(&target.advisories, Severity::High);
144        let medium_count =
145            get_count_by_severity_for_advisories(&target.advisories, Severity::Medium);
146        let low_count = get_count_by_severity_for_advisories(&target.advisories, Severity::Low);
147        let unknown_count =
148            get_count_by_severity_for_advisories(&target.advisories, Severity::Unknown);
149        let total_count = critical_count + high_count + medium_count + low_count + unknown_count;
150
151        stdout_terminal.write_line(
152            format!(
153                "{} Advisories found - {} {}, {} {}, {} {}, {} {}, {} {}",
154                style(total_count).bold().cyan(),
155                styled_severity(Severity::Critical),
156                style(critical_count).bold(),
157                styled_severity(Severity::High),
158                style(high_count).bold(),
159                styled_severity(Severity::Medium),
160                style(medium_count).bold(),
161                styled_severity(Severity::Low),
162                style(low_count).bold(),
163                styled_severity(Severity::Unknown),
164                style(unknown_count).bold()
165            )
166            .as_str(),
167        )?;
168
169        for advisory in &target.advisories {
170            // Title line
171            stdout_terminal.write_line(
172                format!(
173                    "{} - {}",
174                    styled_severity(advisory.severity),
175                    style(&advisory.summary).cyan().bold()
176                )
177                .as_str(),
178            )?;
179            // Detailed output
180            print_advisory_details(&stdout_terminal, advisory)?;
181        }
182    }
183
184    Ok(())
185}
186
187fn styled_severity(severity: Severity) -> String {
188    match severity {
189        Severity::Unknown => style("Unknown").bold().white().bg(Color::Black),
190        Severity::Low => style("Low").bold().green(),
191        Severity::Medium => style("Medium").bold().yellow(),
192        Severity::High => style("High").bold().red(),
193        Severity::Critical => style("Critical").bold().white().bg(Color::Red),
194    }
195    .to_string()
196}
197
198fn emoji_severity(severity: Severity) -> String {
199    match severity {
200        Severity::Unknown => "⚪",
201        Severity::Low => "⚪",
202        Severity::Medium => "🟠",
203        Severity::High => "🔴",
204        Severity::Critical => "🚨",
205    }
206    .to_string()
207}
208
209fn print_advisory_details(terminal: &Term, advisory: &Advisory) -> Result<(), Box<dyn Error>> {
210    let ids = match &advisory.cve_id {
211        Some(cve_id) => format!("{} / {}", advisory.ghsa_id, cve_id),
212        None => advisory.ghsa_id.clone(),
213    };
214
215    terminal.write_line(format!("{}{}", styled_detail_label("ID"), style(ids).bold()).as_str())?;
216    terminal.write_line(
217        format!(
218            "{}{}",
219            styled_detail_label("URL"),
220            style(&advisory.html_url).underlined()
221        )
222        .as_str(),
223    )?;
224    terminal.write_line(
225        format!(
226            "{}{}",
227            styled_detail_label("CVSS v3"),
228            styled_cvss_score(&advisory.cvss_severities.cvss_v3)
229        )
230        .as_str(),
231    )?;
232    terminal.write_line(
233        format!(
234            "{}{}",
235            styled_detail_label("CVSS v4"),
236            styled_cvss_score(&advisory.cvss_severities.cvss_v4)
237        )
238        .as_str(),
239    )?;
240    terminal.write_line(
241        format!(
242            "{}{}",
243            styled_detail_label("EPSS"),
244            styled_epss(advisory.epss)
245        )
246        .as_str(),
247    )?;
248    terminal.write_line(
249        format!(
250            "{}{} (updated {})",
251            styled_detail_label("Published"),
252            formatted_date(&advisory.published_at),
253            formatted_date(&advisory.updated_at)
254        )
255        .as_str(),
256    )?;
257    if let Some(withdrawn_at) = &advisory.withdrawn_at {
258        terminal.write_line(
259            format!(
260                "{}{}",
261                styled_detail_label("Withdrawn"),
262                style(formatted_date(withdrawn_at)).yellow().bold()
263            )
264            .as_str(),
265        )?;
266    }
267    for (index, vulnerability) in advisory.vulnerabilities.iter().enumerate() {
268        // Only the first affected package carries the label, the others line up under it.
269        let label = if index == 0 { "Affected" } else { "" };
270        terminal.write_line(
271            format!(
272                "{}{}",
273                styled_detail_label(label),
274                styled_vulnerability(vulnerability)
275            )
276            .as_str(),
277        )?;
278    }
279    terminal.write_line("")?;
280
281    Ok(())
282}
283
284fn styled_detail_label(label: &str) -> String {
285    let label_width = 11usize;
286    style(format!("{label:<label_width$}")).dim().to_string()
287}
288
289fn styled_cvss_score(cvss: &CvssScore) -> String {
290    // GitHub returns a 0.0 score without a vector string for a CVSS version it has not assessed.
291    let score = cvss.score.filter(|score| *score > 0.0);
292    match (score, &cvss.vector_string) {
293        (Some(score), Some(vector)) => format!(
294            "{} ({})",
295            style(format!("{score:.1}")).bold(),
296            style(vector).dim()
297        ),
298        (Some(score), None) => style(format!("{score:.1}")).bold().to_string(),
299        (None, Some(vector)) => style(vector).dim().to_string(),
300        (None, None) => style("N/A").dim().to_string(),
301    }
302}
303
304fn styled_epss(epss: Option<Epss>) -> String {
305    match epss {
306        // EPSS values are fractions, so both are shown as percentages.
307        Some(epss) => format!(
308            "{}% ({:.1}th percentile)",
309            style(format!("{:.3}", epss.percentage * 100.0)).bold(),
310            epss.percentile * 100.0
311        ),
312        None => style("N/A").dim().to_string(),
313    }
314}
315
316fn styled_vulnerability(vulnerability: &Vulnerability) -> String {
317    // Repository advisories may leave the package or range empty, so only filled parts are shown.
318    let package = [
319        vulnerability.package.ecosystem.as_str(),
320        vulnerability.package.name.as_str(),
321    ]
322    .into_iter()
323    .filter(|part| !part.is_empty())
324    .collect::<Vec<_>>()
325    .join("/");
326
327    let patched = if vulnerability.patched_versions.is_empty() {
328        style("no patched version").red().to_string()
329    } else {
330        format!(
331            "patched: {}",
332            style(vulnerability.patched_versions.join(", ")).green()
333        )
334    };
335
336    let mut parts: Vec<String> = vec![];
337    if !package.is_empty() {
338        parts.push(style(package).bold().to_string());
339    }
340    if !vulnerability.vulnerable_version_range.is_empty() {
341        parts.push(vulnerability.vulnerable_version_range.clone());
342    }
343    parts.push(format!("({patched})"));
344
345    parts.join(" ")
346}
347
348fn formatted_date(date: &DateTime<Utc>) -> String {
349    date.format("%Y-%m-%d").to_string()
350}
351
352fn print_console_divider(terminal: &Term) -> Result<(), Box<dyn Error>> {
353    let (_, width) = terminal.size();
354    let min_size = 150u16;
355    let write_width =
356        [min_size, width]
357            .into_iter()
358            .min()
359            .ok_or(Box::new(KagamiError::OptionalGuard(
360                "write_width".to_string(),
361            )))?;
362    trace!(
363        "Terminal width is {}, divider width is {}.",
364        width, write_width
365    );
366
367    let divider_str = "━".repeat(usize::from(write_width));
368    terminal.write_line(style(divider_str).cyan().to_string().as_str())?;
369
370    Ok(())
371}
372
373fn get_count_by_severity_for_target(results: &[TargetWithAdvisory], severity: Severity) -> usize {
374    results
375        .iter()
376        .flat_map(|v| &v.advisories)
377        .filter(|v| v.severity == severity)
378        .collect::<Vec<_>>()
379        .len()
380}
381
382fn get_count_by_severity_for_advisories(results: &[Advisory], severity: Severity) -> usize {
383    results
384        .iter()
385        .filter(|v| v.severity == severity)
386        .collect::<Vec<_>>()
387        .len()
388}