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 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 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 stdout_terminal.write_line(
172 format!(
173 "{} - {}",
174 styled_severity(advisory.severity),
175 style(&advisory.summary).cyan().bold()
176 )
177 .as_str(),
178 )?;
179 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 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 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 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 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}