|
3 | 3 | * Plugin Name: CloudScale DevTools |
4 | 4 | * Plugin URI: https://andrewbaker.ninja |
5 | 5 | * Description: Developer toolkit with syntax-highlighted code blocks, SQL query tool, code migrator, site monitor, and login security (passkeys, TOTP, email 2FA, hide login URL). |
6 | | - * Version: 1.8.78 |
| 6 | + * Version: 1.8.79 |
7 | 7 | * Author: Andrew Baker |
8 | 8 | * Author URI: https://andrewbaker.ninja |
9 | 9 | * License: GPL-2.0-or-later |
|
38 | 38 | */ |
39 | 39 | class CloudScale_DevTools { |
40 | 40 |
|
41 | | - const VERSION = '1.8.78'; |
| 41 | + const VERSION = '1.8.79'; |
42 | 42 | const HLJS_VERSION = '11.11.1'; |
43 | 43 | const HLJS_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/'; |
44 | 44 | const TOOLS_SLUG = 'cloudscale-devtools'; |
@@ -301,6 +301,9 @@ public static function init() { |
301 | 301 | add_filter( 'network_site_url', [ __CLASS__, 'login_custom_network_url' ], 10, 3 ); |
302 | 302 | add_filter( 'site_url', [ __CLASS__, 'login_custom_site_url' ], 10, 4 ); |
303 | 303 |
|
| 304 | + // Security monitor — always track failed logins regardless of monitor toggle. |
| 305 | + add_action( 'wp_login_failed', [ __CLASS__, 'perf_track_failed_login' ] ); |
| 306 | + |
304 | 307 | // Custom 404 page + hiscore leaderboard. |
305 | 308 | add_action( 'template_redirect', [ __CLASS__, 'maybe_custom_404' ], 1 ); |
306 | 309 | add_action( 'rest_api_init', [ __CLASS__, 'register_hiscore_routes' ] ); |
@@ -3080,20 +3083,108 @@ private static function perf_build_health_data(): array { |
3080 | 3083 | $wp_debug_display = defined( 'WP_DEBUG' ) && WP_DEBUG |
3081 | 3084 | && ( ! defined( 'WP_DEBUG_DISPLAY' ) || WP_DEBUG_DISPLAY ); |
3082 | 3085 |
|
| 3086 | + // ── Credential / account hygiene ───────────────────────────────────── |
| 3087 | + $admin_user_exists = (bool) username_exists( 'admin' ); |
| 3088 | + |
| 3089 | + // ── Database ────────────────────────────────────────────────────────── |
| 3090 | + $db_prefix_default = ( $wpdb->prefix === 'wp_' ); |
| 3091 | + |
| 3092 | + // ── XML-RPC ─────────────────────────────────────────────────────────── |
| 3093 | + $xmlrpc_enabled = file_exists( ABSPATH . 'xmlrpc.php' ) |
| 3094 | + && (bool) apply_filters( 'xmlrpc_enabled', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound |
| 3095 | + |
| 3096 | + // ── File exposure ───────────────────────────────────────────────────── |
| 3097 | + $readme_exposed = file_exists( ABSPATH . 'readme.html' ); |
| 3098 | + $license_exposed = file_exists( ABSPATH . 'license.txt' ); |
| 3099 | + |
| 3100 | + // ── PHP version ─────────────────────────────────────────────────────── |
| 3101 | + // April 2026: 8.0 EOL Nov 2023, 8.1 EOL Dec 2025, 8.2 EOL Dec 2026, 8.3+ current. |
| 3102 | + $php_eol = version_compare( PHP_VERSION, '8.2', '<' ); // EOL — no security patches |
| 3103 | + $php_old = ! $php_eol && version_compare( PHP_VERSION, '8.2', '==' ); // 8.2 EOL Dec 2026 |
| 3104 | + |
| 3105 | + // ── Failed logins (brute-force signal) ──────────────────────────────── |
| 3106 | + $failed_logins_1h = (int) get_transient( 'cs_devtools_failed_logins_1h' ); |
| 3107 | + $failed_logins_24h = (int) get_transient( 'cs_devtools_failed_logins_24h' ); |
| 3108 | + |
| 3109 | + // ── Author enumeration ──────────────────────────────────────────────── |
| 3110 | + // With pretty permalinks on, /?author=1 redirects to /author/username/. |
| 3111 | + // Flag if pretty permalinks are active and no known filter blocks it. |
| 3112 | + $author_enum_risk = ! empty( get_option( 'permalink_structure' ) ) |
| 3113 | + && ! has_filter( 'redirect_canonical', '__return_false' ) |
| 3114 | + && ! has_action( 'template_redirect', '__return_false' ); |
| 3115 | + |
| 3116 | + // ── Plugins with pending updates ────────────────────────────────────── |
| 3117 | + $plugins_with_updates = self::perf_get_plugin_update_info(); |
| 3118 | + |
3083 | 3119 | return [ |
3084 | | - 'autoload_kb' => $autoload_kb, |
3085 | | - 'autoload_count' => $autoload_count, |
3086 | | - 'large_autoloads' => $large_autoloads, |
3087 | | - 'cron_total' => $cron_total, |
3088 | | - 'cron_overdue' => $cron_overdue, |
3089 | | - 'cron_overdue_list' => $overdue_list, |
3090 | | - 'wp_debug_display' => $wp_debug_display, |
3091 | | - 'disallow_file_edit' => defined( 'DISALLOW_FILE_EDIT' ) && DISALLOW_FILE_EDIT, |
3092 | | - 'disallow_file_mods' => defined( 'DISALLOW_FILE_MODS' ) && DISALLOW_FILE_MODS, |
3093 | | - 'site_https' => strpos( home_url(), 'https://' ) === 0, |
| 3120 | + 'autoload_kb' => $autoload_kb, |
| 3121 | + 'autoload_count' => $autoload_count, |
| 3122 | + 'large_autoloads' => $large_autoloads, |
| 3123 | + 'cron_total' => $cron_total, |
| 3124 | + 'cron_overdue' => $cron_overdue, |
| 3125 | + 'cron_overdue_list' => $overdue_list, |
| 3126 | + 'wp_debug_display' => $wp_debug_display, |
| 3127 | + 'disallow_file_edit' => defined( 'DISALLOW_FILE_EDIT' ) && DISALLOW_FILE_EDIT, |
| 3128 | + 'disallow_file_mods' => defined( 'DISALLOW_FILE_MODS' ) && DISALLOW_FILE_MODS, |
| 3129 | + 'site_https' => strpos( home_url(), 'https://' ) === 0, |
| 3130 | + 'admin_user_exists' => $admin_user_exists, |
| 3131 | + 'db_prefix_default' => $db_prefix_default, |
| 3132 | + 'xmlrpc_enabled' => $xmlrpc_enabled, |
| 3133 | + 'readme_exposed' => $readme_exposed, |
| 3134 | + 'license_exposed' => $license_exposed, |
| 3135 | + 'php_eol' => $php_eol, |
| 3136 | + 'php_old' => $php_old, |
| 3137 | + 'failed_logins_1h' => $failed_logins_1h, |
| 3138 | + 'failed_logins_24h' => $failed_logins_24h, |
| 3139 | + 'author_enum_risk' => $author_enum_risk, |
| 3140 | + 'plugins_with_updates' => $plugins_with_updates, |
3094 | 3141 | ]; |
3095 | 3142 | } |
3096 | 3143 |
|
| 3144 | + /** |
| 3145 | + * Fired on wp_login_failed. Increments rolling failed-login counters stored |
| 3146 | + * as transients so the CS Monitor can surface brute-force signals. |
| 3147 | + * |
| 3148 | + * @param string $username The username that failed authentication. |
| 3149 | + * @return void |
| 3150 | + */ |
| 3151 | + public static function perf_track_failed_login( string $username ): void { |
| 3152 | + // 1-hour rolling window. |
| 3153 | + $c1h = (int) get_transient( 'cs_devtools_failed_logins_1h' ); |
| 3154 | + set_transient( 'cs_devtools_failed_logins_1h', $c1h + 1, HOUR_IN_SECONDS ); |
| 3155 | + // 24-hour rolling window. |
| 3156 | + $c24h = (int) get_transient( 'cs_devtools_failed_logins_24h' ); |
| 3157 | + set_transient( 'cs_devtools_failed_logins_24h', $c24h + 1, DAY_IN_SECONDS ); |
| 3158 | + } |
| 3159 | + |
| 3160 | + /** |
| 3161 | + * Returns plugins that have a pending update available, using the cached |
| 3162 | + * update_plugins site transient (populated by WP's own update check cron). |
| 3163 | + * Never makes a live HTTP call — reads from DB only. |
| 3164 | + * |
| 3165 | + * @return array [ { slug, name, current, new_version } ] |
| 3166 | + */ |
| 3167 | + private static function perf_get_plugin_update_info(): array { |
| 3168 | + $update_data = get_site_transient( 'update_plugins' ); |
| 3169 | + if ( ! $update_data || empty( $update_data->response ) ) { |
| 3170 | + return []; |
| 3171 | + } |
| 3172 | + $results = []; |
| 3173 | + foreach ( $update_data->response as $plugin_file => $plugin_data ) { |
| 3174 | + $current_ver = $update_data->checked[ $plugin_file ] ?? ''; |
| 3175 | + $slug = $plugin_data->slug ?? basename( dirname( $plugin_file ) ); |
| 3176 | + $results[] = [ |
| 3177 | + 'plugin' => $plugin_file, |
| 3178 | + 'slug' => $slug, |
| 3179 | + 'current' => $current_ver, |
| 3180 | + 'new_version' => $plugin_data->new_version ?? '', |
| 3181 | + ]; |
| 3182 | + } |
| 3183 | + // Sort by slug name. |
| 3184 | + usort( $results, fn( $a, $b ) => strcmp( $a['slug'], $b['slug'] ) ); |
| 3185 | + return $results; |
| 3186 | + } |
| 3187 | + |
3097 | 3188 | /** |
3098 | 3189 | * Processes $wpdb->queries into a structured array for the panel. |
3099 | 3190 | * |
|
0 commit comments