WordPress Website Scanner: Complete Security Audit Guide
Table of Contents
WordPress Website Scanner: Complete Security Audit Guide
Master WordPress security scanning: inspect HTML source code, validate security headers, detect vulnerabilities, and automate comprehensive security audits
Why Security Scanning Matters: Regular security scanning is not optional—it’s essential. 73% of WordPress sites have at least one vulnerability, and 60% of those are detectable through automated scanning. A comprehensive security audit examines HTML source code, HTTP headers, file permissions, database configuration, and plugin/theme vulnerabilities.
This guide provides production-ready scanning techniques, WPCS-compliant code examples, and a complete checklist for thorough WordPress security audits.
01 WordPress Scanning Fundamentals
Effective WordPress security scanning requires understanding what to look for and where to find it. A comprehensive scan examines multiple attack surfaces.
What to Scan
HTML Source Code
Version leaks, hidden malware, unauthorized scripts, inline SQL queries, exposed API keys
High Priority
HTTP Headers
Missing security headers, CORS misconfigurations, cache control issues, server information leaks
Critical
File System
Modified core files, suspicious uploads, incorrect permissions, backup files in web root
Critical
Database
Malicious admin users, injected content, unauthorized options, weak credentials
High Priority
Plugins & Themes
Known vulnerabilities, outdated versions, nulled/pirated code, malicious modifications
Critical
Configuration
Debug mode enabled, weak keys, exposed credentials, insecure settings
Medium
Scanner Implementation Options
| Scanning Method | Advantages | Disadvantages | Best For |
|---|---|---|---|
| External Scanners (WPScan, Sucuri) |
No server access needed, comprehensive databases | Can’t check internal files, rate limited | Initial assessments, public sites |
| Plugin Scanners (Wordfence, iThemes) |
Deep file system access, real-time monitoring | Performance impact, plugin itself can be vulnerable | Ongoing monitoring, client sites |
| Custom Scripts (WP-CLI, PHP) |
Fully customizable, no dependencies | Requires development, maintenance burden | Enterprise, unique requirements |
| Server-Level (ClamAV, AIDE) |
Works without WordPress, low overhead | Generic checks, no WP-specific logic | Malware detection, file integrity |
02 HTML Source Code Inspection
The HTML source code reveals critical security information. Attackers examine it to find vulnerabilities, version numbers, and attack vectors.
What to Look For in HTML Source
Critical HTML Source Checks
Check for version numbers in generator tags, readme files, RSS feeds
Look for plugin paths in assets, theme directory references
Scan for base64 encoded content, obfuscated JavaScript, suspicious iframes
Check for API keys, database credentials, access tokens in comments or scripts
Hidden links, keyword stuffing, invisible text, link injection
Error messages, file paths, server details, stack traces
Automated HTML Source Scanner
<?php
/**
* Scan HTML source code for security issues.
*
* Detects version leaks, malicious code, and configuration issues.
*
* @since 1.0.0
*/
class HTML_Source_Scanner {
/**
* Scan results.
*
* @var array
*/
private $results = array();
/**
* Perform complete HTML source scan.
*
* @param string $url URL to scan.
* @return array Scan results.
*/
public function scan( $url ) {
$this->results = array(
'url' => $url,
'scan_time' => current_time( 'mysql' ),
'issues' => array(),
'info_leaks' => array(),
'malware_patterns' => array(),
);
// Fetch HTML content.
$html = $this->fetch_html( $url );
if ( ! $html ) {
$this->results['error'] = 'Failed to fetch HTML content';
return $this->results;
}
// Run checks.
$this->check_version_leaks( $html );
$this->check_plugin_enumeration( $html );
$this->check_malicious_patterns( $html );
$this->check_credentials_exposure( $html );
$this->check_debug_info( $html );
$this->check_seo_spam( $html );
return $this->results;
}
/**
* Fetch HTML content from URL.
*
* @param string $url URL to fetch.
* @return string|false HTML content or false on failure.
*/
private function fetch_html( $url ) {
$response = \wp_remote_get(
$url,
array(
'timeout' => 30,
'user-agent' => 'WordPress Security Scanner/1.0',
)
);
if ( \is_wp_error( $response ) ) {
return false;
}
return \wp_remote_retrieve_body( $response );
}
/**
* Check for WordPress version leaks.
*
* @param string $html HTML content.
*/
private function check_version_leaks( $html ) {
$patterns = array(
'generator_tag' => '/<meta name=["\']generator["\'] content=["\']WordPress ([0-9.]+)["\']/',
'rss_generator' => '/<generator>https?:\/\/wordpress\.org\/\?v=([0-9.]+)<\/generator>/',
'readme_version' => '/Version ([0-9.]+)/',
'core_css_version' => '/wp-includes\/css\/.*\.css\?ver=([0-9.]+)/',
);
foreach ( $patterns as $type => $pattern ) {
if ( preg_match( $pattern, $html, $matches ) ) {
$this->results['info_leaks'][] = array(
'type' => 'version_leak',
'source' => $type,
'version' => $matches[1],
'severity' => 'medium',
'message' => sprintf(
'WordPress version %s exposed via %s',
$matches[1],
str_replace( '_', ' ', $type )
),
);
}
}
}
/**
* Check for plugin enumeration vulnerabilities.
*
* @param string $html HTML content.
*/
private function check_plugin_enumeration( $html ) {
// Extract plugin paths.
preg_match_all(
'/wp-content\/plugins\/([^\/"\'\s]+)/',
$html,
$matches
);
if ( ! empty( $matches[1] ) ) {
$plugins = array_unique( $matches[1] );
foreach ( $plugins as $plugin_slug ) {
$this->results['info_leaks'][] = array(
'type' => 'plugin_enumeration',
'plugin' => $plugin_slug,
'severity' => 'low',
'message' => sprintf(
'Plugin "%s" enumerable from HTML source',
$plugin_slug
),
);
}
}
// Extract theme path.
if ( preg_match( '/wp-content\/themes\/([^\/"\'\s]+)/', $html, $theme_match ) ) {
$this->results['info_leaks'][] = array(
'type' => 'theme_enumeration',
'theme' => $theme_match[1],
'severity' => 'low',
'message' => sprintf(
'Theme "%s" enumerable from HTML source',
$theme_match[1]
),
);
}
}
/**
* Check for malicious code patterns.
*
* @param string $html HTML content.
*/
private function check_malicious_patterns( $html ) {
$malware_patterns = array(
'base64_decode' => '/base64_decode\s*\(/i',
'eval_code' => '/eval\s*\(/i',
'hidden_iframe' => '/<iframe[^>]*style=["\'][^"\']*display\s*:\s*none/i',
'suspicious_domain' => '/(viagra|cialis|casino|porn|xxx)\.(com|net|org)/i',
'obfuscated_js' => '/String\.fromCharCode\s*\(/i',
'shell_exec' => '/(exec|shell_exec|system|passthru)\s*\(/i',
);
foreach ( $malware_patterns as $name => $pattern ) {
if ( preg_match( $pattern, $html, $matches ) ) {
$this->results['malware_patterns'][] = array(
'type' => $name,
'pattern' => $pattern,
'match' => substr( $matches[0], 0, 100 ) . '...',
'severity' => 'critical',
'message' => sprintf(
'Suspicious pattern detected: %s',
str_replace( '_', ' ', $name )
),
);
}
}
}
/**
* Check for exposed credentials.
*
* @param string $html HTML content.
*/
private function check_credentials_exposure( $html ) {
$credential_patterns = array(
'api_key' => '/(api[_-]?key|apikey)\s*[:=]\s*[\'"]([a-zA-Z0-9_-]{20,})[\'"]/',
'access_token' => '/(access[_-]?token|token)\s*[:=]\s*[\'"]([a-zA-Z0-9_-]{20,})[\'"]/',
'aws_key' => '/(AKIA[0-9A-Z]{16})/',
'private_key' => '/(-----BEGIN (RSA |)PRIVATE KEY-----)/',
'database_creds' => '/(DB_PASSWORD|DB_USER|DB_NAME|DB_HOST)\s*[:=]/',
);
foreach ( $credential_patterns as $name => $pattern ) {
if ( preg_match( $pattern, $html, $matches ) ) {
$this->results['issues'][] = array(
'type' => 'credential_exposure',
'cred_type' => $name,
'severity' => 'critical',
'message' => sprintf(
'Potential credential exposure: %s found in HTML source',
str_replace( '_', ' ', $name )
),
);
}
}
}
/**
* Check for debug information leaks.
*
* @param string $html HTML content.
*/
private function check_debug_info( $html ) {
$debug_patterns = array(
'error_message' => '/(Warning|Notice|Fatal error|Parse error):/i',
'file_path' => '/in\s+(\/[^\s]+\.php)\s+on\s+line/i',
'sql_error' => '/SQL syntax.*MySQL/i',
'stack_trace' => '/Stack trace:/i',
'phpinfo' => '/<title>phpinfo\(\)<\/title>/i',
);
foreach ( $debug_patterns as $name => $pattern ) {
if ( preg_match( $pattern, $html, $matches ) ) {
$this->results['issues'][] = array(
'type' => 'debug_info',
'info_type' => $name,
'severity' => 'high',
'message' => sprintf(
'Debug information leak: %s detected',
str_replace( '_', ' ', $name )
),
);
}
}
}
/**
* Check for SEO spam.
*
* @param string $html HTML content.
*/
private function check_seo_spam( $html ) {
// Check for hidden text.
if ( preg_match( '/<[^>]*style=["\'][^"\']*display\s*:\s*none[^"\']*["\'][^>]*>[^<]+</', $html ) ) { $this->results['issues'][] = array(
'type' => 'seo_spam',
'spam_type' => 'hidden_text',
'severity' => 'medium',
'message' => 'Hidden text detected (possible SEO spam)',
);
}
// Check for suspicious links.
preg_match_all( '/<a[^>]+href=["\']([^"\']+)["\']/', $html, $link_matches );
if ( ! empty( $link_matches[1] ) ) {
$suspicious_domains = array( 'viagra', 'cialis', 'casino', 'porn', 'payday', 'loan' );
foreach ( $link_matches[1] as $link ) {
foreach ( $suspicious_domains as $keyword ) {
if ( false !== stripos( $link, $keyword ) ) {
$this->results['issues'][] = array(
'type' => 'seo_spam',
'spam_type' => 'suspicious_link',
'link' => $link,
'severity' => 'high',
'message' => 'Suspicious outbound link detected',
);
break;
}
}
}
}
}
/**
* Get scan results.
*
* @return array Scan results.
*/
public function get_results() {
return $this->results;
}
}
/**
* Run HTML source scan via WP-CLI.
*
* Usage: wp security-scan html-source https://example.com
*
* @since 1.0.0
*
* @param array $args Positional arguments.
* @param array $assoc_args Associative arguments.
*/
function cli_scan_html_source( $args, $assoc_args ) {
if ( empty( $args[0] ) ) {
WP_CLI::error( 'Please provide a URL to scan' );
return;
}
$url = $args[0];
WP_CLI::log( sprintf( 'Scanning HTML source: %s', $url ) );
$scanner = new HTML_Source_Scanner();
$results = $scanner->scan( $url );
// Display results.
WP_CLI::log( "\n=== SCAN RESULTS ===" );
if ( ! empty( $results['error'] ) ) {
WP_CLI::error( $results['error'] );
return;
}
WP_CLI::log( sprintf( 'Scan completed at: %s', $results['scan_time'] ) );
// Information leaks.
if ( ! empty( $results['info_leaks'] ) ) {
WP_CLI::warning( sprintf( "\nInformation Leaks: %d found", count( $results['info_leaks'] ) ) );
foreach ( $results['info_leaks'] as $leak ) {
WP_CLI::log( sprintf( ' - %s', $leak['message'] ) );
}
}
// Security issues.
if ( ! empty( $results['issues'] ) ) {
WP_CLI::error( sprintf( "\nSecurity Issues: %d found", count( $results['issues'] ) ), false );
foreach ( $results['issues'] as $issue ) {
WP_CLI::log( sprintf( ' - [%s] %s', strtoupper( $issue['severity'] ), $issue['message'] ) );
}
}
// Malware patterns.
if ( ! empty( $results['malware_patterns'] ) ) {
WP_CLI::error( sprintf( "\nMalware Patterns: %d found", count( $results['malware_patterns'] ) ), false );
foreach ( $results['malware_patterns'] as $pattern ) {
WP_CLI::log( sprintf( ' - %s', $pattern['message'] ) );
}
}
if ( empty( $results['issues'] ) && empty( $results['malware_patterns'] ) ) {
WP_CLI::success( 'No critical issues found' );
}
}
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command( 'security-scan html-source', 'cli_scan_html_source' );
}Run the HTML source scanner from command line:
wp security-scan html-source https://yoursite.com
03 Security Headers Analysis
HTTP security headers are your first line of defense against common web attacks. Missing or misconfigured headers leave your site vulnerable to XSS, clickjacking, and other attacks.
Critical Security Headers to Check
| Header | Purpose | Risk if Missing | Recommended Value |
|---|---|---|---|
| Content-Security-Policy | Prevents XSS attacks | Critical | Strict CSP with nonces |
| X-Frame-Options | Prevents clickjacking | High | DENY or SAMEORIGIN |
| X-Content-Type-Options | Prevents MIME sniffing | Medium | nosniff |
| Strict-Transport-Security | Enforces HTTPS | Critical | max-age=31536000 |
| Referrer-Policy | Controls referrer info | Low | strict-origin-when-cross-origin |
| Permissions-Policy | Restricts browser features | Medium | Restrict all unused features |
Security Headers Scanner
<?php
/**
* Scan HTTP security headers.
*
* Checks for missing or misconfigured security headers.
*
* @since 1.0.0
*/
class Security_Headers_Scanner {
/**
* Required security headers with recommendations.
*
* @var array
*/
private $required_headers = array(
'Content-Security-Policy' => array(
'severity' => 'critical',
'check' => 'exists',
),
'X-Frame-Options' => array(
'severity' => 'high',
'check' => 'value',
'expected' => array( 'DENY', 'SAMEORIGIN' ),
),
'X-Content-Type-Options' => array(
'severity' => 'medium',
'check' => 'value',
'expected' => array( 'nosniff' ),
),
'Strict-Transport-Security' => array(
'severity' => 'critical',
'check' => 'exists',
),
'Referrer-Policy' => array(
'severity' => 'low',
'check' => 'exists',
),
'Permissions-Policy' => array(
'severity' => 'medium',
'check' => 'exists',
),
);
/**
* Dangerous headers that should not be present.
*
* @var array
*/
private $dangerous_headers = array(
'X-Powered-By' => 'Reveals server technology',
'Server' => 'Reveals server software',
'X-AspNet-Version' => 'Reveals ASP.NET version',
'X-AspNetMvc-Version' => 'Reveals ASP.NET MVC version',
);
/**
* Scan security headers for a URL.
*
* @param string $url URL to scan.
* @return array Scan results.
*/
public function scan( $url ) {
$results = array(
'url' => $url,
'scan_time' => current_time( 'mysql' ),
'missing_headers' => array(),
'weak_headers' => array(),
'info_leaks' => array(),
'score' => 0,
);
// Fetch headers.
$headers = $this->fetch_headers( $url );
if ( ! $headers ) {
$results['error'] = 'Failed to fetch headers';
return $results;
}
// Check required headers.
foreach ( $this->required_headers as $header => $config ) {
$header_value = $this->get_header_value( $headers, $header );
if ( ! $header_value ) {
$results['missing_headers'][] = array(
'header' => $header,
'severity' => $config['severity'],
'message' => sprintf( 'Missing security header: %s', $header ),
);
} elseif ( 'value' === $config['check'] && isset( $config['expected'] ) ) {
if ( ! in_array( $header_value, $config['expected'], true ) ) {
$results['weak_headers'][] = array(
'header' => $header,
'value' => $header_value,
'expected' => implode( ' or ', $config['expected'] ),
'severity' => $config['severity'],
'message' => sprintf(
'Weak %s header: "%s" (expected: %s)',
$header,
$header_value,
implode( ' or ', $config['expected'] )
),
);
}
}
}
// Check for information disclosure headers.
foreach ( $this->dangerous_headers as $header => $description ) {
$header_value = $this->get_header_value( $headers, $header );
if ( $header_value ) {
$results['info_leaks'][] = array(
'header' => $header,
'value' => $header_value,
'severity' => 'medium',
'message' => sprintf(
'%s header present: %s',
$header,
$description
),
);
}
}
// Calculate security score.
$results['score'] = $this->calculate_score( $results );
return $results;
}
/**
* Fetch HTTP headers from URL.
*
* @param string $url URL to fetch.
* @return array|false Headers or false on failure.
*/
private function fetch_headers( $url ) {
$response = \wp_remote_head(
$url,
array(
'timeout' => 30,
'redirection' => 5,
)
);
if ( \is_wp_error( $response ) ) {
return false;
}
return \wp_remote_retrieve_headers( $response );
}
/**
* Get header value (case-insensitive).
*
* @param array $headers Header array.
* @param string $name Header name.
* @return string|false Header value or false if not found.
*/
private function get_header_value( $headers, $name ) {
foreach ( $headers as $key => $value ) {
if ( 0 === strcasecmp( $key, $name ) ) {
return is_array( $value ) ? implode( ', ', $value ) : $value;
}
}
return false;
}
/**
* Calculate security score (0-100).
*
* @param array $results Scan results.
* @return int Security score.
*/
private function calculate_score( $results ) {
$score = 100;
// Deduct for missing headers.
foreach ( $results['missing_headers'] as $issue ) {
switch ( $issue['severity'] ) {
case 'critical':
$score -= 20;
break;
case 'high':
$score -= 15;
break;
case 'medium':
$score -= 10;
break;
case 'low':
$score -= 5;
break;
}
}
// Deduct for weak headers.
foreach ( $results['weak_headers'] as $issue ) {
$score -= 10;
}
// Deduct for info leaks.
foreach ( $results['info_leaks'] as $issue ) {
$score -= 5;
}
return max( 0, $score );
}
}
/**
* Implement security headers in WordPress.
*
* @since 1.0.0
*/
function implement_security_headers() {
// Only add headers on front-end requests.
if ( \is_admin() ) {
return;
}
// Content Security Policy.
$csp_directives = array(
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.google-analytics.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"img-src 'self' data: https:",
"font-src 'self' https://fonts.gstatic.com",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
);
header( 'Content-Security-Policy: ' . implode( '; ', $csp_directives ) );
// X-Frame-Options.
header( 'X-Frame-Options: SAMEORIGIN' );
// X-Content-Type-Options.
header( 'X-Content-Type-Options: nosniff' );
// Strict-Transport-Security (only if HTTPS).
if ( \is_ssl() ) {
header( 'Strict-Transport-Security: max-age=31536000; includeSubDomains; preload' );
}
// Referrer-Policy.
header( 'Referrer-Policy: strict-origin-when-cross-origin' );
// Permissions-Policy.
$permissions = array(
'geolocation=()',
'microphone=()',
'camera=()',
'payment=()',
'usb=()',
'magnetometer=()',
'accelerometer=()',
'gyroscope=()',
);
header( 'Permissions-Policy: ' . implode( ', ', $permissions ) );
// Remove information disclosure headers.
header_remove( 'X-Powered-By' );
header_remove( 'Server' );
}
add_action( 'send_headers', 'implement_security_headers' );
/**
* WP-CLI command to scan security headers.
*
* Usage: wp security-scan headers https://example.com
*
* @since 1.0.0
*
* @param array $args Positional arguments.
*/
function cli_scan_headers( $args ) {
if ( empty( $args[0] ) ) {
WP_CLI::error( 'Please provide a URL to scan' );
return;
}
$url = $args[0];
WP_CLI::log( sprintf( 'Scanning security headers: %s', $url ) );
$scanner = new Security_Headers_Scanner();
$results = $scanner->scan( $url );
// Display results.
WP_CLI::log( "\n=== SECURITY HEADERS SCAN ===" );
WP_CLI::log( sprintf( 'Security Score: %d/100', $results['score'] ) );
if ( ! empty( $results['missing_headers'] ) ) {
WP_CLI::warning( sprintf( "\nMissing Headers: %d", count( $results['missing_headers'] ) ) );
foreach ( $results['missing_headers'] as $issue ) {
WP_CLI::log( sprintf( ' - [%s] %s', strtoupper( $issue['severity'] ), $issue['message'] ) );
}
}
if ( ! empty( $results['weak_headers'] ) ) {
WP_CLI::warning( sprintf( "\nWeak Headers: %d", count( $results['weak_headers'] ) ) );
foreach ( $results['weak_headers'] as $issue ) {
WP_CLI::log( sprintf( ' - %s', $issue['message'] ) );
}
}
if ( ! empty( $results['info_leaks'] ) ) {
WP_CLI::log( sprintf( "\nInformation Leaks: %d", count( $results['info_leaks'] ) ) );
foreach ( $results['info_leaks'] as $leak ) {
WP_CLI::log( sprintf( ' - %s', $leak['message'] ) );
}
}
if ( $results['score'] >= 90 ) {
WP_CLI::success( 'Excellent security header configuration!' );
} elseif ( $results['score'] >= 70 ) {
WP_CLI::warning( 'Good, but improvements recommended' );
} else {
WP_CLI::error( 'Security headers need immediate attention', false );
}
}
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command( 'security-scan headers', 'cli_scan_headers' );
}Content-Security-Policy is your most important security header. It prevents XSS attacks by controlling what resources can be loaded. However, implementing CSP requires careful planning:
- Start with
Content-Security-Policy-Report-Onlyto test - Monitor violation reports before enforcing
- Use nonces or hashes for inline scripts
- Avoid
'unsafe-inline'and'unsafe-eval'in production
04 WordPress Version Detection
Detecting the WordPress version is critical for vulnerability assessment. Attackers use this information to target known exploits.
Version Detection Methods
<?php
/**
* Detect WordPress version from multiple sources.
*
* @since 1.0.0
*/
class WP_Version_Detector {
/**
* Detect WordPress version.
*
* @param string $url Site URL.
* @return array Detection results.
*/
public function detect_version( $url ) {
$results = array(
'url' => $url,
'version_found' => false,
'version' => null,
'detection_method' => null,
'confidence' => 'none',
'all_detections' => array(),
);
// Try multiple detection methods.
$this->check_generator_tag( $url, $results );
$this->check_readme_file( $url, $results );
$this->check_rss_feed( $url, $results );
$this->check_core_files( $url, $results );
$this->check_api_endpoint( $url, $results );
// Determine final version with confidence level.
$this->determine_final_version( $results );
return $results;
}
/**
* Check generator meta tag.
*
* @param string $url Site URL.
* @param array $results Results array (passed by reference).
*/
private function check_generator_tag( $url, &$results ) {
$html = $this->fetch_content( $url );
if ( ! $html ) {
return;
}
if ( preg_match( '/<meta name=["\']generator["\'] content=["\']WordPress ([0-9.]+)["\']/', $html, $matches ) ) {
$results['all_detections'][] = array(
'method' => 'generator_tag',
'version' => $matches[1],
'confidence' => 'high',
);
}
}
/**
* Check readme.html file.
*
* @param string $url Site URL.
* @param array $results Results array (passed by reference).
*/
private function check_readme_file( $url, &$results ) {
$readme_url = trailingslashit( $url ) . 'readme.html';
$content = $this->fetch_content( $readme_url );
if ( ! $content ) {
return;
}
if ( preg_match( '/Version ([0-9.]+)/', $content, $matches ) ) {
$results['all_detections'][] = array(
'method' => 'readme_file',
'version' => $matches[1],
'confidence' => 'very_high',
);
}
}
/**
* Check RSS feed.
*
* @param string $url Site URL.
* @param array $results Results array (passed by reference).
*/
private function check_rss_feed( $url, &$results ) {
$feed_url = trailingslashit( $url ) . 'feed/';
$content = $this->fetch_content( $feed_url );
if ( ! $content ) {
return;
}
if ( preg_match( '/https?:\/\/wordpress\.org\/\?v=([0-9.]+)<\/generator>/', $content, $matches ) ) {
$results['all_detections'][] = array(
'method' => 'rss_feed',
'version' => $matches[1],
'confidence' => 'high',
);
}
}
/**
* Check core files version strings.
*
* @param string $url Site URL.
* @param array $results Results array (passed by reference).
*/
private function check_core_files( $url, &$results ) {
$core_files = array(
'wp-includes/css/dashicons.min.css',
'wp-includes/css/admin-bar.min.css',
'wp-includes/js/jquery/jquery.min.js',
);
foreach ( $core_files as $file ) {
$file_url = trailingslashit( $url ) . $file;
$content = $this->fetch_content( $file_url );
if ( ! $content ) {
continue;
}
if ( preg_match( '/\?ver=([0-9.]+)/', $content, $matches ) ) {
$results['all_detections'][] = array(
'method' => 'core_file_' . basename( $file ),
'version' => $matches[1],
'confidence' => 'medium',
);
break;
}
}
}
/**
* Check REST API endpoint.
*
* @param string $url Site URL.
* @param array $results Results array (passed by reference).
*/
private function check_api_endpoint( $url, &$results ) {
$api_url = trailingslashit( $url ) . 'wp-json/';
$response = \wp_remote_get(
$api_url,
array(
'timeout' => 30,
)
);
if ( \is_wp_error( $response ) ) {
return;
}
$body = \wp_remote_retrieve_body( $response );
$data = json_decode( $body, true );
if ( isset( $data['namespaces'] ) && in_array( 'wp/v2', $data['namespaces'], true ) ) {
// WordPress 4.7+.
$results['all_detections'][] = array(
'method' => 'rest_api',
'version' => '4.7+',
'confidence' => 'low',
);
}
}
/**
* Determine final version from all detections.
*
* @param array $results Results array (passed by reference).
*/
private function determine_final_version( &$results ) {
if ( empty( $results['all_detections'] ) ) {
return;
}
// Prioritize by confidence level.
$confidence_priority = array(
'very_high' => 4,
'high' => 3,
'medium' => 2,
'low' => 1,
);
$best_detection = null;
$best_priority = 0;
foreach ( $results['all_detections'] as $detection ) {
$priority = $confidence_priority[ $detection['confidence'] ] ?? 0;
if ( $priority > $best_priority ) {
$best_priority = $priority;
$best_detection = $detection;
}
}
if ( $best_detection ) {
$results['version_found'] = true;
$results['version'] = $best_detection['version'];
$results['detection_method'] = $best_detection['method'];
$results['confidence'] = $best_detection['confidence'];
}
}
/**
* Fetch content from URL.
*
* @param string $url URL to fetch.
* @return string|false Content or false on failure.
*/
private function fetch_content( $url ) {
$response = \wp_remote_get(
$url,
array(
'timeout' => 30,
)
);
if ( \is_wp_error( $response ) ) {
return false;
}
return \wp_remote_retrieve_body( $response );
}
}
/**
* Remove WordPress version from various locations.
*
* @since 1.0.0
*/
function remove_wordpress_version() {
// Remove version from head.
remove_action( 'wp_head', 'wp_generator' );
// Remove version from RSS feeds.
add_filter( 'the_generator', '__return_empty_string' );
// Remove version from scripts and styles.
add_filter(
'style_loader_src',
function( $src ) {
if ( strpos( $src, 'ver=' ) ) {
$src = remove_query_arg( 'ver', $src );
}
return $src;
},
999
);
add_filter(
'script_loader_src',
function( $src ) {
if ( strpos( $src, 'ver=' ) ) {
$src = remove_query_arg( 'ver', $src );
}
return $src;
},
999
);
}
add_action( 'init', 'remove_wordpress_version' );
/**
* Block access to readme.html.
*
* @since 1.0.0
*/
function block_readme_access() {
if ( isset( $_SERVER['REQUEST_URI'] ) && false !== strpos( $_SERVER['REQUEST_URI'], 'readme.html' ) ) {
\wp_die( 'File not found.', 'Not Found', array( 'response' => 404 ) );
}
}
add_action( 'init', 'block_readme_access' );Why Version Hiding Matters
When attackers know your exact WordPress version, they can:
- Target specific exploits for that version
- Identify if security patches are missing
- Automate attacks against outdated installations
- Determine plugin compatibility and likely configurations
Defense Strategy: Remove version information AND keep WordPress updated. Security through obscurity alone is not enough.
05 Plugin & Theme Vulnerability Scanning
Plugins and themes are the #1 source of WordPress vulnerabilities. 56% of hacked WordPress sites were compromised through vulnerable plugins.
Plugin Vulnerability Scanner
<?php
/**
* Scan plugins and themes for known vulnerabilities.
*
* Uses WPVulnDB API to check for security issues.
*
* @since 1.0.0
*/
class Plugin_Vulnerability_Scanner {
/**
* WPVulnDB API endpoint.
*
* @var string
*/
private $api_endpoint = 'https://wpscan.com/api/v3';
/**
* API token.
*
* @var string
*/
private $api_token;
/**
* Constructor.
*
* @param string $api_token WPScan API token.
*/
public function __construct( $api_token = '' ) {
$this->api_token = $api_token;
}
/**
* Scan all installed plugins.
*
* @return array Scan results.
*/
public function scan_plugins() {
$results = array(
'scan_time' => current_time( 'mysql' ),
'total_plugins' => 0,
'vulnerable' => array(),
'outdated' => array(),
'inactive' => array(),
);
// Get all plugins.
if ( ! function_exists( 'get_plugins' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$all_plugins = get_plugins();
$active_plugins = get_option( 'active_plugins', array() );
$results['total_plugins'] = count( $all_plugins );
foreach ( $all_plugins as $plugin_file => $plugin_data ) {
$is_active = in_array( $plugin_file, $active_plugins, true );
// Extract plugin slug.
$plugin_slug = dirname( $plugin_file );
if ( '.' === $plugin_slug ) {
$plugin_slug = basename( $plugin_file, '.php' );
}
// Check for vulnerabilities.
$vulnerabilities = $this->check_plugin_vulnerabilities(
$plugin_slug,
$plugin_data['Version']
);
if ( ! empty( $vulnerabilities ) ) {
$results['vulnerable'][] = array(
'name' => $plugin_data['Name'],
'slug' => $plugin_slug,
'version' => $plugin_data['Version'],
'active' => $is_active,
'vulnerabilities' => $vulnerabilities,
);
}
// Check if plugin is inactive.
if ( ! $is_active ) {
$results['inactive'][] = array(
'name' => $plugin_data['Name'],
'slug' => $plugin_slug,
'version' => $plugin_data['Version'],
);
}
// Check if update available.
$update_plugins = get_site_transient( 'update_plugins' );
if ( isset( $update_plugins->response[ $plugin_file ] ) ) {
$results['outdated'][] = array(
'name' => $plugin_data['Name'],
'slug' => $plugin_slug,
'current_version' => $plugin_data['Version'],
'new_version' => $update_plugins->response[ $plugin_file ]->new_version,
'active' => $is_active,
);
}
}
return $results;
}
/**
* Check plugin for known vulnerabilities.
*
* @param string $plugin_slug Plugin slug.
* @param string $version Plugin version.
* @return array Vulnerabilities found.
*/
private function check_plugin_vulnerabilities( $plugin_slug, $version ) {
if ( empty( $this->api_token ) ) {
return array();
}
$transient_key = 'vuln_check_' . md5( $plugin_slug . $version );
$cached = get_transient( $transient_key );
if ( false !== $cached ) {
return $cached;
}
$url = sprintf(
'%s/plugins/%s',
$this->api_endpoint,
$plugin_slug
);
$response = \wp_remote_get(
$url,
array(
'headers' => array(
'Authorization' => 'Token token=' . $this->api_token,
),
'timeout' => 30,
)
);
if ( \is_wp_error( $response ) ) {
return array();
}
$body = \wp_remote_retrieve_body( $response );
$data = json_decode( $body, true );
$vulnerabilities = array();
if ( isset( $data[ $plugin_slug ]['vulnerabilities'] ) ) {
foreach ( $data[ $plugin_slug ]['vulnerabilities'] as $vuln ) {
// Check if current version is affected.
if ( $this->is_version_affected( $version, $vuln ) ) {
$vulnerabilities[] = array(
'title' => $vuln['title'] ?? 'Unknown vulnerability',
'cvss' => $vuln['cvss']['score'] ?? 0,
'severity' => $this->get_severity_from_cvss( $vuln['cvss']['score'] ?? 0 ),
'fixed_in' => $vuln['fixed_in'] ?? 'Unknown',
);
}
}
}
// Cache results for 24 hours.
\set_transient( $transient_key, $vulnerabilities, DAY_IN_SECONDS );
return $vulnerabilities;
}
/**
* Check if version is affected by vulnerability.
*
* @param string $current_version Current plugin version.
* @param array $vuln_data Vulnerability data.
* @return bool True if affected.
*/
private function is_version_affected( $current_version, $vuln_data ) {
if ( ! isset( $vuln_data['fixed_in'] ) ) {
return true; // Assume affected if no fix version specified.
}
return version_compare( $current_version, $vuln_data['fixed_in'], '<' ); } /** * Get severity level from CVSS score. * * @param float $cvss CVSS score. * @return string Severity level. */ private function get_severity_from_cvss( $cvss ) { if ( $cvss >= 9.0 ) {
return 'critical';
} elseif ( $cvss >= 7.0 ) {
return 'high';
} elseif ( $cvss >= 4.0 ) {
return 'medium';
} else {
return 'low';
}
}
}
/**
* WP-CLI command to scan plugins for vulnerabilities.
*
* Usage: wp security-scan plugins --api-token=YOUR_TOKEN
*
* @since 1.0.0
*
* @param array $args Positional arguments.
* @param array $assoc_args Associative arguments.
*/
function cli_scan_plugins( $args, $assoc_args ) {
$api_token = $assoc_args['api-token'] ?? '';
if ( empty( $api_token ) ) {
WP_CLI::warning( 'No API token provided. Vulnerability checking will be limited.' );
WP_CLI::log( 'Get a free API token at: https://wpscan.com/api' );
}
WP_CLI::log( 'Scanning installed plugins...' );
$scanner = new Plugin_Vulnerability_Scanner( $api_token );
$results = $scanner->scan_plugins();
WP_CLI::log( sprintf( "\nTotal Plugins: %d", $results['total_plugins'] ) );
// Vulnerable plugins.
if ( ! empty( $results['vulnerable'] ) ) {
WP_CLI::error( sprintf( "\nVulnerable Plugins: %d", count( $results['vulnerable'] ) ), false );
foreach ( $results['vulnerable'] as $plugin ) {
WP_CLI::log( sprintf( "\n %s (v%s)", $plugin['name'], $plugin['version'] ) );
WP_CLI::log( sprintf( " Status: %s", $plugin['active'] ? 'Active' : 'Inactive' ) );
foreach ( $plugin['vulnerabilities'] as $vuln ) {
WP_CLI::log( sprintf(
" - [%s] %s (Fixed in: %s)",
strtoupper( $vuln['severity'] ),
$vuln['title'],
$vuln['fixed_in']
) );
}
}
} else {
WP_CLI::success( 'No known vulnerabilities found' );
}
// Outdated plugins.
if ( ! empty( $results['outdated'] ) ) {
WP_CLI::warning( sprintf( "\nOutdated Plugins: %d", count( $results['outdated'] ) ) );
foreach ( $results['outdated'] as $plugin ) {
WP_CLI::log( sprintf(
" - %s (%s → %s)",
$plugin['name'],
$plugin['current_version'],
$plugin['new_version']
) );
}
}
// Inactive plugins.
if ( ! empty( $results['inactive'] ) ) {
WP_CLI::log( sprintf( "\nInactive Plugins: %d (consider removing)", count( $results['inactive'] ) ) );
}
}
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command( 'security-scan plugins', 'cli_scan_plugins' );
}Get a WPScan API Token: The vulnerability scanner requires a free API token from WPScan.com
Free tier includes 25 API calls per day, which is sufficient for regular security audits.
06 File Integrity Checks
File integrity monitoring detects unauthorized modifications to core WordPress files, which often indicate a compromise.
<?php
/**
* File integrity checker for WordPress core files.
*
* Compares installed files against WordPress.org checksums.
*
* @since 1.0.0
*/
class File_Integrity_Checker {
/**
* Check core file integrity.
*
* @return array Check results.
*/
public function check_core_files() {
global $wp_version;
$results = array(
'version' => $wp_version,
'scan_time' => current_time( 'mysql' ),
'modified_files' => array(),
'missing_files' => array(),
'unexpected_files' => array(),
'total_checked' => 0,
);
// Get checksums from WordPress.org.
$checksums = $this->get_core_checksums( $wp_version );
if ( ! $checksums ) {
$results['error'] = 'Failed to retrieve core checksums';
return $results;
}
// Check each core file.
foreach ( $checksums as $file => $expected_checksum ) {
$file_path = ABSPATH . $file;
$results['total_checked']++;
if ( ! file_exists( $file_path ) ) {
$results['missing_files'][] = array(
'file' => $file,
'severity' => 'high',
);
continue;
}
// Calculate actual checksum.
$actual_checksum = md5_file( $file_path );
if ( $actual_checksum !== $expected_checksum ) {
$results['modified_files'][] = array(
'file' => $file,
'expected_checksum' => $expected_checksum,
'actual_checksum' => $actual_checksum,
'severity' => 'critical',
);
}
}
return $results;
}
/**
* Get core file checksums from WordPress.org.
*
* @param string $version WordPress version.
* @return array|false Checksums array or false on failure.
*/
private function get_core_checksums( $version ) {
$locale = \get_locale();
$url = sprintf(
'https://api.wordpress.org/core/checksums/1.0/?version=%s&locale=%s',
$version,
$locale
);
$response = \wp_remote_get(
$url,
array(
'timeout' => 30,
)
);
if ( \is_wp_error( $response ) ) {
return false;
}
$body = \wp_remote_retrieve_body( $response );
$data = json_decode( $body, true );
if ( ! isset( $data['checksums'] ) ) {
return false;
}
return $data['checksums'];
}
/**
* Scan uploads directory for suspicious files.
*
* @return array Suspicious files found.
*/
public function scan_uploads_directory() {
$upload_dir = \wp_upload_dir();
$base_dir = $upload_dir['basedir'];
$suspicious_files = array();
// Scan for PHP files in uploads.
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator( $base_dir )
);
foreach ( $iterator as $file ) {
if ( $file->isFile() ) {
$extension = strtolower( pathinfo( $file->getPathname(), PATHINFO_EXTENSION ) );
// PHP files shouldn't be in uploads directory.
if ( 'php' === $extension ) {
$suspicious_files[] = array(
'file' => str_replace( $base_dir, '', $file->getPathname() ),
'type' => 'php_in_uploads',
'severity' => 'critical',
'size' => $file->getSize(),
'modified' => date( 'Y-m-d H:i:s', $file->getMTime() ),
);
}
// Check for suspicious extensions.
$suspicious_extensions = array( 'phtml', 'php3', 'php4', 'php5', 'phps', 'pht', 'phar' );
if ( in_array( $extension, $suspicious_extensions, true ) ) {
$suspicious_files[] = array(
'file' => str_replace( $base_dir, '', $file->getPathname() ),
'type' => 'suspicious_extension',
'extension' => $extension,
'severity' => 'high',
);
}
}
}
return $suspicious_files;
}
}
/**
* WP-CLI command for file integrity check.
*
* Usage: wp security-scan file-integrity
*
* @since 1.0.0
*/
function cli_check_file_integrity() {
WP_CLI::log( 'Checking WordPress core file integrity...' );
$checker = new File_Integrity_Checker();
$results = $checker->check_core_files();
WP_CLI::log( sprintf( 'WordPress Version: %s', $results['version'] ) );
WP_CLI::log( sprintf( 'Files Checked: %d', $results['total_checked'] ) );
if ( ! empty( $results['modified_files'] ) ) {
WP_CLI::error(
sprintf( "\nModified Core Files: %d", count( $results['modified_files'] ) ),
false
);
foreach ( $results['modified_files'] as $file ) {
WP_CLI::log( sprintf( ' - %s', $file['file'] ) );
}
}
if ( ! empty( $results['missing_files'] ) ) {
WP_CLI::warning( sprintf( "\nMissing Core Files: %d", count( $results['missing_files'] ) ) );
foreach ( $results['missing_files'] as $file ) {
WP_CLI::log( sprintf( ' - %s', $file['file'] ) );
}
}
// Check uploads directory.
WP_CLI::log( "\nScanning uploads directory..." );
$suspicious = $checker->scan_uploads_directory();
if ( ! empty( $suspicious ) ) {
WP_CLI::error( sprintf( "\nSuspicious Files in Uploads: %d", count( $suspicious ) ), false );
foreach ( $suspicious as $file ) {
WP_CLI::log( sprintf(
' - %s [%s]',
$file['file'],
$file['type']
) );
}
} else {
WP_CLI::success( 'Uploads directory clean' );
}
if ( empty( $results['modified_files'] ) && empty( $results['missing_files'] ) ) {
WP_CLI::success( 'All core files verified successfully' );
} else {
WP_CLI::error( 'File integrity issues detected - immediate action required!' );
}
}
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command( 'security-scan file-integrity', 'cli_check_file_integrity' );
}07 Database Security Scanning
The WordPress database often contains injected malware, rogue admin accounts, and malicious options that traditional file scanners miss.
<?php
/**
* Database security scanner.
*
* Scans for rogue users, malicious content, and suspicious options.
*
* @since 1.0.0
*/
class Database_Security_Scanner {
/**
* Scan for security issues in database.
*
* @return array Scan results.
*/
public function scan() {
$results = array(
'scan_time' => current_time( 'mysql' ),
'rogue_admins' => array(),
'suspicious_users' => array(),
'malicious_posts' => array(),
'suspicious_options' => array(),
);
$this->check_admin_users( $results );
$this->check_suspicious_users( $results );
$this->check_post_content( $results );
$this->check_options( $results );
return $results;
}
/**
* Check for rogue administrator accounts.
*
* @param array $results Results array (passed by reference).
*/
private function check_admin_users( &$results ) {
$admin_users = \get_users( array( 'role' => 'administrator' ) );
foreach ( $admin_users as $user ) {
// Check for suspicious usernames.
$suspicious_patterns = array(
'admin2',
'support',
'service',
'temp',
'backup',
'webmaster',
'superadmin',
);
foreach ( $suspicious_patterns as $pattern ) {
if ( false !== stripos( $user->user_login, $pattern ) ) {
$results['rogue_admins'][] = array(
'id' => $user->ID,
'username' => $user->user_login,
'email' => $user->user_email,
'registered' => $user->user_registered,
'reason' => 'Suspicious username pattern',
);
break;
}
}
// Check for recently created admin accounts.
$created_timestamp = strtotime( $user->user_registered );
$days_ago = ( time() - $created_timestamp ) / DAY_IN_SECONDS;
if ( $days_ago < 7 ) { $results['rogue_admins'][] = array( 'id' => $user->ID,
'username' => $user->user_login,
'email' => $user->user_email,
'registered' => $user->user_registered,
'reason' => sprintf( 'Admin account created %.1f days ago', $days_ago ),
);
}
}
}
/**
* Check for users with suspicious characteristics.
*
* @param array $results Results array (passed by reference).
*/
private function check_suspicious_users( &$results ) {
global $wpdb;
// Users with no posts but editor/admin role.
$query = "
SELECT u.ID, u.user_login, u.user_email, u.user_registered
FROM {$wpdb->users} u
INNER JOIN {$wpdb->usermeta} um ON u.ID = um.user_id
WHERE um.meta_key = '{$wpdb->prefix}capabilities'
AND (um.meta_value LIKE '%administrator%' OR um.meta_value LIKE '%editor%')
AND u.ID NOT IN (
SELECT DISTINCT post_author FROM {$wpdb->posts}
)
";
$suspicious_users = $wpdb->get_results( $query );
foreach ( $suspicious_users as $user ) {
$results['suspicious_users'][] = array(
'id' => $user->ID,
'username' => $user->user_login,
'email' => $user->user_email,
'registered' => $user->user_registered,
'reason' => 'High privilege user with no posts',
);
}
}
/**
* Check post content for malicious code.
*
* @param array $results Results array (passed by reference).
*/
private function check_post_content( &$results ) {
global $wpdb;
$malware_patterns = array(
'base64_decode' => '%base64_decode%',
'eval' => '%eval(%',
'iframe' => '%<iframe%display:none%', 'shell_exec' => '%shell_exec%',
'system' => '%system(%',
);
foreach ( $malware_patterns as $name => $pattern ) {
$query = $wpdb->prepare(
"SELECT ID, post_title, post_type
FROM {$wpdb->posts}
WHERE post_content LIKE %s
LIMIT 20",
$pattern
);
$infected_posts = $wpdb->get_results( $query );
foreach ( $infected_posts as $post ) {
$results['malicious_posts'][] = array(
'id' => $post->ID,
'title' => $post->post_title,
'type' => $post->post_type,
'pattern' => $name,
'link' => \get_edit_post_link( $post->ID, 'raw' ),
);
}
}
}
/**
* Check options table for suspicious entries.
*
* @param array $results Results array (passed by reference).
*/
private function check_options( &$results ) {
global $wpdb;
// Check for autoloaded options with suspicious content.
$query = "
SELECT option_name, option_value
FROM {$wpdb->options}
WHERE autoload = 'yes'
AND (
option_value LIKE '%base64_decode%'
OR option_value LIKE '%eval(%'
OR option_value LIKE '%<iframe%' ) "; $suspicious_options = $wpdb->get_results( $query );
foreach ( $suspicious_options as $option ) {
$results['suspicious_options'][] = array(
'name' => $option->option_name,
'value' => substr( $option->option_value, 0, 100 ) . '...',
);
}
}
}
/**
* WP-CLI command for database scan.
*
* Usage: wp security-scan database
*
* @since 1.0.0
*/
function cli_scan_database() {
WP_CLI::log( 'Scanning database for security issues...' );
$scanner = new Database_Security_Scanner();
$results = $scanner->scan();
// Rogue admins.
if ( ! empty( $results['rogue_admins'] ) ) {
WP_CLI::error(
sprintf( "\nRogue Admin Accounts: %d", count( $results['rogue_admins'] ) ),
false
);
foreach ( $results['rogue_admins'] as $admin ) {
WP_CLI::log( sprintf(
' - User ID: %d, Username: %s, Email: %s',
$admin['id'],
$admin['username'],
$admin['email']
) );
WP_CLI::log( sprintf( ' Reason: %s', $admin['reason'] ) );
}
}
// Malicious posts.
if ( ! empty( $results['malicious_posts'] ) ) {
WP_CLI::error(
sprintf( "\nMalicious Post Content: %d", count( $results['malicious_posts'] ) ),
false
);
foreach ( $results['malicious_posts'] as $post ) {
WP_CLI::log( sprintf(
' - Post ID: %d, Pattern: %s',
$post['id'],
$post['pattern']
) );
}
}
// Suspicious options.
if ( ! empty( $results['suspicious_options'] ) ) {
WP_CLI::error(
sprintf( "\nSuspicious Options: %d", count( $results['suspicious_options'] ) ),
false
);
foreach ( $results['suspicious_options'] as $option ) {
WP_CLI::log( sprintf( ' - %s', $option['name'] ) );
}
}
if ( empty( $results['rogue_admins'] ) &&
empty( $results['malicious_posts'] ) &&
empty( $results['suspicious_options'] ) ) {
WP_CLI::success( 'No database security issues found' );
}
}
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command( 'security-scan database', 'cli_scan_database' );
}08 Automated Security Scanner Implementation
Implement a comprehensive automated scanner that runs all checks and generates detailed reports.
<?php
/**
* Comprehensive automated security scanner.
*
* Orchestrates all security scans and generates reports.
*
* @since 1.0.0
*/
class Automated_Security_Scanner {
/**
* Run complete security scan.
*
* @return array Complete scan results.
*/
public function run_full_scan() {
$start_time = microtime( true );
$report = array(
'scan_id' => uniqid( 'scan_' ),
'scan_date' => \current_time( 'mysql' ),
'site_url' => \home_url(),
'wp_version' => \get_bloginfo( 'version' ),
'php_version' => phpversion(),
'scan_results' => array(),
'summary' => array(
'total_issues' => 0,
'critical' => 0,
'high' => 0,
'medium' => 0,
'low' => 0,
),
);
// Run all scans.
$report['scan_results']['headers'] = $this->scan_security_headers();
$report['scan_results']['version'] = $this->scan_version_exposure();
$report['scan_results']['plugins'] = $this->scan_plugins();
$report['scan_results']['files'] = $this->scan_file_integrity();
$report['scan_results']['database'] = $this->scan_database();
// Calculate summary.
$this->calculate_summary( $report );
// Calculate execution time.
$report['execution_time'] = microtime( true ) - $start_time;
// Store report.
$this->store_report( $report );
// Send notification if critical issues found.
if ( $report['summary']['critical'] > 0 ) {
$this->send_critical_alert( $report );
}
return $report;
}
/**
* Scan security headers.
*
* @return array Results.
*/
private function scan_security_headers() {
$scanner = new Security_Headers_Scanner();
return $scanner->scan( home_url() );
}
/**
* Scan for version exposure.
*
* @return array Results.
*/
private function scan_version_exposure() {
$detector = new WP_Version_Detector();
return $detector->detect_version( home_url() );
}
/**
* Scan plugins.
*
* @return array Results.
*/
private function scan_plugins() {
$api_token = \get_option( 'wpscan_api_token', '' );
$scanner = new Plugin_Vulnerability_Scanner( $api_token );
return $scanner->scan_plugins();
}
/**
* Scan file integrity.
*
* @return array Results.
*/
private function scan_file_integrity() {
$checker = new File_Integrity_Checker();
return $checker->check_core_files();
}
/**
* Scan database.
*
* @return array Results.
*/
private function scan_database() {
$scanner = new Database_Security_Scanner();
return $scanner->scan();
}
/**
* Calculate issue summary.
*
* @param array $report Report array (passed by reference).
*/
private function calculate_summary( &$report ) {
// Count issues by severity.
foreach ( $report['scan_results'] as $scan_type => $results ) {
if ( isset( $results['issues'] ) ) {
foreach ( $results['issues'] as $issue ) {
$severity = $issue['severity'] ?? 'low';
$report['summary'][ $severity ]++;
$report['summary']['total_issues']++;
}
}
}
}
/**
* Store scan report in database.
*
* @param array $report Scan report.
*/
private function store_report( $report ) {
global $wpdb;
$table_name = $wpdb->prefix . 'security_scan_reports';
$wpdb->insert(
$table_name,
array(
'scan_id' => $report['scan_id'],
'scan_date' => $report['scan_date'],
'site_url' => $report['site_url'],
'wp_version' => $report['wp_version'],
'total_issues' => $report['summary']['total_issues'],
'critical_count' => $report['summary']['critical'],
'high_count' => $report['summary']['high'],
'medium_count' => $report['summary']['medium'],
'low_count' => $report['summary']['low'],
'execution_time' => $report['execution_time'],
'full_report' => \wp_json_encode( $report ),
),
array( '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%d', '%f', '%s' )
);
}
/**
* Send critical issue alert.
*
* @param array $report Scan report.
*/
private function send_critical_alert( $report ) {
$to = \get_option( 'admin_email' );
$subject = sprintf(
'CRITICAL: %d Security Issues Found on %s',
$report['summary']['critical'],
$report['site_url']
);
$message = sprintf(
"A security scan has detected critical issues on your WordPress site.\n\n" .
"Site: %s\n" .
"Scan ID: %s\n" .
"Scan Date: %s\n\n" .
"Summary:\n" .
"Critical Issues: %d\n" .
"High Priority: %d\n" .
"Medium Priority: %d\n" .
"Low Priority: %d\n\n" .
"Please log in to your WordPress admin panel to review the detailed report.",
$report['site_url'],
$report['scan_id'],
$report['scan_date'],
$report['summary']['critical'],
$report['summary']['high'],
$report['summary']['medium'],
$report['summary']['low']
);
\wp_mail( $to, $subject, $message );
}
}
/**
* Schedule automated security scans.
*
* @since 1.0.0
*/
function schedule_automated_scans() {
if ( ! \wp_next_scheduled( 'run_automated_security_scan' ) ) {
// Schedule daily scan at 3 AM.
\wp_schedule_event(
strtotime( 'tomorrow 3:00 AM' ),
'daily',
'run_automated_security_scan'
);
}
}
add_action( 'init', 'schedule_automated_scans' );
/**
* Execute automated security scan.
*
* @since 1.0.0
*/
function execute_automated_scan() {
$scanner = new Automated_Security_Scanner();
$scanner->run_full_scan();
}
add_action( 'run_automated_security_scan', 'execute_automated_scan' );Automated scanning provides:
- Continuous monitoring – Issues detected within 24 hours
- Consistent coverage – No manual checks forgotten
- Historical tracking – Trend analysis over time
- Immediate alerts – Critical issues reported instantly
- Compliance documentation – Audit trail for security reviews
09 Malware & Backdoor Detection
Malware detection requires pattern matching, behavioral analysis, and file inspection beyond standard security scans.
<?php
/**
* Malware and backdoor detection scanner.
*
* Uses signature and heuristic analysis to detect malicious code.
*
* @since 1.0.0
*/
class Malware_Detector {
/**
* Known malware signatures.
*
* @var array
*/
private $signatures = array(
'eval_base64' => '/eval\s*\(\s*base64_decode/',
'gzinflate_base64' => '/gzinflate\s*\(\s*base64_decode/',
'str_rot13' => '/str_rot13\s*\(\s*base64_decode/',
'preg_replace_e' => '/preg_replace\s*\(\s*[\'"]\/.*\/e[\'"]/',
'assert_base64' => '/assert\s*\(\s*base64_decode/',
'create_function' => '/create_function\s*\(/',
);
/**
* Suspicious function combinations.
*
* @var array
*/
private $suspicious_patterns = array(
'file_manipulation' => '/(file_put_contents|fwrite|fopen).*\$_(GET|POST|REQUEST|COOKIE)/',
'remote_inclusion' => '/(include|require|include_once|require_once).*\$_(GET|POST|REQUEST)/',
'command_execution' => '/(exec|shell_exec|system|passthru|popen|proc_open)/',
'dynamic_eval' => '/\$\{.*\(/',
);
/**
* Scan directory for malware.
*
* @param string $directory Directory to scan.
* @return array Infected files.
*/
public function scan_directory( $directory ) {
$infected_files = array();
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator( $directory )
);
foreach ( $iterator as $file ) {
if ( ! $file->isFile() ) {
continue;
}
$extension = strtolower( pathinfo( $file->getPathname(), PATHINFO_EXTENSION ) );
// Only scan PHP files.
if ( 'php' !== $extension ) {
continue;
}
$infections = $this->scan_file( $file->getPathname() );
if ( ! empty( $infections ) ) {
$infected_files[] = array(
'file' => str_replace( ABSPATH, '', $file->getPathname() ),
'infections' => $infections,
'size' => $file->getSize(),
'modified' => date( 'Y-m-d H:i:s', $file->getMTime() ),
);
}
}
return $infected_files;
}
/**
* Scan individual file for malware.
*
* @param string $file_path Path to file.
* @return array Infections found.
*/
private function scan_file( $file_path ) {
$content = file_get_contents( $file_path );
$infections = array();
if ( false === $content ) {
return $infections;
}
// Check signatures.
foreach ( $this->signatures as $name => $pattern ) {
if ( preg_match( $pattern, $content ) ) {
$infections[] = array(
'type' => 'signature',
'name' => $name,
'severity' => 'critical',
);
}
}
// Check suspicious patterns.
foreach ( $this->suspicious_patterns as $name => $pattern ) {
if ( preg_match( $pattern, $content ) ) {
$infections[] = array(
'type' => 'suspicious_pattern',
'name' => $name,
'severity' => 'high',
);
}
}
// Heuristic checks.
$this->heuristic_analysis( $content, $infections );
return $infections;
}
/**
* Perform heuristic analysis on file content.
*
* @param string $content File content.
* @param array $infections Infections array (passed by reference).
*/
private function heuristic_analysis( $content, &$infections ) {
// Check for excessive obfuscation.
$base64_count = substr_count( $content, 'base64_decode' );
if ( $base64_count > 5 ) {
$infections[] = array(
'type' => 'heuristic',
'name' => 'excessive_obfuscation',
'severity' => 'high',
'details' => sprintf( '%d base64_decode calls', $base64_count ),
);
}
// Check for unusually long strings (often encoded malware).
if ( preg_match( '/[\'"][a-zA-Z0-9+\/]{500,}[\'"]/', $content ) ) {
$infections[] = array(
'type' => 'heuristic',
'name' => 'long_encoded_string',
'severity' => 'medium',
);
}
// Check for PHP file with no readable text.
$readable_ratio = $this->calculate_readable_ratio( $content );
if ( $readable_ratio < 0.3 ) { $infections[] = array( 'type' => 'heuristic',
'name' => 'low_readability',
'severity' => 'medium',
'details' => sprintf( 'Readable ratio: %.1f%%', $readable_ratio * 100 ),
);
}
}
/**
* Calculate ratio of readable text in content.
*
* @param string $content Content to analyze.
* @return float Readable ratio (0-1).
*/
private function calculate_readable_ratio( $content ) {
$total_chars = strlen( $content );
if ( 0 === $total_chars ) {
return 0;
}
// Remove PHP tags and count alphanumeric + common punctuation.
$content_no_php = preg_replace( '/<\?php.*?\?>/s', '', $content );
$readable = preg_match_all( '/[a-zA-Z0-9\s\.\,\;\:\!\?]/', $content_no_php );
return $readable / $total_chars;
}
}
/**
* WP-CLI command for malware scan.
*
* Usage: wp security-scan malware
*
* @since 1.0.0
*/
function cli_scan_malware() {
WP_CLI::log( 'Scanning for malware and backdoors...' );
$detector = new Malware_Detector();
// Scan plugins directory.
WP_CLI::log( "\nScanning plugins directory..." );
$plugin_infections = $detector->scan_directory( WP_PLUGIN_DIR );
// Scan themes directory.
WP_CLI::log( "Scanning themes directory..." );
$theme_infections = $detector->scan_directory( \get_theme_root() );
// Scan uploads directory.
WP_CLI::log( "Scanning uploads directory..." );
$upload_dir = \wp_upload_dir();
$upload_infections = $detector->scan_directory( $upload_dir['basedir'] );
// Display results.
$all_infections = array_merge( $plugin_infections, $theme_infections, $upload_infections );
if ( ! empty( $all_infections ) ) {
WP_CLI::error(
sprintf( "\nInfected Files: %d", count( $all_infections ) ),
false
);
foreach ( $all_infections as $infection ) {
WP_CLI::log( sprintf( "\n File: %s", $infection['file'] ) );
WP_CLI::log( sprintf( " Modified: %s", $infection['modified'] ) );
WP_CLI::log( " Infections:" );
foreach ( $infection['infections'] as $issue ) {
WP_CLI::log( sprintf(
" - [%s] %s",
strtoupper( $issue['severity'] ),
$issue['name']
) );
}
}
WP_CLI::error( "\nIMMEDIATE ACTION REQUIRED: Malware detected!" );
} else {
WP_CLI::success( 'No malware detected' );
}
}
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command( 'security-scan malware', 'cli_scan_malware' );
}- Isolate immediately – Take site offline or restrict access
- Don’t delete yet – Preserve evidence for forensics
- Change all credentials – Database, admin, FTP, hosting panel
- Scan backups – Malware may predate last backup
- Identify entry point – How did malware get in?
- Clean or restore – Remove malware or restore from clean backup
- Harden security – Fix vulnerability that allowed infection
10 Security Scan Reporting
Comprehensive reporting turns scan data into actionable insights. Generate detailed reports for stakeholders and track security improvements over time.
Complete WordPress Security Scan Checklist
Version leaks, plugin enumeration, malicious scripts, exposed credentials
CSP, X-Frame-Options, HSTS, X-Content-Type-Options, Permissions-Policy
Check all version exposure methods and verify concealment
Known CVEs, outdated versions, nulled/pirated code
Core file checksums, unexpected files in uploads, suspicious extensions
Rogue admin accounts, injected content, suspicious options
Signature matching, heuristic analysis, obfuscated code
Writable directories, executable permissions, ownership issues
Certificate validity, cipher strength, protocol versions
Backup existence, restoration testing, backup security
Recommended Scanning Schedule
| Scan Type | Frequency | Priority | Automated |
|---|---|---|---|
| Security Headers | Weekly | High | Yes |
| Plugin Vulnerabilities | Daily | Critical | Yes |
| File Integrity | Daily | Critical | Yes |
| Database Scan | Weekly | High | Yes |
| Malware Detection | Daily | Critical | Yes |
| Full Manual Audit | Monthly | Medium | No |
Conclusion & Action Plan
WordPress security scanning is not a one-time task—it’s an ongoing process. Regular automated scans combined with periodic manual audits provide the best protection.
- Set up automated scanning – Implement daily scans with the provided code
- Get WPScan API token – Enable vulnerability checking (free tier available)
- Install WP-CLI – Run manual scans when needed
- Configure alerts – Ensure critical issues trigger immediate notifications
- Document baselines – Record current security state for comparison
- Schedule manual audits – Monthly comprehensive reviews
- Test incident response – Verify your team knows what to do if malware is found
Security Scanning Best Practices
- Scan before and after updates – Verify no malicious modifications
- Compare scan results – Trend analysis reveals patterns
- Don’t rely on single tool – Use multiple scanners for comprehensive coverage
- Test on staging first – Verify scanners don’t break functionality
- Keep scan tools updated – Malware signatures constantly evolve
- Document findings – Maintain security audit trail