wordpress_vulnerable_scanner/
vulnerability.rs1use 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
11const WPVULN_API: &str = "https://www.wpvulnerability.net";
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
16#[serde(rename_all = "lowercase")]
17pub enum Severity {
18 #[default]
20 Low,
21 Medium,
23 High,
25 Critical,
27}
28
29impl Severity {
30 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#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct Vulnerability {
69 pub id: String,
71
72 pub title: String,
74
75 pub severity: Severity,
77
78 pub cvss_score: Option<f32>,
80
81 pub affected_max: Option<String>,
83
84 pub fixed_in: Option<String>,
86
87 pub references: Vec<String>,
89}
90
91#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93pub struct VulnerabilityReport {
94 pub vulnerabilities: Vec<Vulnerability>,
96}
97
98impl VulnerabilityReport {
99 pub fn is_empty(&self) -> bool {
101 self.vulnerabilities.is_empty()
102 }
103
104 pub fn max_severity(&self) -> Option<Severity> {
106 self.vulnerabilities.iter().map(|v| v.severity).max()
107 }
108
109 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 pub fn filter_by_version(&self, version: Option<&str>) -> Self {
119 let Some(ver) = version else {
120 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
137fn version_is_affected(installed: &str, affected_max: Option<&str>) -> bool {
139 let Some(max) = affected_max else {
140 return true;
142 };
143
144 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 installed <= max
154}
155
156#[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
239pub struct VulnerabilityClient {
241 client: Client,
242}
243
244impl VulnerabilityClient {
245 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 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 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 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 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 fn convert_entry(&self, entry: WpVulnEntry) -> Vulnerability {
299 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 let affected_max = entry.operator.and_then(|o| o.max_version);
307
308 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, references,
344 }
345 }
346}
347
348impl Default for VulnerabilityClient {
349 fn default() -> Self {
350 Self::new().expect("Failed to create vulnerability client")
351 }
352}