Skip to content

Commit bc930bd

Browse files
Andrew Bakerclaude
andcommitted
feat: CS Monitor — brute-force tracking, plugin updates, extended security audit
Failed login tracking (fail2ban equivalent): - wp_login_failed hook increments rolling transients: 1h and 24h windows - Critical issue if ≥10 failures/hour (active brute force), warning ≥3/hour - Info if ≥10 in 24h with no acute spike New security checks in computeIssues() + Site Health summary: - Critical: "admin" username exists (prime credential-stuffing target) - Critical: PHP < 8.2 is end-of-life (no security patches) - Warning: PHP 8.2 approaching EOL December 2026 - Warning: XML-RPC enabled (system.multicall brute-force amplification) - Warning: Default wp_ table prefix (harder to exploit with custom prefix) - Warning: Plugins with pending updates (reads cached update_plugins transient — no HTTP calls) - Info: readme.html / license.txt present (WP version fingerprinting) - Info: Author enumeration risk (/?author=1 reveals usernames with pretty permalinks) perf_get_plugin_update_info() reads get_site_transient('update_plugins') — zero extra HTTP calls, uses WordPress's own update check cache Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6b769d7 commit bc930bd

File tree

2 files changed

+220
-12
lines changed

2 files changed

+220
-12
lines changed

assets/cs-perf-monitor.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,6 +1012,77 @@
10121012
detail: 'Add define(\'DISALLOW_FILE_MODS\', true) to wp-config.php for hardened servers', plugin: '' });
10131013
}
10141014

1015+
// "admin" username exists — prime brute-force target
1016+
if (health.admin_user_exists) {
1017+
issuesList.push({ sev: 'critical', tab: 'summary',
1018+
title: 'Username "admin" exists — prime brute-force target',
1019+
detail: 'Rename in Users → Profile. Transfer content first, then delete the old account.', plugin: '' });
1020+
}
1021+
1022+
// Default wp_ table prefix
1023+
if (health.db_prefix_default) {
1024+
issuesList.push({ sev: 'warning', tab: 'summary',
1025+
title: 'Default wp_ database prefix — easier to exploit in SQL injection',
1026+
detail: 'Change prefix if recently set up; requires a full DB backup on existing sites', plugin: '' });
1027+
}
1028+
1029+
// XML-RPC enabled
1030+
if (health.xmlrpc_enabled) {
1031+
issuesList.push({ sev: 'warning', tab: 'summary',
1032+
title: 'XML-RPC enabled — brute-force amplification vector',
1033+
detail: "system.multicall allows 100s of password attempts per request — disable via add_filter('xmlrpc_enabled','__return_false')", plugin: '' });
1034+
}
1035+
1036+
// readme.html / license.txt expose WP version
1037+
if (health.readme_exposed || health.license_exposed) {
1038+
var expFiles = [health.readme_exposed ? 'readme.html' : '', health.license_exposed ? 'license.txt' : ''].filter(Boolean).join(', ');
1039+
issuesList.push({ sev: 'info', tab: 'summary',
1040+
title: 'WP version disclosed via ' + expFiles,
1041+
detail: 'Delete these files or block via server config to prevent version fingerprinting', plugin: '' });
1042+
}
1043+
1044+
// PHP EOL
1045+
if (health.php_eol) {
1046+
issuesList.push({ sev: 'critical', tab: 'summary',
1047+
title: 'PHP ' + meta.php_version + ' is end-of-life — no security patches',
1048+
detail: 'Upgrade to PHP 8.3 or 8.4 — all PHP < 8.2 reached end-of-life', plugin: '' });
1049+
} else if (health.php_old) {
1050+
issuesList.push({ sev: 'warning', tab: 'summary',
1051+
title: 'PHP ' + meta.php_version + ' reaches end-of-life December 2026',
1052+
detail: 'Plan upgrade to PHP 8.3+ — approaching end of security support', plugin: '' });
1053+
}
1054+
1055+
// Failed logins — brute-force signal (analogous to fail2ban for SSH)
1056+
if (health.failed_logins_1h >= 10) {
1057+
issuesList.push({ sev: 'critical', tab: 'summary',
1058+
title: health.failed_logins_1h + ' failed logins in the last hour — active brute force',
1059+
detail: health.failed_logins_24h + ' in 24 h — block source IP in Cloudflare or enforce 2FA', plugin: '' });
1060+
} else if (health.failed_logins_1h >= 3) {
1061+
issuesList.push({ sev: 'warning', tab: 'summary',
1062+
title: health.failed_logins_1h + ' failed login attempts in the last hour',
1063+
detail: health.failed_logins_24h + ' in 24 h', plugin: '' });
1064+
} else if (health.failed_logins_24h >= 10) {
1065+
issuesList.push({ sev: 'info', tab: 'summary',
1066+
title: health.failed_logins_24h + ' failed login attempts in the last 24 hours',
1067+
detail: 'No acute spike, but sustained probing detected', plugin: '' });
1068+
}
1069+
1070+
// Author enumeration
1071+
if (health.author_enum_risk) {
1072+
issuesList.push({ sev: 'info', tab: 'summary',
1073+
title: 'Author enumeration — /?author=1 reveals WordPress usernames',
1074+
detail: "Add add_filter('redirect_canonical','__return_false') or disable author archives", plugin: '' });
1075+
}
1076+
1077+
// Plugins with pending updates
1078+
var pUpdates = health.plugins_with_updates || [];
1079+
if (pUpdates.length > 0) {
1080+
var pNames = pUpdates.slice(0, 3).map(function (p) { return p.slug + ' (' + p.current + ' → ' + p.new_version + ')'; }).join(', ');
1081+
issuesList.push({ sev: 'warning', tab: 'summary',
1082+
title: pUpdates.length + ' plugin' + (pUpdates.length > 1 ? 's have' : ' has') + ' pending updates',
1083+
detail: pNames + (pUpdates.length > 3 ? ' + ' + (pUpdates.length - 3) + ' more' : ''), plugin: '' });
1084+
}
1085+
10151086
// Render-blocking scripts — in <head>, no defer/async
10161087
var renderBlocking = (data.assets && data.assets.scripts || []).filter(function (s) {
10171088
return s.src && !s.in_footer && s.strategy !== 'defer' && s.strategy !== 'async';
@@ -1479,6 +1550,52 @@
14791550
: health.cron_total + ' events scheduled, none overdue';
14801551
html += hBadge(cronOk, cronWarn, 'WP-Cron', cronDetail);
14811552

1553+
// "admin" username
1554+
html += hBadge(!health.admin_user_exists, false,
1555+
'"admin" username',
1556+
health.admin_user_exists ? 'EXISTS — rename immediately' : 'Not in use');
1557+
1558+
// DB prefix
1559+
html += hBadge(!health.db_prefix_default, true,
1560+
'DB table prefix',
1561+
health.db_prefix_default ? 'Default wp_ prefix in use' : 'Custom prefix set');
1562+
1563+
// XML-RPC
1564+
html += hBadge(!health.xmlrpc_enabled, true,
1565+
'XML-RPC',
1566+
health.xmlrpc_enabled ? 'Enabled — disable if not needed' : 'Disabled');
1567+
1568+
// PHP version
1569+
var phpOk = !health.php_eol && !health.php_old;
1570+
var phpWarn = !health.php_eol && health.php_old;
1571+
html += hBadge(phpOk, phpWarn, 'PHP version', meta.php_version + (health.php_eol ? ' — EOL' : health.php_old ? ' — EOL Dec 2026' : ' — supported'));
1572+
1573+
// Failed logins
1574+
var fl1h = health.failed_logins_1h || 0;
1575+
var fl24h = health.failed_logins_24h || 0;
1576+
var flOk = fl1h < 3 && fl24h < 10;
1577+
var flWarn = fl1h >= 3 && fl1h < 10;
1578+
html += hBadge(flOk, flWarn,
1579+
'Failed logins',
1580+
fl1h + ' in 1 h · ' + fl24h + ' in 24 h' + (fl1h >= 10 ? ' — ACTIVE BRUTE FORCE' : ''));
1581+
1582+
// readme / license exposure
1583+
var exposed = [health.readme_exposed ? 'readme.html' : '', health.license_exposed ? 'license.txt' : ''].filter(Boolean);
1584+
html += hBadge(exposed.length === 0, true,
1585+
'Version disclosure',
1586+
exposed.length > 0 ? exposed.join(', ') + ' present' : 'No version files found');
1587+
1588+
// Author enumeration
1589+
html += hBadge(!health.author_enum_risk, true,
1590+
'Author enumeration',
1591+
health.author_enum_risk ? '/?author=1 may reveal usernames' : 'Protected or disabled');
1592+
1593+
// Plugin updates
1594+
var puLen = (health.plugins_with_updates || []).length;
1595+
html += hBadge(puLen === 0, puLen > 0,
1596+
'Plugin updates',
1597+
puLen === 0 ? 'All plugins up to date' : puLen + ' pending update' + (puLen > 1 ? 's' : ''));
1598+
14821599
html += '</div>';
14831600
}
14841601

cs-code-block.php

Lines changed: 103 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Plugin Name: CloudScale DevTools
44
* Plugin URI: https://andrewbaker.ninja
55
* 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
77
* Author: Andrew Baker
88
* Author URI: https://andrewbaker.ninja
99
* License: GPL-2.0-or-later
@@ -38,7 +38,7 @@
3838
*/
3939
class CloudScale_DevTools {
4040

41-
const VERSION = '1.8.78';
41+
const VERSION = '1.8.79';
4242
const HLJS_VERSION = '11.11.1';
4343
const HLJS_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/';
4444
const TOOLS_SLUG = 'cloudscale-devtools';
@@ -301,6 +301,9 @@ public static function init() {
301301
add_filter( 'network_site_url', [ __CLASS__, 'login_custom_network_url' ], 10, 3 );
302302
add_filter( 'site_url', [ __CLASS__, 'login_custom_site_url' ], 10, 4 );
303303

304+
// Security monitor — always track failed logins regardless of monitor toggle.
305+
add_action( 'wp_login_failed', [ __CLASS__, 'perf_track_failed_login' ] );
306+
304307
// Custom 404 page + hiscore leaderboard.
305308
add_action( 'template_redirect', [ __CLASS__, 'maybe_custom_404' ], 1 );
306309
add_action( 'rest_api_init', [ __CLASS__, 'register_hiscore_routes' ] );
@@ -3080,20 +3083,108 @@ private static function perf_build_health_data(): array {
30803083
$wp_debug_display = defined( 'WP_DEBUG' ) && WP_DEBUG
30813084
&& ( ! defined( 'WP_DEBUG_DISPLAY' ) || WP_DEBUG_DISPLAY );
30823085

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+
30833119
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,
30943141
];
30953142
}
30963143

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+
30973188
/**
30983189
* Processes $wpdb->queries into a structured array for the panel.
30993190
*

0 commit comments

Comments
 (0)