use crate::http_client::HttpClient;
use crate::types::{Confidence, ScanConfig, Severity, Vulnerability};
use anyhow::Result;
use regex::Regex;
use std::collections::HashMap;
use std::sync::Arc;
use tracing::{debug, info};
pub struct WordPressSecurityScanner {
http_client: Arc<HttpClient>,
known_vulnerable_plugins: HashMap<String, Vec<PluginVulnerability>>,
}
#[derive(Clone)]
struct PluginVulnerability {
slug: String,
vulnerable_version: String,
cve: Option<String>,
severity: Severity,
description: String,
}
impl WordPressSecurityScanner {
pub fn new(http_client: Arc<HttpClient>) -> Self {
Self {
http_client,
known_vulnerable_plugins: Self::build_vulnerable_plugins_db(),
}
}
fn build_vulnerable_plugins_db() -> HashMap<String, Vec<PluginVulnerability>> {
let mut db = HashMap::new();
let vulnerabilities = vec![
PluginVulnerability {
slug: "contact-form-7".to_string(),
vulnerable_version: "5.3.1".to_string(),
cve: Some("CVE-2020-35489".to_string()),
severity: Severity::Critical,
description: "Unrestricted file upload vulnerability".to_string(),
},
PluginVulnerability {
slug: "elementor".to_string(),
vulnerable_version: "3.6.0".to_string(),
cve: Some("CVE-2022-29455".to_string()),
severity: Severity::High,
description: "DOM-based XSS vulnerability".to_string(),
},
PluginVulnerability {
slug: "wp-file-manager".to_string(),
vulnerable_version: "6.8".to_string(),
cve: Some("CVE-2020-25213".to_string()),
severity: Severity::Critical,
description: "Remote code execution via file upload".to_string(),
},
PluginVulnerability {
slug: "duplicator".to_string(),
vulnerable_version: "1.3.26".to_string(),
cve: Some("CVE-2020-11738".to_string()),
severity: Severity::Critical,
description: "Arbitrary file download vulnerability".to_string(),
},
PluginVulnerability {
slug: "wp-super-cache".to_string(),
vulnerable_version: "1.7.1".to_string(),
cve: Some("CVE-2021-24209".to_string()),
severity: Severity::High,
description: "Authenticated RCE vulnerability".to_string(),
},
PluginVulnerability {
slug: "ninja-forms".to_string(),
vulnerable_version: "3.4.24".to_string(),
cve: Some("CVE-2020-12462".to_string()),
severity: Severity::High,
description: "CSRF to stored XSS".to_string(),
},
PluginVulnerability {
slug: "all-in-one-seo-pack".to_string(),
vulnerable_version: "4.0.16".to_string(),
cve: Some("CVE-2021-25036".to_string()),
severity: Severity::Critical,
description: "Privilege escalation and SQL injection".to_string(),
},
PluginVulnerability {
slug: "wordfence".to_string(),
vulnerable_version: "7.4.5".to_string(),
cve: Some("CVE-2021-24917".to_string()),
severity: Severity::Medium,
description: "Information disclosure".to_string(),
},
PluginVulnerability {
slug: "wpforms-lite".to_string(),
vulnerable_version: "1.6.3.1".to_string(),
cve: Some("CVE-2021-24126".to_string()),
severity: Severity::High,
description: "CSV injection vulnerability".to_string(),
},
PluginVulnerability {
slug: "yoast-seo".to_string(),
vulnerable_version: "15.6".to_string(),
cve: Some("CVE-2021-25032".to_string()),
severity: Severity::Medium,
description: "Open redirect vulnerability".to_string(),
},
PluginVulnerability {
slug: "advanced-custom-fields".to_string(),
vulnerable_version: "5.8.12".to_string(),
cve: Some("CVE-2021-20839".to_string()),
severity: Severity::High,
description: "Stored XSS vulnerability".to_string(),
},
PluginVulnerability {
slug: "updraftplus".to_string(),
vulnerable_version: "1.22.2".to_string(),
cve: Some("CVE-2022-0633".to_string()),
severity: Severity::Critical,
description: "Arbitrary backup download".to_string(),
},
PluginVulnerability {
slug: "wp-statistics".to_string(),
vulnerable_version: "13.0.7".to_string(),
cve: Some("CVE-2021-24340".to_string()),
severity: Severity::High,
description: "SQL injection vulnerability".to_string(),
},
PluginVulnerability {
slug: "really-simple-ssl".to_string(),
vulnerable_version: "4.0.1".to_string(),
cve: Some("CVE-2021-24182".to_string()),
severity: Severity::Medium,
description: "Open redirect vulnerability".to_string(),
},
PluginVulnerability {
slug: "redirection".to_string(),
vulnerable_version: "5.1.1".to_string(),
cve: Some("CVE-2021-24288".to_string()),
severity: Severity::High,
description: "SQL injection vulnerability".to_string(),
},
PluginVulnerability {
slug: "themegrill-demo-importer".to_string(),
vulnerable_version: "1.6.1".to_string(),
cve: Some("CVE-2020-8656".to_string()),
severity: Severity::Critical,
description: "Authentication bypass - database wipe".to_string(),
},
PluginVulnerability {
slug: "easy-wp-smtp".to_string(),
vulnerable_version: "1.4.2".to_string(),
cve: Some("CVE-2021-24329".to_string()),
severity: Severity::Critical,
description: "Authentication bypass".to_string(),
},
PluginVulnerability {
slug: "popup-builder".to_string(),
vulnerable_version: "3.64.1".to_string(),
cve: Some("CVE-2020-15092".to_string()),
severity: Severity::High,
description: "Stored XSS vulnerability".to_string(),
},
];
for vuln in vulnerabilities {
db.entry(vuln.slug.clone())
.or_insert_with(Vec::new)
.push(vuln);
}
db
}
pub async fn scan(
&self,
url: &str,
config: &ScanConfig,
) -> Result<(Vec<Vulnerability>, usize)> {
info!("[WordPress] Advanced WordPress security scan starting");
let mut vulnerabilities = Vec::new();
let mut tests_run = 0;
tests_run += 1;
let is_wordpress = self.detect_wordpress(url).await;
if !is_wordpress {
debug!("Not a WordPress site, skipping WordPress-specific tests");
return Ok((vulnerabilities, tests_run));
}
info!("[WordPress] WordPress detected, running comprehensive scan");
tests_run += 1;
let user_enum_vulns = self.test_user_enumeration(url).await;
vulnerabilities.extend(user_enum_vulns);
tests_run += 1;
let rest_api_vulns = self.test_rest_api_exposure(url).await;
vulnerabilities.extend(rest_api_vulns);
tests_run += 1;
let xmlrpc_vulns = self.test_xmlrpc_comprehensive(url).await;
vulnerabilities.extend(xmlrpc_vulns);
tests_run += 1;
let config_vulns = self.test_config_exposure(url).await;
vulnerabilities.extend(config_vulns);
tests_run += 1;
let debug_vulns = self.test_debug_log_exposure(url).await;
vulnerabilities.extend(debug_vulns);
tests_run += 1;
let version_vulns = self.test_version_disclosure(url).await;
vulnerabilities.extend(version_vulns);
tests_run += 1;
let plugin_vulns = self.test_plugin_vulnerabilities(url).await;
vulnerabilities.extend(plugin_vulns);
tests_run += 1;
let backup_vulns = self.test_backup_exposure(url).await;
vulnerabilities.extend(backup_vulns);
tests_run += 1;
let install_vulns = self.test_installation_exposure(url).await;
vulnerabilities.extend(install_vulns);
tests_run += 1;
let listing_vulns = self.test_directory_listing(url).await;
vulnerabilities.extend(listing_vulns);
tests_run += 1;
let theme_vulns = self.test_theme_vulnerabilities(url).await;
vulnerabilities.extend(theme_vulns);
tests_run += 1;
let cron_vulns = self.test_wp_cron_exposure(url).await;
vulnerabilities.extend(cron_vulns);
if config.scan_mode.as_str() != "fast" {
tests_run += 1;
let deep_plugin_vulns = self.deep_plugin_scan(url).await;
vulnerabilities.extend(deep_plugin_vulns);
}
info!(
"[WordPress] Scan complete: {} vulnerabilities found in {} tests",
vulnerabilities.len(),
tests_run
);
Ok((vulnerabilities, tests_run))
}
async fn detect_wordpress(&self, url: &str) -> bool {
let base_url = self.get_base_url(url);
if let Ok(response) = self.http_client.get(url).await {
let indicators = vec![
"wp-content",
"wp-includes",
"wp-json",
"/wp-admin/",
"wordpress",
"generator\" content=\"WordPress",
];
for indicator in indicators {
if response.body.contains(indicator) {
return true;
}
}
if let Some(re) = Regex::new(r#"<meta[^>]*generator[^>]*WordPress"#).ok() {
if re.is_match(&response.body) {
return true;
}
}
}
let login_url = format!("{}/wp-login.php", base_url);
if let Ok(response) = self.http_client.get(&login_url).await {
if response.status_code == 200 && response.body.contains("wp-login") {
return true;
}
}
let admin_url = format!("{}/wp-admin/", base_url);
if let Ok(response) = self.http_client.get(&admin_url).await {
if response.status_code == 302 || response.body.contains("wp-admin") {
return true;
}
}
false
}
async fn test_user_enumeration(&self, url: &str) -> Vec<Vulnerability> {
let mut vulnerabilities = Vec::new();
let base_url = self.get_base_url(url);
let mut found_users = Vec::new();
for i in 1..=10 {
let test_url = format!("{}/?author={}", base_url, i);
if let Ok(response) = self.http_client.get(&test_url).await {
if response.status_code == 301 || response.status_code == 302 {
if let Some(location) = response.headers.get("location") {
if location.contains("/author/") {
if let Some(username) = location.split("/author/").nth(1) {
let clean_username = username.trim_end_matches('/');
found_users.push(clean_username.to_string());
}
}
}
}
if response.status_code == 200 {
let author_re = Regex::new(r#"/author/([a-zA-Z0-9_-]+)/"#).ok();
if let Some(re) = author_re {
for cap in re.captures_iter(&response.body) {
if let Some(username) = cap.get(1) {
let name = username.as_str().to_string();
if !found_users.contains(&name) {
found_users.push(name);
}
}
}
}
}
}
}
if !found_users.is_empty() {
vulnerabilities.push(Vulnerability {
id: format!("wp_user_enum_{}", Self::generate_id()),
vuln_type: "WordPress User Enumeration via Author Parameter".to_string(),
severity: Severity::Medium,
confidence: Confidence::High,
category: "Information Disclosure".to_string(),
url: format!("{}/?author=1", base_url),
parameter: Some("author".to_string()),
payload: "?author=1,2,3...".to_string(),
description: format!(
"WordPress allows user enumeration via the author parameter. \
{} usernames discovered: {}",
found_users.len(),
found_users.join(", ")
),
evidence: Some(format!(
"Enumerated users:\n{}",
found_users
.iter()
.map(|u| format!("- {}", u))
.collect::<Vec<_>>()
.join("\n")
)),
cwe: "CWE-200".to_string(),
cvss: 5.3,
verified: true,
false_positive: false,
remediation: "1. Install a security plugin to block user enumeration\n\
2. Add to functions.php:\n\
add_action('template_redirect', function() {\n\
if (isset($_GET['author'])) {\n\
wp_redirect(home_url(), 301);\n\
exit;\n\
}\n\
});\n\
3. Or use .htaccess rules to block author queries"
.to_string(),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_data: None,
});
}
vulnerabilities
}
async fn test_rest_api_exposure(&self, url: &str) -> Vec<Vulnerability> {
let mut vulnerabilities = Vec::new();
let base_url = self.get_base_url(url);
let rest_endpoints = vec![
"/wp-json/wp/v2/users",
"/wp-json/wp/v2/users?per_page=100",
"/?rest_route=/wp/v2/users",
];
for endpoint in rest_endpoints {
let test_url = format!("{}{}", base_url, endpoint);
if let Ok(response) = self.http_client.get(&test_url).await {
if response.status_code == 200
&& response.body.contains("\"id\"")
&& response.body.contains("\"name\"")
&& response.body.contains("\"slug\"")
{
let mut users = Vec::new();
let user_re = Regex::new(r#""slug"\s*:\s*"([^"]+)""#).ok();
if let Some(re) = user_re {
for cap in re.captures_iter(&response.body) {
if let Some(username) = cap.get(1) {
users.push(username.as_str().to_string());
}
}
}
vulnerabilities.push(Vulnerability {
id: format!("wp_rest_users_{}", Self::generate_id()),
vuln_type: "WordPress REST API User Enumeration".to_string(),
severity: Severity::Medium,
confidence: Confidence::High,
category: "Information Disclosure".to_string(),
url: test_url.clone(),
parameter: None,
payload: endpoint.to_string(),
description: format!(
"WordPress REST API exposes user information publicly. \
Found {} users: {}",
users.len(),
if users.len() > 5 {
format!("{}, ... and {} more",
users[..5].join(", "),
users.len() - 5
)
} else {
users.join(", ")
}
),
evidence: Some(format!(
"REST API response contains user data:\n{}...",
&response.body.chars().take(500).collect::<String>()
)),
cwe: "CWE-200".to_string(),
cvss: 5.3,
verified: true,
false_positive: false,
remediation: "1. Disable REST API user endpoint:\n\
add_filter('rest_endpoints', function($endpoints) {\n\
if (isset($endpoints['/wp/v2/users'])) {\n\
unset($endpoints['/wp/v2/users']);\n\
}\n\
if (isset($endpoints['/wp/v2/users/(?P<id>[\\\\d]+)'])) {\n\
unset($endpoints['/wp/v2/users/(?P<id>[\\\\d]+)']);\n\
}\n\
return $endpoints;\n\
});\n\
\n\
2. Or use a security plugin like Wordfence or iThemes Security".to_string(),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_data: None,
});
break;
}
}
}
vulnerabilities
}
async fn test_xmlrpc_comprehensive(&self, url: &str) -> Vec<Vulnerability> {
let mut vulnerabilities = Vec::new();
let base_url = self.get_base_url(url);
let xmlrpc_url = format!("{}/xmlrpc.php", base_url);
let list_methods = r#"<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>"#;
if let Ok(response) = self
.http_client
.post(&xmlrpc_url, list_methods.to_string())
.await
{
if response.status_code == 200 && response.body.contains("methodResponse") {
let mut exposed_methods = Vec::new();
let dangerous_methods = vec![
("wp.getUsersBlogs", "Authentication - brute force target"),
("wp.getAuthors", "User enumeration"),
("pingback.ping", "SSRF/DDoS amplification"),
("system.multicall", "Amplified brute force"),
("wp.uploadFile", "File upload"),
("wp.getPosts", "Content access"),
("wp.getUsers", "User enumeration"),
];
for (method, risk) in &dangerous_methods {
if response.body.contains(method) {
exposed_methods.push(format!("{} ({})", method, risk));
}
}
let pingback_test = r#"<?xml version="1.0"?>
<methodCall>
<methodName>pingback.ping</methodName>
<params>
<param><value><string>http://127.0.0.1:80/</string></value></param>
<param><value><string>http://127.0.0.1/</string></value></param>
</params>
</methodCall>"#;
let has_pingback_ssrf = if let Ok(pingback_response) = self
.http_client
.post(&xmlrpc_url, pingback_test.to_string())
.await
{
!pingback_response.body.contains("source URL does not exist")
&& pingback_response.body.contains("faultCode")
} else {
false
};
let severity = if exposed_methods
.iter()
.any(|m| m.contains("multicall") || m.contains("pingback"))
{
Severity::High
} else {
Severity::Medium
};
vulnerabilities.push(Vulnerability {
id: format!("wp_xmlrpc_{}", Self::generate_id()),
vuln_type: "WordPress XML-RPC Exposed".to_string(),
severity,
confidence: Confidence::High,
category: "Misconfiguration".to_string(),
url: xmlrpc_url.clone(),
parameter: None,
payload: "system.listMethods".to_string(),
description: format!(
"WordPress XML-RPC interface is enabled with potentially dangerous methods exposed.\n\n\
Exposed methods:\n{}\n\n\
Pingback SSRF risk: {}",
exposed_methods.join("\n"),
if has_pingback_ssrf { "Potentially vulnerable" } else { "Blocked" }
),
evidence: Some(format!(
"XML-RPC responds to listMethods. {} dangerous methods exposed.",
exposed_methods.len()
)),
cwe: "CWE-16".to_string(),
cvss: if has_pingback_ssrf { 7.5 } else { 5.3 },
verified: true,
false_positive: false,
remediation: "1. Disable XML-RPC completely if not needed:\n\
add_filter('xmlrpc_enabled', '__return_false');\n\n\
2. Or block via .htaccess:\n\
<Files xmlrpc.php>\n\
Order Deny,Allow\n\
Deny from all\n\
</Files>\n\n\
3. Disable pingback specifically:\n\
add_filter('xmlrpc_methods', function($methods) {\n\
unset($methods['pingback.ping']);\n\
unset($methods['pingback.extensions.getPingbacks']);\n\
return $methods;\n\
});".to_string(),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_data: None,
});
}
}
vulnerabilities
}
async fn test_config_exposure(&self, url: &str) -> Vec<Vulnerability> {
let mut vulnerabilities = Vec::new();
let base_url = self.get_base_url(url);
let config_files = vec![
("/wp-config.php", "Main WordPress configuration"),
("/wp-config.php.bak", "Configuration backup"),
("/wp-config.php.old", "Old configuration"),
("/wp-config.php.txt", "Configuration as text"),
("/wp-config.php~", "Editor backup"),
("/wp-config.php.save", "Editor save"),
("/wp-config.php.swp", "Vim swap file"),
("/wp-config.bak", "Configuration backup"),
("/wp-config.txt", "Configuration as text"),
(
"/wp-config-sample.php",
"Sample configuration (version disclosure)",
),
("/.wp-config.php.swp", "Hidden vim swap"),
("/wp-config.php.orig", "Original backup"),
];
for (file, description) in config_files {
let test_url = format!("{}{}", base_url, file);
if let Ok(response) = self.http_client.get(&test_url).await {
if response.status_code == 200 {
let has_sensitive_data = response.body.contains("DB_NAME")
|| response.body.contains("DB_PASSWORD")
|| response.body.contains("DB_USER")
|| response.body.contains("AUTH_KEY")
|| response.body.contains("SECURE_AUTH_KEY")
|| response.body.contains("table_prefix");
if has_sensitive_data {
vulnerabilities.push(Vulnerability {
id: format!("wp_config_exposed_{}", Self::generate_id()),
vuln_type: "WordPress Configuration File Exposed".to_string(),
severity: Severity::Critical,
confidence: Confidence::High,
category: "Sensitive Data Exposure".to_string(),
url: test_url.clone(),
parameter: None,
payload: file.to_string(),
description: format!(
"WordPress configuration file exposed: {}\n\
This file contains database credentials, authentication keys, and other sensitive information.\n\
{}",
file, description
),
evidence: Some("Configuration file contains sensitive data (DB credentials, auth keys)".to_string()),
cwe: "CWE-200".to_string(),
cvss: 9.8,
verified: true,
false_positive: false,
remediation: "1. CRITICAL: Change ALL database credentials and auth keys immediately!\n\
2. Remove or secure the backup file\n\
3. Add to .htaccess to protect config files:\n\
<FilesMatch \"^wp-config\">\n\
Order Allow,Deny\n\
Deny from all\n\
</FilesMatch>\n\
4. Move wp-config.php one directory up\n\
5. Ensure backup files are not in web root".to_string(),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_data: None,
});
break; }
}
}
}
vulnerabilities
}
async fn test_debug_log_exposure(&self, url: &str) -> Vec<Vulnerability> {
let mut vulnerabilities = Vec::new();
let base_url = self.get_base_url(url);
let debug_files = vec![
"/wp-content/debug.log",
"/debug.log",
"/wp-content/uploads/debug.log",
"/error_log",
"/wp-content/error_log",
];
for file in debug_files {
let test_url = format!("{}{}", base_url, file);
if let Ok(response) = self.http_client.get(&test_url).await {
if response.status_code == 200 && response.body.len() > 100 {
let is_log_file = response.body.contains("PHP")
|| response.body.contains("Warning")
|| response.body.contains("Error")
|| response.body.contains("Notice")
|| response.body.contains("Fatal")
|| response.body.contains("Stack trace");
if is_log_file {
vulnerabilities.push(Vulnerability {
id: format!("wp_debug_log_{}", Self::generate_id()),
vuln_type: "WordPress Debug Log Exposed".to_string(),
severity: Severity::High,
confidence: Confidence::High,
category: "Information Disclosure".to_string(),
url: test_url.clone(),
parameter: None,
payload: file.to_string(),
description: format!(
"WordPress debug log is publicly accessible at: {}\n\
Debug logs may contain sensitive information including:\n\
- File paths revealing server structure\n\
- Database queries and errors\n\
- Plugin/theme error details\n\
- User information\n\
- SQL queries",
file
),
evidence: Some(format!(
"Debug log content preview:\n{}...",
&response.body.chars().take(500).collect::<String>()
)),
cwe: "CWE-532".to_string(),
cvss: 7.5,
verified: true,
false_positive: false,
remediation: "1. Delete the debug.log file\n\
2. Disable WP_DEBUG in production:\n\
define('WP_DEBUG', false);\n\
define('WP_DEBUG_LOG', false);\n\
define('WP_DEBUG_DISPLAY', false);\n\n\
3. If debugging is needed, restrict access:\n\
<Files debug.log>\n\
Order Allow,Deny\n\
Deny from all\n\
</Files>"
.to_string(),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_data: None,
});
break;
}
}
}
}
vulnerabilities
}
async fn test_version_disclosure(&self, url: &str) -> Vec<Vulnerability> {
let mut vulnerabilities = Vec::new();
let base_url = self.get_base_url(url);
let mut version_found = None;
let readme_url = format!("{}/readme.html", base_url);
if let Ok(response) = self.http_client.get(&readme_url).await {
if response.status_code == 200 && response.body.contains("WordPress") {
let version_re = Regex::new(r#"Version\s*([\d.]+)"#).ok();
if let Some(re) = version_re {
if let Some(cap) = re.captures(&response.body) {
if let Some(ver) = cap.get(1) {
version_found = Some(ver.as_str().to_string());
}
}
}
}
}
if version_found.is_none() {
if let Ok(response) = self.http_client.get(url).await {
let gen_re = Regex::new(r#"<meta[^>]*generator[^>]*WordPress\s*([\d.]+)"#).ok();
if let Some(re) = gen_re {
if let Some(cap) = re.captures(&response.body) {
if let Some(ver) = cap.get(1) {
version_found = Some(ver.as_str().to_string());
}
}
}
}
}
if version_found.is_none() {
let opml_url = format!("{}/wp-links-opml.php", base_url);
if let Ok(response) = self.http_client.get(&opml_url).await {
if response.status_code == 200 {
let ver_re = Regex::new(r#"generator="WordPress/([\d.]+)"#).ok();
if let Some(re) = ver_re {
if let Some(cap) = re.captures(&response.body) {
if let Some(ver) = cap.get(1) {
version_found = Some(ver.as_str().to_string());
}
}
}
}
}
}
if let Some(version) = version_found {
vulnerabilities.push(Vulnerability {
id: format!("wp_version_{}", Self::generate_id()),
vuln_type: "WordPress Version Disclosure".to_string(),
severity: Severity::Low,
confidence: Confidence::High,
category: "Information Disclosure".to_string(),
url: base_url.clone(),
parameter: None,
payload: String::new(),
description: format!(
"WordPress version {} detected.\n\
Version disclosure helps attackers identify known vulnerabilities for this specific version.",
version
),
evidence: Some(format!("WordPress version: {}", version)),
cwe: "CWE-200".to_string(),
cvss: 3.7,
verified: true,
false_positive: false,
remediation: "1. Remove readme.html from web root\n\
2. Remove version from generator meta tag:\n\
remove_action('wp_head', 'wp_generator');\n\
3. Remove version from RSS feeds:\n\
add_filter('the_generator', '__return_empty_string');\n\
4. Keep WordPress updated to latest version".to_string(),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_data: None,
});
}
vulnerabilities
}
async fn test_plugin_vulnerabilities(&self, url: &str) -> Vec<Vulnerability> {
let mut vulnerabilities = Vec::new();
let base_url = self.get_base_url(url);
for (plugin_slug, vulns) in &self.known_vulnerable_plugins {
let plugin_paths = vec![
format!("{}/wp-content/plugins/{}/readme.txt", base_url, plugin_slug),
format!("{}/wp-content/plugins/{}/README.txt", base_url, plugin_slug),
];
for path in plugin_paths {
if let Ok(response) = self.http_client.get(&path).await {
if response.status_code == 200 {
let version_re = Regex::new(r#"(?i)Stable tag:\s*([\d.]+)"#).ok();
let version = version_re.and_then(|re| {
re.captures(&response.body)
.and_then(|cap| cap.get(1).map(|v| v.as_str().to_string()))
});
if let Some(ver) = version {
for vuln in vulns {
if Self::version_vulnerable(&ver, &vuln.vulnerable_version) {
vulnerabilities.push(Vulnerability {
id: format!("wp_plugin_vuln_{}", Self::generate_id()),
vuln_type: format!(
"Vulnerable WordPress Plugin: {}",
plugin_slug
),
severity: vuln.severity.clone(),
confidence: Confidence::High,
category: "Known Vulnerability".to_string(),
url: path.clone(),
parameter: None,
payload: plugin_slug.clone(),
description: format!(
"Vulnerable plugin detected: {} version {}\n\n\
Vulnerability: {}\n\
CVE: {}\n\
Vulnerable versions: <= {}",
plugin_slug,
ver,
vuln.description,
vuln.cve.as_ref().unwrap_or(&"N/A".to_string()),
vuln.vulnerable_version
),
evidence: Some(format!(
"Plugin {} version {} detected via readme.txt",
plugin_slug, ver
)),
cwe: "CWE-1035".to_string(),
cvss: match vuln.severity {
Severity::Critical => 9.8,
Severity::High => 7.5,
Severity::Medium => 5.3,
_ => 3.7,
},
verified: true,
false_positive: false,
remediation: format!(
"1. Update {} to the latest version immediately\n\
2. Review the changelog for security fixes\n\
3. Check https://wpscan.com/plugins/{} for details",
plugin_slug, plugin_slug
),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_data: None,
});
}
}
}
break;
}
}
}
}
vulnerabilities
}
async fn test_backup_exposure(&self, url: &str) -> Vec<Vulnerability> {
let mut vulnerabilities = Vec::new();
let base_url = self.get_base_url(url);
let backup_patterns = vec![
"/backup.sql",
"/backup.sql.gz",
"/backup.sql.zip",
"/db.sql",
"/database.sql",
"/dump.sql",
"/wp-content/backup.sql",
"/wp-content/backups/",
"/backups/",
"/backup/",
"/bak/",
"/.sql",
"/wordpress.sql",
"/site.sql",
"/wp-content/uploads/backup.sql",
"/wp-content/updraft/", ];
for pattern in backup_patterns {
let test_url = format!("{}{}", base_url, pattern);
if let Ok(response) = self.http_client.get(&test_url).await {
if response.status_code == 200 {
let is_backup = response.body.contains("CREATE TABLE")
|| response.body.contains("INSERT INTO")
|| response.body.contains("DROP TABLE")
|| response.body.contains("wp_users")
|| response.body.contains("wp_options")
|| (response.body.len() > 10000 && pattern.contains(".sql"));
let is_directory = response.body.contains("Index of")
|| response.body.contains("<title>Index");
if is_backup {
vulnerabilities.push(Vulnerability {
id: format!("wp_backup_sql_{}", Self::generate_id()),
vuln_type: "WordPress Database Backup Exposed".to_string(),
severity: Severity::Critical,
confidence: Confidence::High,
category: "Sensitive Data Exposure".to_string(),
url: test_url.clone(),
parameter: None,
payload: pattern.to_string(),
description: "Database backup file is publicly accessible. \
This exposes all WordPress data including user credentials, \
posts, configuration, and potentially sensitive customer data."
.to_string(),
evidence: Some(
"SQL backup file containing database schema and data".to_string(),
),
cwe: "CWE-200".to_string(),
cvss: 9.8,
verified: true,
false_positive: false,
remediation: "1. CRITICAL: Remove backup file immediately!\n\
2. Change all user passwords\n\
3. Rotate authentication keys\n\
4. Review for sensitive data exposure\n\
5. Never store backups in web-accessible directories\n\
6. Use secure off-site backup storage"
.to_string(),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_data: None,
});
break;
} else if is_directory {
vulnerabilities.push(Vulnerability {
id: format!("wp_backup_dir_{}", Self::generate_id()),
vuln_type: "WordPress Backup Directory Exposed".to_string(),
severity: Severity::High,
confidence: Confidence::High,
category: "Misconfiguration".to_string(),
url: test_url.clone(),
parameter: None,
payload: pattern.to_string(),
description: "Backup directory with directory listing enabled. \
May contain database dumps, configuration files, or other sensitive data.".to_string(),
evidence: Some("Directory listing enabled on backup directory".to_string()),
cwe: "CWE-548".to_string(),
cvss: 7.5,
verified: true,
false_positive: false,
remediation: "1. Remove or secure the backup directory\n\
2. Disable directory listing\n\
3. Add .htaccess to deny access".to_string(),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_data: None,
});
}
}
}
}
vulnerabilities
}
async fn test_installation_exposure(&self, url: &str) -> Vec<Vulnerability> {
let mut vulnerabilities = Vec::new();
let base_url = self.get_base_url(url);
let install_url = format!("{}/wp-admin/install.php", base_url);
if let Ok(response) = self.http_client.get(&install_url).await {
if response.status_code == 200 {
let is_install_available = response.body.contains("Welcome to WordPress")
|| response.body.contains("installation process")
|| response.body.contains("wp-core-ui")
&& !response.body.contains("Already Installed");
if is_install_available {
vulnerabilities.push(Vulnerability {
id: format!("wp_install_{}", Self::generate_id()),
vuln_type: "WordPress Installation Script Accessible".to_string(),
severity: Severity::Critical,
confidence: Confidence::High,
category: "Misconfiguration".to_string(),
url: install_url,
parameter: None,
payload: "/wp-admin/install.php".to_string(),
description: "WordPress installation script is accessible. \
If wp-config.php is not properly configured, an attacker could \
potentially reinstall WordPress and gain admin access."
.to_string(),
evidence: Some("Installation script returns installation page".to_string()),
cwe: "CWE-16".to_string(),
cvss: 9.8,
verified: true,
false_positive: false,
remediation: "1. Complete WordPress installation\n\
2. Ensure wp-config.php is properly configured\n\
3. Delete install.php if WordPress is installed\n\
4. Block access via .htaccess:\n\
<Files install.php>\n\
Order Allow,Deny\n\
Deny from all\n\
</Files>"
.to_string(),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_data: None,
});
}
}
}
vulnerabilities
}
async fn test_directory_listing(&self, url: &str) -> Vec<Vulnerability> {
let mut vulnerabilities = Vec::new();
let base_url = self.get_base_url(url);
let directories = vec![
"/wp-content/",
"/wp-content/uploads/",
"/wp-content/plugins/",
"/wp-content/themes/",
"/wp-includes/",
];
for dir in directories {
let test_url = format!("{}{}", base_url, dir);
if let Ok(response) = self.http_client.get(&test_url).await {
if response.status_code == 200 {
let has_listing = response.body.contains("Index of")
|| response.body.contains("<title>Index")
|| response.body.contains("Parent Directory");
if has_listing {
vulnerabilities.push(Vulnerability {
id: format!("wp_dir_listing_{}", Self::generate_id()),
vuln_type: "WordPress Directory Listing Enabled".to_string(),
severity: Severity::Medium,
confidence: Confidence::High,
category: "Information Disclosure".to_string(),
url: test_url.clone(),
parameter: None,
payload: dir.to_string(),
description: format!(
"Directory listing is enabled at {}. \
This exposes file structure and may reveal sensitive files, \
plugin/theme versions, and upload contents.",
dir
),
evidence: Some("Directory index page with file listing".to_string()),
cwe: "CWE-548".to_string(),
cvss: 5.3,
verified: true,
false_positive: false,
remediation: "1. Add to .htaccess:\n\
Options -Indexes\n\n\
2. Or add empty index.php to each directory:\n\
<?php // Silence is golden"
.to_string(),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_data: None,
});
}
}
}
}
vulnerabilities
}
async fn test_theme_vulnerabilities(&self, url: &str) -> Vec<Vulnerability> {
let mut vulnerabilities = Vec::new();
let base_url = self.get_base_url(url);
if let Ok(response) = self.http_client.get(url).await {
let theme_re = Regex::new(r#"/wp-content/themes/([^/]+)/"#).ok();
if let Some(re) = theme_re {
let mut themes: Vec<String> = re
.captures_iter(&response.body)
.filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string()))
.collect();
themes.dedup();
for theme in themes {
let readme_url = format!("{}/wp-content/themes/{}/readme.txt", base_url, theme);
if let Ok(readme_response) = self.http_client.get(&readme_url).await {
if readme_response.status_code == 200 {
if readme_response
.body
.to_lowercase()
.contains("tested up to: 4")
|| readme_response
.body
.to_lowercase()
.contains("tested up to: 3")
{
vulnerabilities.push(Vulnerability {
id: format!("wp_theme_old_{}", Self::generate_id()),
vuln_type: format!("Outdated WordPress Theme: {}", theme),
severity: Severity::Medium,
confidence: Confidence::Medium,
category: "Known Vulnerability".to_string(),
url: readme_url,
parameter: None,
payload: theme.clone(),
description: format!(
"Theme '{}' appears to be significantly outdated. \
Outdated themes may contain known security vulnerabilities.",
theme
),
evidence: Some("Theme readme indicates old WordPress compatibility".to_string()),
cwe: "CWE-1035".to_string(),
cvss: 5.3,
verified: true,
false_positive: false,
remediation: format!(
"1. Update theme '{}' to the latest version\n\
2. Check theme changelog for security fixes\n\
3. Consider switching to a maintained theme if updates unavailable",
theme
),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_data: None,
});
}
}
}
}
}
}
vulnerabilities
}
async fn test_wp_cron_exposure(&self, url: &str) -> Vec<Vulnerability> {
let mut vulnerabilities = Vec::new();
let base_url = self.get_base_url(url);
let cron_url = format!("{}/wp-cron.php", base_url);
if let Ok(response) = self.http_client.get(&cron_url).await {
if response.status_code == 200 {
vulnerabilities.push(Vulnerability {
id: format!("wp_cron_{}", Self::generate_id()),
vuln_type: "WordPress WP-Cron Publicly Accessible".to_string(),
severity: Severity::Low,
confidence: Confidence::High,
category: "Misconfiguration".to_string(),
url: cron_url,
parameter: None,
payload: "/wp-cron.php".to_string(),
description: "wp-cron.php is publicly accessible and can be triggered externally. \
This can be used for denial of service or to trigger scheduled tasks unexpectedly. \
While often not critical, it's a known WordPress hardening recommendation to disable public access.".to_string(),
evidence: Some("wp-cron.php returns HTTP 200".to_string()),
cwe: "CWE-16".to_string(),
cvss: 3.7,
verified: true,
false_positive: false,
remediation: "1. Disable wp-cron and use real cron:\n\
Add to wp-config.php: define('DISABLE_WP_CRON', true);\n\n\
2. Set up server cron job:\n\
*/15 * * * * wget -q -O - https://yoursite.com/wp-cron.php?doing_wp_cron > /dev/null 2>&1\n\n\
3. Or block external access:\n\
<Files wp-cron.php>\n\
Order Deny,Allow\n\
Deny from all\n\
Allow from 127.0.0.1\n\
</Files>".to_string(),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_data: None,
});
}
}
vulnerabilities
}
async fn deep_plugin_scan(&self, url: &str) -> Vec<Vulnerability> {
let vulnerabilities = Vec::new();
let base_url = self.get_base_url(url);
let common_plugins = vec![
"akismet",
"jetpack",
"classic-editor",
"gutenberg",
"woocommerce",
"wp-mail-smtp",
"google-analytics-for-wordpress",
"wp-google-maps",
"cookie-notice",
"wordpress-seo",
"better-wp-security",
"sucuri-scanner",
"wpforms-lite",
"mailchimp-for-wp",
"google-sitemap-generator",
"tinymce-advanced",
"wp-optimize",
"regenerate-thumbnails",
"duplicate-post",
"redirection",
"tablepress",
"autoptimize",
"w3-total-cache",
"wp-fastest-cache",
"litespeed-cache",
];
for plugin in common_plugins {
let plugin_url = format!("{}/wp-content/plugins/{}/readme.txt", base_url, plugin);
if let Ok(response) = self.http_client.get(&plugin_url).await {
if response.status_code == 200 && response.body.contains("===") {
let version_re = Regex::new(r#"(?i)Stable tag:\s*([\d.]+)"#).ok();
if let Some(re) = version_re {
if let Some(cap) = re.captures(&response.body) {
if let Some(ver) = cap.get(1) {
debug!("Found plugin {} version {}", plugin, ver.as_str());
}
}
}
}
}
}
vulnerabilities
}
fn get_base_url(&self, url: &str) -> String {
if let Ok(parsed) = url::Url::parse(url) {
let host = parsed.host_str().unwrap_or("localhost");
let scheme = parsed.scheme();
if let Some(port) = parsed.port() {
format!("{}://{}:{}", scheme, host, port)
} else {
format!("{}://{}", scheme, host)
}
} else {
url.to_string()
}
}
fn version_vulnerable(current: &str, vulnerable: &str) -> bool {
let parse_version =
|v: &str| -> Vec<u32> { v.split('.').filter_map(|s| s.parse().ok()).collect() };
let current_parts = parse_version(current);
let vuln_parts = parse_version(vulnerable);
for (c, v) in current_parts.iter().zip(vuln_parts.iter()) {
if c < v {
return true;
}
if c > v {
return false;
}
}
current_parts.len() <= vuln_parts.len()
}
fn generate_id() -> String {
use rand::Rng;
let mut rng = rand::rng();
format!("{:08x}", rng.random::<u32>())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version_comparison() {
assert!(WordPressSecurityScanner::version_vulnerable(
"1.0.0", "1.0.1"
));
assert!(WordPressSecurityScanner::version_vulnerable("1.0", "1.0.1"));
assert!(!WordPressSecurityScanner::version_vulnerable(
"1.0.2", "1.0.1"
));
assert!(WordPressSecurityScanner::version_vulnerable(
"5.3.1", "5.3.1"
));
assert!(!WordPressSecurityScanner::version_vulnerable(
"5.3.2", "5.3.1"
));
}
}