wordpress_vulnerable_scanner/
output.rs

1//! Output formatting for vulnerability scan results
2
3use 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/// Output format for results
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub enum OutputFormat {
13    /// Human-readable table output
14    #[default]
15    Human,
16    /// JSON output
17    Json,
18    /// No output (silent mode)
19    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/// Configuration for output formatting
36#[derive(Debug, Clone)]
37pub struct OutputConfig {
38    /// Output format
39    pub format: OutputFormat,
40    /// Minimum severity to display
41    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    /// Create a new output config
55    pub fn new(format: OutputFormat, min_severity: Severity) -> Self {
56        Self {
57            format,
58            min_severity,
59        }
60    }
61}
62
63/// Output the analysis results
64pub 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
76/// Output JSON format
77fn 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
83/// Output human-readable format
84fn output_human<W: Write>(
85    analysis: &Analysis,
86    config: &OutputConfig,
87    writer: &mut W,
88) -> Result<()> {
89    // Print target URL if available
90    if let Some(ref url) = analysis.url {
91        writeln!(writer, "Target: {}", url)?;
92        writeln!(writer)?;
93    }
94
95    // Check if any vulnerabilities found
96    if !analysis.summary.has_any() {
97        writeln!(writer, "No vulnerabilities found.")?;
98        return Ok(());
99    }
100
101    // Group and display by severity (highest first)
102    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        // Severity header
135        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        // Build table for this severity level
140        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    // Summary
166    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
178/// Add a row for a vulnerability
179fn 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
211/// Get color for severity level
212fn 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
221/// Maximum title length before truncation
222const MAX_TITLE_LENGTH: usize = 40;
223
224/// Truncate title if too long, adding ellipsis
225fn 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
233/// Apply ANSI color to text
234fn 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}