wordpress_vulnerable_scanner/
output.rs1use crate::analyze::{Analysis, ComponentVulnerabilities};
4use crate::error::{Error, Result};
5use crate::vulnerability::Severity;
6use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table, presets::UTF8_FULL};
7use std::io::Write;
8use std::str::FromStr;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub enum OutputFormat {
13 #[default]
15 Human,
16 Json,
18 None,
20}
21
22impl FromStr for OutputFormat {
23 type Err = Error;
24
25 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
26 match s.to_lowercase().as_str() {
27 "human" => Ok(Self::Human),
28 "json" => Ok(Self::Json),
29 "none" => Ok(Self::None),
30 _ => Err(Error::InvalidOutputFormat(s.to_string())),
31 }
32 }
33}
34
35#[derive(Debug, Clone)]
37pub struct OutputConfig {
38 pub format: OutputFormat,
40 pub min_severity: Severity,
42}
43
44impl Default for OutputConfig {
45 fn default() -> Self {
46 Self {
47 format: OutputFormat::Human,
48 min_severity: Severity::Low,
49 }
50 }
51}
52
53impl OutputConfig {
54 pub fn new(format: OutputFormat, min_severity: Severity) -> Self {
56 Self {
57 format,
58 min_severity,
59 }
60 }
61}
62
63pub fn output_analysis<W: Write>(
65 analysis: &Analysis,
66 config: &OutputConfig,
67 writer: &mut W,
68) -> Result<()> {
69 match config.format {
70 OutputFormat::Human => output_human(analysis, config, writer),
71 OutputFormat::Json => output_json(analysis, writer),
72 OutputFormat::None => Ok(()),
73 }
74}
75
76fn output_json<W: Write>(analysis: &Analysis, writer: &mut W) -> Result<()> {
78 serde_json::to_writer_pretty(&mut *writer, analysis)?;
79 writeln!(writer)?;
80 Ok(())
81}
82
83fn output_human<W: Write>(
85 analysis: &Analysis,
86 config: &OutputConfig,
87 writer: &mut W,
88) -> Result<()> {
89 if let Some(ref url) = analysis.url {
91 writeln!(writer, "Target: {}", url)?;
92 writeln!(writer)?;
93 }
94
95 if !analysis.summary.has_any() {
97 writeln!(writer, "No vulnerabilities found.")?;
98 return Ok(());
99 }
100
101 let severities = [
103 Severity::Critical,
104 Severity::High,
105 Severity::Medium,
106 Severity::Low,
107 ];
108
109 for severity in severities {
110 if severity < config.min_severity {
111 continue;
112 }
113
114 let components: Vec<_> = analysis
115 .components
116 .iter()
117 .filter(|c| c.vulnerabilities.iter().any(|v| v.severity == severity))
118 .collect();
119
120 if components.is_empty() {
121 continue;
122 }
123
124 let count: usize = components
125 .iter()
126 .map(|c| {
127 c.vulnerabilities
128 .iter()
129 .filter(|v| v.severity == severity)
130 .count()
131 })
132 .sum();
133
134 let header = format!("{} ({})", severity.to_string().to_uppercase(), count);
136 let header_color = severity_color(severity);
137 writeln!(writer, "{}", colorize(&header, header_color))?;
138
139 let mut table = Table::new();
141 table
142 .load_preset(UTF8_FULL)
143 .set_content_arrangement(ContentArrangement::Dynamic)
144 .set_header(vec![
145 Cell::new("Component").add_attribute(Attribute::Bold),
146 Cell::new("Version").add_attribute(Attribute::Bold),
147 Cell::new("Vulnerability").add_attribute(Attribute::Bold),
148 Cell::new("Fixed").add_attribute(Attribute::Bold),
149 ]);
150
151 for component in &components {
152 for vuln in component
153 .vulnerabilities
154 .iter()
155 .filter(|v| v.severity == severity)
156 {
157 add_vulnerability_row(&mut table, component, vuln);
158 }
159 }
160
161 writeln!(writer, "{}", table)?;
162 writeln!(writer)?;
163 }
164
165 writeln!(
167 writer,
168 "Summary: {} Critical, {} High, {} Medium, {} Low",
169 analysis.summary.critical,
170 analysis.summary.high,
171 analysis.summary.medium,
172 analysis.summary.low
173 )?;
174
175 Ok(())
176}
177
178fn add_vulnerability_row(
180 table: &mut Table,
181 component: &ComponentVulnerabilities,
182 vuln: &crate::vulnerability::Vulnerability,
183) {
184 let component_name = match component.component_type {
185 crate::scanner::ComponentType::Core => "WordPress".to_string(),
186 crate::scanner::ComponentType::Plugin => component.slug.clone(),
187 crate::scanner::ComponentType::Theme => format!("Theme: {}", component.slug),
188 };
189
190 let version = component.version.as_deref().unwrap_or("-");
191
192 let title = truncate_title(&vuln.title);
193
194 let vuln_desc = format!("{}: {}", vuln.id, title);
195
196 let fixed = vuln
197 .fixed_in
198 .as_deref()
199 .or(vuln.affected_max.as_deref())
200 .map(|v| format!(">{}", v))
201 .unwrap_or_else(|| "-".to_string());
202
203 table.add_row(vec![
204 Cell::new(component_name),
205 Cell::new(version),
206 Cell::new(vuln_desc),
207 Cell::new(fixed),
208 ]);
209}
210
211fn severity_color(severity: Severity) -> Color {
213 match severity {
214 Severity::Critical => Color::Red,
215 Severity::High => Color::Red,
216 Severity::Medium => Color::Yellow,
217 Severity::Low => Color::DarkYellow,
218 }
219}
220
221const MAX_TITLE_LENGTH: usize = 40;
223
224fn truncate_title(title: &str) -> String {
226 if title.len() > MAX_TITLE_LENGTH {
227 format!("{}...", &title[..MAX_TITLE_LENGTH - 3])
228 } else {
229 title.to_string()
230 }
231}
232
233fn colorize(text: &str, color: Color) -> String {
235 let code = match color {
236 Color::Red => "31",
237 Color::Yellow => "33",
238 Color::DarkYellow => "33",
239 Color::Green => "32",
240 _ => "0",
241 };
242 format!("\x1b[{}m{}\x1b[0m", code, text)
243}