request( '/xmlrpc.php', array( 'method' => 'GET' ) ); if ( is_wp_error( $response ) ) { return $this->info( __( 'Unable to probe xmlrpc.php (the loopback request failed). Verify manually whether it is reachable.', 'aardwolf-security-scanner' ) ); } $code = (int) wp_remote_retrieve_response_code( $response ); $body = wp_remote_retrieve_body( $response ); // A live XML-RPC endpoint answers a GET with the classic message. $reachable = ( 200 === $code || 405 === $code ) && false !== strpos( $body, 'XML-RPC server accepts POST requests only' ); if ( $reachable ) { return $this->warn( Aardwolf_Check_Result::SEVERITY_MEDIUM, __( 'xmlrpc.php is enabled and responding. Attackers abuse it to amplify brute-force attacks (system.multicall lets them try hundreds of passwords in one request) and to launch pingback-based DDoS reflection.', 'aardwolf-security-scanner' ), __( 'If you do not use the Jetpack/mobile app or remote publishing, disable XML-RPC — many security plugins and hosts offer a toggle, or you can block /xmlrpc.php at the web-server level. If you need it, at minimum disable pingbacks and rate-limit the endpoint.', 'aardwolf-security-scanner' ), 'https://kinsta.com/blog/xmlrpc-php/' ); } return $this->pass( __( 'XML-RPC is disabled or not reachable.', 'aardwolf-security-scanner' ) ); } } /** * readme.html leaks the exact WordPress version. */ class Aardwolf_Check_Readme extends Aardwolf_Check { public function id() { return 'readme_exposed'; } public function label() { return __( 'Version-leaking readme.html is not public', 'aardwolf-security-scanner' ); } public function category() { return __( 'Information Exposure', 'aardwolf-security-scanner' ); } public function run() { $response = $this->request( '/readme.html' ); if ( is_wp_error( $response ) ) { return $this->info( __( 'Unable to probe readme.html. Check manually whether it is accessible.', 'aardwolf-security-scanner' ) ); } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 === $code ) { return $this->warn( Aardwolf_Check_Result::SEVERITY_LOW, __( 'The default readme.html is publicly accessible. It advertises your exact WordPress version, letting attackers match your site against version-specific exploits at a glance.', 'aardwolf-security-scanner' ), __( 'Delete /readme.html (and /license.txt) from the site root, or block them at the web server. Note that core updates may recreate readme.html, so add a deny rule rather than relying on deletion alone.', 'aardwolf-security-scanner' ) ); } return $this->pass( __( 'readme.html is not publicly accessible.', 'aardwolf-security-scanner' ) ); } } /** * The generator meta tag advertises the WordPress version. */ class Aardwolf_Check_Version_Disclosure extends Aardwolf_Check { public function id() { return 'version_disclosure'; } public function label() { return __( 'WordPress version is not advertised', 'aardwolf-security-scanner' ); } public function category() { return __( 'Information Exposure', 'aardwolf-security-scanner' ); } public function run() { $response = $this->request( '/' ); if ( is_wp_error( $response ) ) { return $this->info( __( 'Unable to fetch the home page to inspect the generator tag.', 'aardwolf-security-scanner' ) ); } $body = wp_remote_retrieve_body( $response ); $leaks_generator = (bool) preg_match( '/]+name=["\']generator["\'][^>]+WordPress\s*[0-9]/i', $body ); $leaks_query_ver = (bool) preg_match( '/\?ver=[0-9]+\.[0-9]+(\.[0-9]+)?/', $body ) && false !== strpos( $body, 'wp-includes' ); if ( $leaks_generator ) { return $this->warn( Aardwolf_Check_Result::SEVERITY_LOW, __( 'The HTML source includes a tag exposing the WordPress version number. This gives attackers a free fingerprint of exactly which exploits might apply.', 'aardwolf-security-scanner' ), __( 'Remove the generator tag by adding remove_action( \'wp_head\', \'wp_generator\' ); in your theme or a small mu-plugin. Note this is defence-in-depth (security by obscurity), not a substitute for patching.', 'aardwolf-security-scanner' ) ); } if ( $leaks_query_ver ) { return $this->info( __( 'Asset URLs still carry ?ver= query strings, which can hint at versions. This is low risk and mainly a fingerprinting nicety for attackers.', 'aardwolf-security-scanner' ), __( 'Optionally strip version query args from enqueued scripts/styles. Keeping software patched matters far more.', 'aardwolf-security-scanner' ) ); } return $this->pass( __( 'No WordPress version is advertised in the page source.', 'aardwolf-security-scanner' ) ); } } /** * Directory listing is enabled for wp-content/uploads. */ class Aardwolf_Check_Directory_Listing extends Aardwolf_Check { public function id() { return 'directory_listing'; } public function label() { return __( 'Directory browsing is disabled', 'aardwolf-security-scanner' ); } public function category() { return __( 'Information Exposure', 'aardwolf-security-scanner' ); } public function run() { $uploads = wp_get_upload_dir(); $base_url = isset( $uploads['baseurl'] ) ? $uploads['baseurl'] : content_url( '/uploads' ); $response = wp_remote_get( trailingslashit( $base_url ), array( 'timeout' => 10, 'redirection' => 0, 'sslverify' => apply_filters( 'aardwolf_sc_sslverify', true ), ) ); if ( is_wp_error( $response ) ) { return $this->info( __( 'Unable to probe the uploads directory for browsing.', 'aardwolf-security-scanner' ) ); } $code = (int) wp_remote_retrieve_response_code( $response ); $body = wp_remote_retrieve_body( $response ); $listing_visible = 200 === $code && (bool) preg_match( '/\s*Index of/i', $body ); if ( $listing_visible ) { return $this->fail( Aardwolf_Check_Result::SEVERITY_MEDIUM, __( 'The wp-content/uploads directory returns a browsable file index. Anyone can walk your uploaded files — including backups, exports or documents you assumed were private.', 'aardwolf-security-scanner' ), __( 'Disable directory listing. On Apache add "Options -Indexes" to your .htaccess; on Nginx set "autoindex off;". Also place an empty index.php in sensitive directories as a fallback.', 'aardwolf-security-scanner' ) ); } return $this->pass( __( 'Directory browsing is disabled for the uploads directory.', 'aardwolf-security-scanner' ) ); } } /** * Sensitive files exposed at the web root (debug.log, backups, VCS folders). */ class Aardwolf_Check_Sensitive_Files extends Aardwolf_Check { public function id() { return 'sensitive_files'; } public function label() { return __( 'No sensitive files exposed at the web root', 'aardwolf-security-scanner' ); } public function category() { return __( 'Information Exposure', 'aardwolf-security-scanner' ); } public function run() { // Paths that should never be readable by an anonymous visitor. $candidates = array( '/wp-content/debug.log' => __( 'PHP debug log (may contain paths, queries and secrets)', 'aardwolf-security-scanner' ), '/.git/config' => __( 'Git repository metadata (can expose full source history)', 'aardwolf-security-scanner' ), '/.env' => __( 'Environment file (frequently holds database and API credentials)', 'aardwolf-security-scanner' ), '/wp-config.php.bak' => __( 'Backup of wp-config.php (contains database credentials)', 'aardwolf-security-scanner' ), '/wp-config.php.save' => __( 'Editor backup of wp-config.php', 'aardwolf-security-scanner' ), '/wp-config.txt' => __( 'Renamed wp-config exposed as plain text', 'aardwolf-security-scanner' ), ); $exposed = array(); foreach ( $candidates as $path => $description ) { $response = $this->request( $path ); if ( is_wp_error( $response ) ) { continue; } $code = (int) wp_remote_retrieve_response_code( $response ); $body = wp_remote_retrieve_body( $response ); if ( 200 !== $code || '' === $body ) { continue; } // Guard against soft-404s: some sites route unknown paths to a // themed "not found" page and still answer 200. Every file we probe // here is raw text (a log, a dotfile, a config backup), so if the // response looks like a rendered HTML page it is almost certainly // the theme's fallback rather than the real file. $looks_like_html_page = ( false !== stripos( $body, '</html>' ) || false !== stripos( $body, '<!doctype html' ) ); if ( $looks_like_html_page ) { continue; } $exposed[ $path ] = $description; } if ( ! empty( $exposed ) ) { $lines = array(); foreach ( $exposed as $path => $description ) { $lines[] = sprintf( '%s — %s', $path, $description ); } return $this->fail( Aardwolf_Check_Result::SEVERITY_HIGH, sprintf( /* translators: %s: newline separated list of exposed files. */ __( 'The following sensitive path(s) are readable by anyone: %s', 'aardwolf-security-scanner' ), "\n" . implode( "\n", $lines ) ), __( 'Remove these files from the web root immediately and rotate any credentials they may have leaked. Configure your web server to deny access to dotfiles, .log, .bak, .save and .git paths. Never keep backups or exports inside the public web root.', 'aardwolf-security-scanner' ) ); } return $this->pass( __( 'No common sensitive files were reachable at the web root.', 'aardwolf-security-scanner' ) ); } }