wordpress_audit/
analyze.rs

1//! Analysis logic for WordPress scan results
2
3use crate::scanner::ScanResult;
4use serde::Serialize;
5use std::cmp::Ordering;
6use std::collections::HashMap;
7
8/// Placeholder for unknown/missing version information
9const UNKNOWN_VERSION: &str = "-";
10
11/// Compare two version strings semantically
12/// Returns Ordering::Greater if current > latest (ahead/dev version)
13/// Returns Ordering::Less if current < latest (outdated)
14/// Returns Ordering::Equal if they match
15fn compare_versions(current: &str, latest: &str) -> Ordering {
16    // Parse version parts, handling alpha/beta/rc suffixes
17    fn parse_version(v: &str) -> (Vec<u64>, bool) {
18        // Split off any suffix like -alpha, -beta, -rc
19        let pos = v.find(|c: char| c == '-' || c.is_ascii_alphabetic());
20        let version_part = match pos {
21            Some(p) => &v[..p],
22            None => v,
23        };
24        let has_suffix = pos.is_some();
25
26        let parts: Vec<u64> = version_part
27            .split('.')
28            .filter_map(|p| p.parse().ok())
29            .collect();
30
31        (parts, has_suffix)
32    }
33
34    let (current_parts, current_has_suffix) = parse_version(current);
35    let (latest_parts, latest_has_suffix) = parse_version(latest);
36
37    // Compare numeric parts
38    let max_len = current_parts.len().max(latest_parts.len());
39    for i in 0..max_len {
40        let c = current_parts.get(i).copied().unwrap_or(0);
41        let l = latest_parts.get(i).copied().unwrap_or(0);
42        match c.cmp(&l) {
43            Ordering::Equal => continue,
44            other => return other,
45        }
46    }
47
48    // If numeric parts are equal, check suffixes
49    // A version without suffix is considered newer than one with suffix
50    // (e.g., 7.0 > 7.0-alpha)
51    match (current_has_suffix, latest_has_suffix) {
52        (false, true) => Ordering::Greater,
53        (true, false) => Ordering::Less,
54        _ => Ordering::Equal,
55    }
56}
57
58/// Component type
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
60#[serde(rename_all = "lowercase")]
61pub enum ComponentType {
62    /// WordPress core
63    Core,
64    /// Theme
65    Theme,
66    /// Plugin
67    Plugin,
68}
69
70impl std::fmt::Display for ComponentType {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            Self::Core => write!(f, "Core"),
74            Self::Theme => write!(f, "Theme"),
75            Self::Plugin => write!(f, "Plugin"),
76        }
77    }
78}
79
80/// Analysis result for a single component
81#[derive(Debug, Clone, Serialize)]
82pub struct ComponentAnalysis {
83    /// Component type
84    pub component_type: ComponentType,
85
86    /// Component name/slug
87    pub name: String,
88
89    /// Detected version (or "-" if unknown)
90    pub version: String,
91
92    /// Latest available version (or "-" if unknown)
93    pub latest_version: String,
94
95    /// Component status
96    pub status: ComponentStatus,
97}
98
99impl ComponentAnalysis {
100    fn new(
101        component_type: ComponentType,
102        name: impl Into<String>,
103        version: Option<String>,
104        latest_version: Option<String>,
105    ) -> Self {
106        let version_str = version.unwrap_or_else(|| UNKNOWN_VERSION.to_string());
107        let latest_str = latest_version.unwrap_or_else(|| UNKNOWN_VERSION.to_string());
108
109        let status = if version_str == UNKNOWN_VERSION {
110            ComponentStatus::Unknown
111        } else if latest_str == UNKNOWN_VERSION {
112            // Can't compare without latest version
113            ComponentStatus::Ok
114        } else {
115            match compare_versions(&version_str, &latest_str) {
116                Ordering::Less => ComponentStatus::Outdated,
117                Ordering::Equal | Ordering::Greater => ComponentStatus::Ok,
118            }
119        };
120
121        Self {
122            component_type,
123            name: name.into(),
124            version: version_str,
125            latest_version: latest_str,
126            status,
127        }
128    }
129
130    fn not_detected(component_type: ComponentType, name: impl Into<String>) -> Self {
131        Self {
132            component_type,
133            name: name.into(),
134            version: UNKNOWN_VERSION.to_string(),
135            latest_version: UNKNOWN_VERSION.to_string(),
136            status: ComponentStatus::NotDetected,
137        }
138    }
139}
140
141/// Component status
142#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
143#[serde(rename_all = "lowercase")]
144pub enum ComponentStatus {
145    /// Component is up to date
146    Ok,
147    /// Component detected but version unknown
148    Unknown,
149    /// Component is outdated
150    Outdated,
151    /// Component not detected
152    NotDetected,
153}
154
155/// Complete analysis results
156#[derive(Debug, Clone, Serialize)]
157pub struct Analysis {
158    /// Target URL
159    pub url: String,
160
161    /// WordPress core analysis
162    pub wordpress: ComponentAnalysis,
163
164    /// Main theme analysis
165    pub theme: ComponentAnalysis,
166
167    /// Plugin analyses
168    pub plugins: HashMap<String, ComponentAnalysis>,
169}
170
171impl Analysis {
172    /// Check if WordPress was detected
173    pub fn is_wordpress(&self) -> bool {
174        self.wordpress.status != ComponentStatus::NotDetected
175    }
176
177    /// Get count of detected plugins
178    pub fn plugin_count(&self) -> usize {
179        self.plugins.len()
180    }
181
182    /// Get count of outdated components
183    pub fn outdated_count(&self) -> usize {
184        let core_outdated = (self.wordpress.status == ComponentStatus::Outdated) as usize;
185        let theme_outdated = (self.theme.status == ComponentStatus::Outdated) as usize;
186        let plugins_outdated = self
187            .plugins
188            .values()
189            .filter(|p| p.status == ComponentStatus::Outdated)
190            .count();
191
192        core_outdated + theme_outdated + plugins_outdated
193    }
194}
195
196/// Analyzer for scan results
197pub struct Analyzer {
198    scan: ScanResult,
199}
200
201impl Analyzer {
202    /// Create a new analyzer for the given scan result
203    pub fn new(scan: ScanResult) -> Self {
204        Self { scan }
205    }
206
207    /// Perform the analysis
208    pub fn analyze(self) -> Analysis {
209        Analysis {
210            url: self.scan.url.to_string(),
211            wordpress: self.analyze_wordpress(),
212            theme: self.analyze_theme(),
213            plugins: self.analyze_plugins(),
214        }
215    }
216
217    fn analyze_wordpress(&self) -> ComponentAnalysis {
218        match &self.scan.wordpress_version {
219            Some(version) => ComponentAnalysis::new(
220                ComponentType::Core,
221                "WordPress",
222                Some(version.clone()),
223                self.scan.wordpress_latest.clone(),
224            ),
225            None if self.scan.wordpress_detected => {
226                // WordPress detected via REST API or cookies, but version unknown
227                ComponentAnalysis::new(
228                    ComponentType::Core,
229                    "WordPress",
230                    None,
231                    self.scan.wordpress_latest.clone(),
232                )
233            }
234            None => ComponentAnalysis::not_detected(ComponentType::Core, "WordPress"),
235        }
236    }
237
238    fn analyze_theme(&self) -> ComponentAnalysis {
239        match &self.scan.theme {
240            Some(theme) => ComponentAnalysis::new(
241                ComponentType::Theme,
242                &theme.slug,
243                theme.version.clone(),
244                theme.latest_version.clone(),
245            ),
246            None => ComponentAnalysis::not_detected(ComponentType::Theme, "-"),
247        }
248    }
249
250    fn analyze_plugins(&self) -> HashMap<String, ComponentAnalysis> {
251        self.scan
252            .plugins
253            .iter()
254            .map(|plugin| {
255                let analysis = ComponentAnalysis::new(
256                    ComponentType::Plugin,
257                    &plugin.slug,
258                    plugin.version.clone(),
259                    plugin.latest_version.clone(),
260                );
261                (plugin.slug.clone(), analysis)
262            })
263            .collect()
264    }
265}