wordpress_vulnerable_scanner/
vulnerability.rs

1//! Vulnerability types and WPVulnerability API client
2
3use crate::error::{Error, Result};
4use reqwest::Client;
5use serde::{Deserialize, Serialize};
6use std::str::FromStr;
7use std::time::Duration;
8
9use crate::http::{TIMEOUT_SECS, USER_AGENT};
10
11/// WPVulnerability API base URL
12const WPVULN_API: &str = "https://www.wpvulnerability.net";
13
14/// Severity level for vulnerabilities
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
16#[serde(rename_all = "lowercase")]
17pub enum Severity {
18    /// Low severity (CVSS 0.1-3.9)
19    #[default]
20    Low,
21    /// Medium severity (CVSS 4.0-6.9)
22    Medium,
23    /// High severity (CVSS 7.0-8.9)
24    High,
25    /// Critical severity (CVSS 9.0-10.0)
26    Critical,
27}
28
29impl Severity {
30    /// Create from CVSS score
31    pub fn from_cvss(score: f32) -> Self {
32        match score {
33            s if s >= 9.0 => Severity::Critical,
34            s if s >= 7.0 => Severity::High,
35            s if s >= 4.0 => Severity::Medium,
36            _ => Severity::Low,
37        }
38    }
39}
40
41impl FromStr for Severity {
42    type Err = Error;
43
44    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
45        match s.to_lowercase().as_str() {
46            "low" => Ok(Self::Low),
47            "medium" => Ok(Self::Medium),
48            "high" => Ok(Self::High),
49            "critical" => Ok(Self::Critical),
50            _ => Err(Error::InvalidSeverity(s.to_string())),
51        }
52    }
53}
54
55impl std::fmt::Display for Severity {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        match self {
58            Severity::Low => write!(f, "Low"),
59            Severity::Medium => write!(f, "Medium"),
60            Severity::High => write!(f, "High"),
61            Severity::Critical => write!(f, "Critical"),
62        }
63    }
64}
65
66/// A single vulnerability record
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct Vulnerability {
69    /// Unique identifier (CVE or UUID)
70    pub id: String,
71
72    /// Human-readable title/description
73    pub title: String,
74
75    /// Severity level
76    pub severity: Severity,
77
78    /// CVSS score (0.0-10.0)
79    pub cvss_score: Option<f32>,
80
81    /// Maximum affected version
82    pub affected_max: Option<String>,
83
84    /// Fixed in version (if known)
85    pub fixed_in: Option<String>,
86
87    /// Reference URLs for more information
88    pub references: Vec<String>,
89}
90
91/// Vulnerabilities for a component
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93pub struct VulnerabilityReport {
94    /// List of known vulnerabilities
95    pub vulnerabilities: Vec<Vulnerability>,
96}
97
98impl VulnerabilityReport {
99    /// Check if there are any vulnerabilities
100    pub fn is_empty(&self) -> bool {
101        self.vulnerabilities.is_empty()
102    }
103
104    /// Get the highest severity level
105    pub fn max_severity(&self) -> Option<Severity> {
106        self.vulnerabilities.iter().map(|v| v.severity).max()
107    }
108
109    /// Count vulnerabilities by severity
110    pub fn count_by_severity(&self, severity: Severity) -> usize {
111        self.vulnerabilities
112            .iter()
113            .filter(|v| v.severity == severity)
114            .count()
115    }
116
117    /// Filter vulnerabilities that affect a specific version
118    pub fn filter_by_version(&self, version: Option<&str>) -> Self {
119        let Some(ver) = version else {
120            // No version specified, return all vulnerabilities
121            return self.clone();
122        };
123
124        let filtered: Vec<_> = self
125            .vulnerabilities
126            .iter()
127            .filter(|v| version_is_affected(ver, v.affected_max.as_deref()))
128            .cloned()
129            .collect();
130
131        Self {
132            vulnerabilities: filtered,
133        }
134    }
135}
136
137/// Check if a version is affected by a vulnerability
138fn version_is_affected(installed: &str, affected_max: Option<&str>) -> bool {
139    let Some(max) = affected_max else {
140        // No max version specified, assume all versions affected
141        return true;
142    };
143
144    // Try semantic version comparison
145    if let (Ok(installed_ver), Ok(max_ver)) = (
146        semver::Version::parse(installed),
147        semver::Version::parse(max),
148    ) {
149        return installed_ver <= max_ver;
150    }
151
152    // Fallback to string comparison for non-semver versions
153    installed <= max
154}
155
156// WPVulnerability API response structures
157
158#[derive(Debug, Deserialize)]
159struct WpVulnApiResponse {
160    error: i32,
161    #[allow(dead_code)]
162    message: Option<String>,
163    data: Option<WpVulnData>,
164}
165
166#[derive(Debug, Deserialize)]
167struct WpVulnData {
168    #[allow(dead_code)]
169    name: Option<String>,
170    vulnerability: Option<Vec<WpVulnEntry>>,
171}
172
173#[derive(Debug, Deserialize)]
174struct WpVulnEntry {
175    uuid: String,
176    name: Option<String>,
177    operator: Option<WpVulnOperator>,
178    source: Option<Vec<WpVulnSource>>,
179    impact: Option<WpVulnImpact>,
180}
181
182#[derive(Debug, Deserialize)]
183struct WpVulnOperator {
184    max_version: Option<String>,
185    #[allow(dead_code)]
186    min_version: Option<String>,
187}
188
189#[derive(Debug, Deserialize)]
190struct WpVulnSource {
191    #[serde(rename = "id")]
192    source_id: Option<String>,
193    #[allow(dead_code)]
194    name: Option<String>,
195    link: Option<String>,
196    description: Option<String>,
197}
198
199#[derive(Debug, Deserialize, Default)]
200#[serde(default)]
201struct WpVulnImpact {
202    cvss: Option<WpVulnCvss>,
203    #[allow(dead_code)]
204    cwe: Option<Vec<WpVulnCwe>>,
205}
206
207#[derive(Debug, Deserialize)]
208struct WpVulnCvss {
209    #[serde(deserialize_with = "deserialize_score")]
210    score: Option<f32>,
211}
212
213fn deserialize_score<'de, D>(deserializer: D) -> std::result::Result<Option<f32>, D::Error>
214where
215    D: serde::Deserializer<'de>,
216{
217    use serde::de::Error;
218
219    #[derive(Deserialize)]
220    #[serde(untagged)]
221    enum StringOrNumber {
222        String(String),
223        Number(f32),
224    }
225
226    match Option::<StringOrNumber>::deserialize(deserializer)? {
227        Some(StringOrNumber::String(s)) => s.parse::<f32>().map(Some).map_err(D::Error::custom),
228        Some(StringOrNumber::Number(n)) => Ok(Some(n)),
229        None => Ok(None),
230    }
231}
232
233#[derive(Debug, Deserialize)]
234struct WpVulnCwe {
235    #[allow(dead_code)]
236    cwe: Option<String>,
237}
238
239/// Client for the WPVulnerability API
240pub struct VulnerabilityClient {
241    client: Client,
242}
243
244impl VulnerabilityClient {
245    /// Create a new vulnerability client
246    pub fn new() -> Result<Self> {
247        let client = Client::builder()
248            .user_agent(USER_AGENT)
249            .timeout(Duration::from_secs(TIMEOUT_SECS))
250            .build()
251            .map_err(|e| Error::HttpClient(e.to_string()))?;
252
253        Ok(Self { client })
254    }
255
256    /// Fetch vulnerabilities for WordPress core
257    pub async fn fetch_core_vulns(&self, version: &str) -> Option<VulnerabilityReport> {
258        let url = format!("{}/core/{}/", WPVULN_API, version);
259        self.fetch_vulns(&url).await
260    }
261
262    /// Fetch vulnerabilities for a plugin
263    pub async fn fetch_plugin_vulns(&self, slug: &str) -> Option<VulnerabilityReport> {
264        let encoded_slug = urlencoding::encode(slug);
265        let url = format!("{}/plugin/{}/", WPVULN_API, encoded_slug);
266        self.fetch_vulns(&url).await
267    }
268
269    /// Fetch vulnerabilities for a theme
270    pub async fn fetch_theme_vulns(&self, slug: &str) -> Option<VulnerabilityReport> {
271        let encoded_slug = urlencoding::encode(slug);
272        let url = format!("{}/theme/{}/", WPVULN_API, encoded_slug);
273        self.fetch_vulns(&url).await
274    }
275
276    /// Generic vulnerability fetch
277    async fn fetch_vulns(&self, url: &str) -> Option<VulnerabilityReport> {
278        let response = self.client.get(url).send().await.ok()?;
279        let body = response.text().await.ok()?;
280        let api_response: WpVulnApiResponse = serde_json::from_str(&body).ok()?;
281
282        if api_response.error != 0 {
283            return None;
284        }
285
286        let data = api_response.data?;
287        let vulns = data.vulnerability.unwrap_or_default();
288
289        let vulnerabilities: Vec<Vulnerability> = vulns
290            .into_iter()
291            .map(|entry| self.convert_entry(entry))
292            .collect();
293
294        Some(VulnerabilityReport { vulnerabilities })
295    }
296
297    /// Convert API entry to our Vulnerability type
298    fn convert_entry(&self, entry: WpVulnEntry) -> Vulnerability {
299        // Extract CVSS score and derive severity
300        let cvss_score = entry.impact.and_then(|i| i.cvss).and_then(|c| c.score);
301        let severity = cvss_score
302            .map(Severity::from_cvss)
303            .unwrap_or(Severity::Medium);
304
305        // Extract affected version info
306        let affected_max = entry.operator.and_then(|o| o.max_version);
307
308        // Extract CVE ID from sources, or use UUID
309        let (id, references, title) = if let Some(ref sources) = entry.source {
310            let cve = sources
311                .iter()
312                .find(|s| {
313                    s.source_id
314                        .as_ref()
315                        .is_some_and(|id| id.starts_with("CVE-"))
316                })
317                .and_then(|s| s.source_id.clone());
318
319            let refs: Vec<String> = sources.iter().filter_map(|s| s.link.clone()).collect();
320
321            let desc = entry.name.clone().unwrap_or_else(|| {
322                sources
323                    .first()
324                    .and_then(|s| s.description.clone())
325                    .unwrap_or_else(|| "Unknown vulnerability".to_string())
326            });
327
328            (cve.unwrap_or_else(|| entry.uuid.clone()), refs, desc)
329        } else {
330            let desc = entry
331                .name
332                .unwrap_or_else(|| "Unknown vulnerability".to_string());
333            (entry.uuid, Vec::new(), desc)
334        };
335
336        Vulnerability {
337            id,
338            title,
339            severity,
340            cvss_score,
341            affected_max,
342            fixed_in: None, // API doesn't provide this directly
343            references,
344        }
345    }
346}
347
348impl Default for VulnerabilityClient {
349    fn default() -> Self {
350        Self::new().expect("Failed to create vulnerability client")
351    }
352}