wordpress-vulnerable-scanner 1.0.0

WordPress vulnerability scanner - detects known CVEs in core, plugins, and themes
Documentation
//! Vulnerability types and WPVulnerability API client

use crate::error::{Error, Result};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use std::time::Duration;

use crate::http::{TIMEOUT_SECS, USER_AGENT};

/// WPVulnerability API base URL
const WPVULN_API: &str = "https://www.wpvulnerability.net";

/// Severity level for vulnerabilities
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
    /// Low severity (CVSS 0.1-3.9)
    #[default]
    Low,
    /// Medium severity (CVSS 4.0-6.9)
    Medium,
    /// High severity (CVSS 7.0-8.9)
    High,
    /// Critical severity (CVSS 9.0-10.0)
    Critical,
}

impl Severity {
    /// Create from CVSS score
    pub fn from_cvss(score: f32) -> Self {
        match score {
            s if s >= 9.0 => Severity::Critical,
            s if s >= 7.0 => Severity::High,
            s if s >= 4.0 => Severity::Medium,
            _ => Severity::Low,
        }
    }
}

impl FromStr for Severity {
    type Err = Error;

    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
        match s.to_lowercase().as_str() {
            "low" => Ok(Self::Low),
            "medium" => Ok(Self::Medium),
            "high" => Ok(Self::High),
            "critical" => Ok(Self::Critical),
            _ => Err(Error::InvalidSeverity(s.to_string())),
        }
    }
}

impl std::fmt::Display for Severity {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Severity::Low => write!(f, "Low"),
            Severity::Medium => write!(f, "Medium"),
            Severity::High => write!(f, "High"),
            Severity::Critical => write!(f, "Critical"),
        }
    }
}

/// A single vulnerability record
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Vulnerability {
    /// Unique identifier (CVE or UUID)
    pub id: String,

    /// Human-readable title/description
    pub title: String,

    /// Severity level
    pub severity: Severity,

    /// CVSS score (0.0-10.0)
    pub cvss_score: Option<f32>,

    /// Maximum affected version
    pub affected_max: Option<String>,

    /// Fixed in version (if known)
    pub fixed_in: Option<String>,

    /// Reference URLs for more information
    pub references: Vec<String>,
}

/// Vulnerabilities for a component
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VulnerabilityReport {
    /// List of known vulnerabilities
    pub vulnerabilities: Vec<Vulnerability>,
}

impl VulnerabilityReport {
    /// Check if there are any vulnerabilities
    pub fn is_empty(&self) -> bool {
        self.vulnerabilities.is_empty()
    }

    /// Get the highest severity level
    pub fn max_severity(&self) -> Option<Severity> {
        self.vulnerabilities.iter().map(|v| v.severity).max()
    }

    /// Count vulnerabilities by severity
    pub fn count_by_severity(&self, severity: Severity) -> usize {
        self.vulnerabilities
            .iter()
            .filter(|v| v.severity == severity)
            .count()
    }

    /// Filter vulnerabilities that affect a specific version
    pub fn filter_by_version(&self, version: Option<&str>) -> Self {
        let Some(ver) = version else {
            // No version specified, return all vulnerabilities
            return self.clone();
        };

        let filtered: Vec<_> = self
            .vulnerabilities
            .iter()
            .filter(|v| version_is_affected(ver, v.affected_max.as_deref()))
            .cloned()
            .collect();

        Self {
            vulnerabilities: filtered,
        }
    }
}

/// Check if a version is affected by a vulnerability
fn version_is_affected(installed: &str, affected_max: Option<&str>) -> bool {
    let Some(max) = affected_max else {
        // No max version specified, assume all versions affected
        return true;
    };

    // Try semantic version comparison
    if let (Ok(installed_ver), Ok(max_ver)) = (
        semver::Version::parse(installed),
        semver::Version::parse(max),
    ) {
        return installed_ver <= max_ver;
    }

    // Fallback to string comparison for non-semver versions
    installed <= max
}

// WPVulnerability API response structures

#[derive(Debug, Deserialize)]
struct WpVulnApiResponse {
    error: i32,
    #[allow(dead_code)]
    message: Option<String>,
    data: Option<WpVulnData>,
}

#[derive(Debug, Deserialize)]
struct WpVulnData {
    #[allow(dead_code)]
    name: Option<String>,
    vulnerability: Option<Vec<WpVulnEntry>>,
}

#[derive(Debug, Deserialize)]
struct WpVulnEntry {
    uuid: String,
    name: Option<String>,
    operator: Option<WpVulnOperator>,
    source: Option<Vec<WpVulnSource>>,
    impact: Option<WpVulnImpact>,
}

#[derive(Debug, Deserialize)]
struct WpVulnOperator {
    max_version: Option<String>,
    #[allow(dead_code)]
    min_version: Option<String>,
}

#[derive(Debug, Deserialize)]
struct WpVulnSource {
    #[serde(rename = "id")]
    source_id: Option<String>,
    #[allow(dead_code)]
    name: Option<String>,
    link: Option<String>,
    description: Option<String>,
}

#[derive(Debug, Deserialize, Default)]
#[serde(default)]
struct WpVulnImpact {
    cvss: Option<WpVulnCvss>,
    #[allow(dead_code)]
    cwe: Option<Vec<WpVulnCwe>>,
}

#[derive(Debug, Deserialize)]
struct WpVulnCvss {
    #[serde(deserialize_with = "deserialize_score")]
    score: Option<f32>,
}

fn deserialize_score<'de, D>(deserializer: D) -> std::result::Result<Option<f32>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    use serde::de::Error;

    #[derive(Deserialize)]
    #[serde(untagged)]
    enum StringOrNumber {
        String(String),
        Number(f32),
    }

    match Option::<StringOrNumber>::deserialize(deserializer)? {
        Some(StringOrNumber::String(s)) => s.parse::<f32>().map(Some).map_err(D::Error::custom),
        Some(StringOrNumber::Number(n)) => Ok(Some(n)),
        None => Ok(None),
    }
}

#[derive(Debug, Deserialize)]
struct WpVulnCwe {
    #[allow(dead_code)]
    cwe: Option<String>,
}

/// Client for the WPVulnerability API
pub struct VulnerabilityClient {
    client: Client,
}

impl VulnerabilityClient {
    /// Create a new vulnerability client
    pub fn new() -> Result<Self> {
        let client = Client::builder()
            .user_agent(USER_AGENT)
            .timeout(Duration::from_secs(TIMEOUT_SECS))
            .build()
            .map_err(|e| Error::HttpClient(e.to_string()))?;

        Ok(Self { client })
    }

    /// Fetch vulnerabilities for WordPress core
    pub async fn fetch_core_vulns(&self, version: &str) -> Option<VulnerabilityReport> {
        let url = format!("{}/core/{}/", WPVULN_API, version);
        self.fetch_vulns(&url).await
    }

    /// Fetch vulnerabilities for a plugin
    pub async fn fetch_plugin_vulns(&self, slug: &str) -> Option<VulnerabilityReport> {
        let encoded_slug = urlencoding::encode(slug);
        let url = format!("{}/plugin/{}/", WPVULN_API, encoded_slug);
        self.fetch_vulns(&url).await
    }

    /// Fetch vulnerabilities for a theme
    pub async fn fetch_theme_vulns(&self, slug: &str) -> Option<VulnerabilityReport> {
        let encoded_slug = urlencoding::encode(slug);
        let url = format!("{}/theme/{}/", WPVULN_API, encoded_slug);
        self.fetch_vulns(&url).await
    }

    /// Generic vulnerability fetch
    async fn fetch_vulns(&self, url: &str) -> Option<VulnerabilityReport> {
        let response = self.client.get(url).send().await.ok()?;
        let body = response.text().await.ok()?;
        let api_response: WpVulnApiResponse = serde_json::from_str(&body).ok()?;

        if api_response.error != 0 {
            return None;
        }

        let data = api_response.data?;
        let vulns = data.vulnerability.unwrap_or_default();

        let vulnerabilities: Vec<Vulnerability> = vulns
            .into_iter()
            .map(|entry| self.convert_entry(entry))
            .collect();

        Some(VulnerabilityReport { vulnerabilities })
    }

    /// Convert API entry to our Vulnerability type
    fn convert_entry(&self, entry: WpVulnEntry) -> Vulnerability {
        // Extract CVSS score and derive severity
        let cvss_score = entry.impact.and_then(|i| i.cvss).and_then(|c| c.score);
        let severity = cvss_score
            .map(Severity::from_cvss)
            .unwrap_or(Severity::Medium);

        // Extract affected version info
        let affected_max = entry.operator.and_then(|o| o.max_version);

        // Extract CVE ID from sources, or use UUID
        let (id, references, title) = if let Some(ref sources) = entry.source {
            let cve = sources
                .iter()
                .find(|s| {
                    s.source_id
                        .as_ref()
                        .is_some_and(|id| id.starts_with("CVE-"))
                })
                .and_then(|s| s.source_id.clone());

            let refs: Vec<String> = sources.iter().filter_map(|s| s.link.clone()).collect();

            let desc = entry.name.clone().unwrap_or_else(|| {
                sources
                    .first()
                    .and_then(|s| s.description.clone())
                    .unwrap_or_else(|| "Unknown vulnerability".to_string())
            });

            (cve.unwrap_or_else(|| entry.uuid.clone()), refs, desc)
        } else {
            let desc = entry
                .name
                .unwrap_or_else(|| "Unknown vulnerability".to_string());
            (entry.uuid, Vec::new(), desc)
        };

        Vulnerability {
            id,
            title,
            severity,
            cvss_score,
            affected_max,
            fixed_in: None, // API doesn't provide this directly
            references,
        }
    }
}

impl Default for VulnerabilityClient {
    fn default() -> Self {
        Self::new().expect("Failed to create vulnerability client")
    }
}