AlkantarClanX12
| Current Path : /www/capitalgmcbuickregina_830/public/wp-content/mu-plugins/ |
| Current File : /www/capitalgmcbuickregina_830/public/wp-content/mu-plugins/lbx-login-monitor.php |
<?php
/**
* Plugin Name: LBX Login Monitor
* Description: Real-time failed login monitoring with Google Chat alerts. Watches for attacker blocklist IPs, brute force spikes, and logins from new IPs. Rate-limited to avoid spam.
* Version: 2.7.0
* Author: Leadbox Security
*/
if (!defined('ABSPATH')) exit;
/**
* Google Chat webhook URL for alerts.
* Override in wp-config.php with:
* define('LBX_ALERT_WEBHOOK', 'https://...');
*/
if (!defined('LBX_ALERT_WEBHOOK')) {
define('LBX_ALERT_WEBHOOK', 'https://chat.googleapis.com/v1/spaces/AAQAbTPa9eo/messages?key=AIzaSyDdI0hCZtE6vySjMm-WEfRq3CPzqKqqsHI&token=iuLXnmI9112Um_ipQqbZJS0I8t3sWAy0cKU8f4yyRpk');
}
/**
* Known attacker IPs from the April 2026 CVE-2026-0740 wave and the
* 2026-04-09 user enumeration recon wave.
*/
function lbx_attacker_blocklist() {
return [
// User enumeration wave (2026-04-09)
'62.60.131.158',
'62.60.131.175',
// Indonesian credential-stuffing wave (2026-04-12)
'180.252.169.1',
'36.37.209.184',
// Chinese webshell + wp-file-manager abuse (2026-04-24, capitalgmcbuickregina)
'23.142.200.55',
// CVE-2026-0740 exploitation wave (2026-04-07/08)
'109.107.178.102',
'176.213.124.119',
'46.246.126.191',
'185.36.143.39',
'192.36.61.172',
'194.31.175.187',
'18.182.54.150',
];
}
/**
* Extract real client IP, honoring Kinsta/Cloudflare proxy headers.
*/
function lbx_client_ip() {
foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'] as $h) {
if (!empty($_SERVER[$h])) {
$candidate = explode(',', $_SERVER[$h])[0];
$candidate = trim($candidate);
if (filter_var($candidate, FILTER_VALIDATE_IP)) {
return $candidate;
}
}
}
return 'unknown';
}
/**
* Rate-limit: only allow one alert per (trigger, key) pair within $cooldown seconds.
* Returns true if we're OK to send, false if in cooldown.
*/
function lbx_can_alert($key, $cooldown = 900) {
$transient = 'lbx_alert_' . md5($key);
if (get_transient($transient)) {
return false;
}
set_transient($transient, 1, $cooldown);
return true;
}
/**
* Look up country for an IP address via ip-api.com.
* Cached per-site for 24h to stay well under the 45 req/min free limit.
* Returns a string like "Indonesia (ID)" or empty string on failure.
*/
function lbx_ip_country($ip) {
if (!$ip || $ip === 'unknown' || !filter_var($ip, FILTER_VALIDATE_IP)) {
return '';
}
$cache_key = 'lbx_geo_' . md5($ip);
$cached = get_transient($cache_key);
if ($cached !== false) {
return $cached;
}
$resp = wp_remote_get(
"http://ip-api.com/json/{$ip}?fields=country,countryCode",
['timeout' => 2, 'sslverify' => false]
);
$country = '';
if (!is_wp_error($resp) && wp_remote_retrieve_response_code($resp) === 200) {
$body = json_decode(wp_remote_retrieve_body($resp), true);
if (isset($body['country']) && isset($body['countryCode'])) {
$country = $body['country'] . ' (' . $body['countryCode'] . ')';
}
}
// Cache even the empty result for 1h to avoid retry storms when API is down
set_transient($cache_key, $country, $country ? DAY_IN_SECONDS : HOUR_IN_SECONDS);
return $country;
}
/**
* Send alert to Google Chat webhook (non-blocking so it never delays requests).
*/
function lbx_send_alert(array $data) {
if (!LBX_ALERT_WEBHOOK) return;
$site = function_exists('get_bloginfo') ? get_bloginfo('name') : 'unknown';
$host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : '';
$severity_emoji = [
'critical' => '🚨',
'warning' => '⚠️',
'info' => 'ℹ️',
];
$emoji = $severity_emoji[$data['severity']] ?? 'ℹ️';
// Look up country for the IP (cached, non-blocking on cache hit)
$country = lbx_ip_country($data['ip']);
$ip_display = $country ? sprintf('`%s` — %s', $data['ip'], $country) : sprintf('`%s`', $data['ip']);
$lines = [
sprintf('%s *%s* — %s', $emoji, strtoupper($data['severity']), $data['trigger_label']),
sprintf('*Site:* %s (`%s`)', $site, $host),
sprintf('*IP:* %s', $ip_display),
];
if (!empty($data['user'])) {
$lines[] = sprintf('*User attempted:* `%s`', $data['user']);
}
if (!empty($data['fails_5min'])) {
$lines[] = sprintf('*Failed attempts (last 5 min):* %d', $data['fails_5min']);
}
if (!empty($data['ua'])) {
$ua = substr($data['ua'], 0, 120);
$lines[] = sprintf('*UA:* `%s`', $ua);
}
if (!empty($data['details'])) {
$lines[] = "```\n" . substr($data['details'], 0, 3000) . "\n```";
}
$lines[] = sprintf('*Time:* %s UTC', gmdate('Y-m-d H:i:s'));
$body = ['text' => implode("\n", $lines)];
wp_remote_post(LBX_ALERT_WEBHOOK, [
'method' => 'POST',
'timeout' => 2,
'blocking' => false, // fire-and-forget, don't delay the request
'headers' => ['Content-Type' => 'application/json; charset=UTF-8'],
'body' => wp_json_encode($body),
'data_format' => 'body',
'sslverify' => true,
]);
}
/**
* Track failed login attempts in a rolling 5-minute window per IP.
* Returns the count.
*/
function lbx_bump_fail_counter($ip) {
$key = 'lbx_fail_' . md5($ip);
$bucket = get_transient($key);
if (!is_array($bucket)) {
$bucket = [];
}
$now = time();
// Keep only hits from the last 5 min
$bucket = array_filter($bucket, function ($t) use ($now) {
return ($now - $t) < 300;
});
$bucket[] = $now;
set_transient($key, $bucket, 600); // 10 min TTL
return count($bucket);
}
/**
* Track whether this IP has ever had a successful login for this user.
* Returns true if this is the first-ever successful login from this IP for this user.
*/
function lbx_is_new_ip_for_user($user_login, $ip) {
$key = 'lbx_known_ips_' . md5($user_login);
$known = get_option($key, []);
if (!is_array($known)) {
$known = [];
}
if (in_array($ip, $known, true)) {
return false;
}
$known[] = $ip;
// Keep only last 50 per user to avoid bloat
if (count($known) > 50) {
$known = array_slice($known, -50);
}
update_option($key, $known, false);
return true;
}
/**
* HOOK: failed login.
*/
add_action('wp_login_failed', function ($username) {
$ip = lbx_client_ip();
$ua = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '';
// Trigger A: IP is on the known attacker blocklist → ALWAYS alert (once per 15 min)
if (in_array($ip, lbx_attacker_blocklist(), true)) {
if (lbx_can_alert("blocklist_$ip", 900)) {
lbx_send_alert([
'severity' => 'critical',
'trigger' => 'blocklist_ip_attempt',
'trigger_label' => 'Login attempt from attacker blocklist IP',
'ip' => $ip,
'user' => $username,
'ua' => $ua,
]);
}
return;
}
// ============================================================
// Filter (v2.6): only alert on actual attack PATTERNS, not single attempts.
// Trigger B1: brute force spike — >10 fails in 5 min from same IP (CRITICAL)
// Hits any target, catches distributed attacks.
// Trigger B2: sustained attack on EXISTING user — >5 fails in 5 min
// against a real account (WARNING). Catches slower credential
// stuffing while ignoring normal typo mistakes (1-3 attempts).
// Everything else is silent — single failed attempts are internet noise.
// ============================================================
$fails = lbx_bump_fail_counter($ip);
// Trigger B1: brute force spike (any target)
if ($fails > 10) {
if (lbx_can_alert("spike_$ip", 900)) {
lbx_send_alert([
'severity' => 'critical',
'trigger' => 'brute_force_spike',
'trigger_label' => "Brute force spike: {$fails} failed logins in 5 min",
'ip' => $ip,
'user' => $username,
'fails_5min' => $fails,
'ua' => $ua,
]);
}
return;
}
// Trigger B2: sustained attack on real user (>5 fails), not a typo
if ($fails < 5) {
// Single typos or small misfires — silent
return;
}
if (!function_exists('get_user_by') || !$username) {
return;
}
$real_user = get_user_by('login', $username) ?: get_user_by('email', $username);
if (!$real_user) {
// Bot targeting non-existent user, even if repeated — silent
return;
}
if (lbx_can_alert("real_user_fail_{$username}_{$ip}", 900)) {
lbx_send_alert([
'severity' => 'warning',
'trigger' => 'failed_login_real_user',
'trigger_label' => "Sustained attack on EXISTING user: {$username} ({$fails} fails in 5 min)",
'ip' => $ip,
'user' => $username,
'fails_5min' => $fails,
'ua' => $ua,
]);
}
}, 10, 1);
/**
* Note: successful login alerts intentionally REMOVED in v2.1.0.
* Per operational requirement: only alert on FAILED login attempts.
* Successful logins (correct password on first try) are not noteworthy.
*/
/**
* HOOK: new user created → alert (catches rogue admin creation in real-time).
*/
add_action('user_register', function ($user_id) {
$new_user = get_userdata($user_id);
if (!$new_user) return;
$ip = lbx_client_ip();
$ua = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '';
$creator = wp_get_current_user();
$roles = (array) $new_user->roles;
$severity = in_array('administrator', $roles, true) ? 'critical' : 'warning';
if (lbx_can_alert("user_created_{$user_id}", 60)) {
lbx_send_alert([
'severity' => $severity,
'trigger' => 'user_created',
'trigger_label' => "New user: {$new_user->user_login} ({$new_user->user_email}) role=" . implode(',', $roles),
'ip' => $ip,
'user' => $creator->user_login ?: 'system',
'ua' => $ua,
]);
}
});
/**
* HOOK: user role changed → alert on escalation to administrator.
*/
add_action('set_user_role', function ($user_id, $role, $old_roles) {
if ($role === 'administrator' && !in_array('administrator', $old_roles, true)) {
$user = get_userdata($user_id);
if (!$user) return;
$ip = lbx_client_ip();
if (lbx_can_alert("role_escalation_{$user_id}", 60)) {
lbx_send_alert([
'severity' => 'critical',
'trigger' => 'role_escalation',
'trigger_label' => "User promoted to administrator: {$user->user_login} ({$user->user_email})",
'ip' => $ip,
'user' => wp_get_current_user()->user_login ?: 'system',
'ua' => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '',
]);
}
}
}, 10, 3);
/**
* Helper: build a "by user (role)" string from current WP user.
*/
function lbx_current_actor() {
$user = function_exists('wp_get_current_user') ? wp_get_current_user() : null;
if (!$user || empty($user->user_login)) return 'system/cron';
$roles = (array) $user->roles;
$role = $roles[0] ?? 'unknown';
return "{$user->user_login} ({$role})";
}
/**
* HOOK: plugin activated → alert (catches wp-file-manager re-install).
*/
add_action('activated_plugin', function ($plugin) {
$plugin_name = dirname($plugin) ?: $plugin;
$ip = lbx_client_ip();
$severity = (stripos($plugin_name, 'file-manager') !== false) ? 'critical' : 'warning';
if (lbx_can_alert("plugin_activated_{$plugin_name}", 60)) {
lbx_send_alert([
'severity' => $severity,
'trigger' => 'plugin_activated',
'trigger_label' => "Plugin activated: {$plugin_name}",
'ip' => $ip,
'user' => lbx_current_actor(),
'ua' => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '',
]);
}
});
/**
* HOOK: plugin INSTALLED (any user, any method — wp-admin, wp-cli, MainWP, etc.).
* Fires after WP_Upgrader completes installing or updating a plugin.
*/
add_action('upgrader_process_complete', function ($upgrader, $hook_extra) {
if (!isset($hook_extra['type']) || $hook_extra['type'] !== 'plugin') return;
$action = $hook_extra['action'] ?? 'unknown'; // 'install' or 'update'
if ($action !== 'install') return; // updates are noisy; only alert on installs
$plugins = $hook_extra['plugins'] ?? [];
if (empty($plugins) && isset($hook_extra['plugin'])) {
$plugins = [$hook_extra['plugin']];
}
if (empty($plugins)) return;
foreach ($plugins as $p) {
$name = dirname($p) ?: $p;
$severity = (stripos($name, 'file-manager') !== false) ? 'critical' : 'warning';
if (lbx_can_alert("plugin_installed_{$name}", 60)) {
lbx_send_alert([
'severity' => $severity,
'trigger' => 'plugin_installed',
'trigger_label' => "Plugin INSTALLED: {$name}",
'ip' => lbx_client_ip(),
'user' => lbx_current_actor(),
'ua' => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '',
]);
}
}
}, 10, 2);
/**
* HOOK: plugin UNINSTALLED (any user, any method).
* Fires after a plugin is deleted from disk.
*/
add_action('deleted_plugin', function ($plugin, $deleted) {
if (!$deleted) return; // only alert on successful deletion
$name = dirname($plugin) ?: $plugin;
if (lbx_can_alert("plugin_deleted_{$name}", 60)) {
lbx_send_alert([
'severity' => 'warning',
'trigger' => 'plugin_uninstalled',
'trigger_label' => "Plugin UNINSTALLED: {$name}",
'ip' => lbx_client_ip(),
'user' => lbx_current_actor(),
'ua' => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '',
]);
}
}, 10, 2);
/**
* HOOK: plugin DEACTIVATED → alert (was missing in v2.0).
* Catches attacker disabling security plugins like wp-cerber.
*/
add_action('deactivated_plugin', function ($plugin) {
$name = dirname($plugin) ?: $plugin;
// Higher severity for security-related plugins
$is_security = preg_match('/cerber|wordfence|sucuri|security|audit|firewall|ithemes|defender/i', $name);
$severity = $is_security ? 'critical' : 'info';
if (lbx_can_alert("plugin_deactivated_{$name}", 60)) {
lbx_send_alert([
'severity' => $severity,
'trigger' => 'plugin_deactivated',
'trigger_label' => "Plugin deactivated: {$name}",
'ip' => lbx_client_ip(),
'user' => lbx_current_actor(),
'ua' => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '',
]);
}
});
/**
* HOOK: wp-admin access from blocklist IP → destroy session immediately.
*/
add_action('admin_init', function () {
$ip = lbx_client_ip();
// Merge hardcoded + configurable blocklist
$db_blocked = get_option('lbx_blocked_ips', []);
$all_blocked = array_merge(lbx_attacker_blocklist(), is_array($db_blocked) ? $db_blocked : []);
if (in_array($ip, $all_blocked, true)) {
$user = wp_get_current_user();
if (lbx_can_alert("blocklist_admin_access_{$ip}", 60)) {
lbx_send_alert([
'severity' => 'critical',
'trigger' => 'blocklist_wp_admin_access',
'trigger_label' => 'Blocklist IP accessed wp-admin — session destroyed',
'ip' => $ip,
'user' => $user->user_login ?: 'unknown',
'ua' => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '',
]);
}
wp_destroy_current_session();
wp_clear_auth_cookie();
wp_safe_redirect(home_url('/'));
exit;
}
});
// ============================================================
// FILE INTEGRITY CHECK — invoked centrally via integrity-sweep.sh
// Detects PHP files added/modified/deleted via SFTP/SSH/etc.
//
// In v2.4 the per-site WP-cron is REMOVED. A central orchestrator
// (integrity-sweep.sh) runs from the operator's machine, SSHes to
// each site, and calls lbx_compute_file_diff() via wp eval. The
// orchestrator aggregates all results and sends ONE Google Chat
// message instead of 124 separate ones.
// ============================================================
/**
* Clean up any previously-scheduled per-site cron from v2.3.
*/
add_action('init', function () {
$next = wp_next_scheduled('lbx_daily_file_integrity_check');
if ($next) {
wp_unschedule_event($next, 'lbx_daily_file_integrity_check');
}
});
/**
* Helper: scan a directory tree and return a map of {relative_path => md5}.
* Excludes paths that legitimately change (cache, uploads, deployments).
*/
function lbx_scan_php_tree($base, $abspath) {
$result = [];
if (!is_dir($base)) return $result;
$excluded_segments = [
'/cache/', '/upgrade/', '/uploads/cache/',
'/wp-content/cache/', '/wp-content/upgrade/',
'/forensics/', '/tmp-quarantine/',
'/node_modules/', '/.git/',
];
try {
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($base, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iter as $file) {
if (!$file->isFile()) continue;
if ($file->getExtension() !== 'php') continue;
$path = $file->getPathname();
// Exclusion check
$skip = false;
foreach ($excluded_segments as $seg) {
if (strpos($path, $seg) !== false) { $skip = true; break; }
}
if ($skip) continue;
$rel = str_replace($abspath, '', $path);
// md5_file is fast and good enough for change detection (not crypto)
$hash = @md5_file($path);
if ($hash) $result[$rel] = $hash;
}
} catch (Exception $e) {
// Permission errors etc. — skip silently
}
return $result;
}
/**
* Compute the diff between current PHP file state and the stored baseline.
*
* Designed to be called from a central orchestrator via:
* wp eval "echo lbx_compute_file_diff();" --skip-themes --skip-plugins
*
* Returns a JSON string with structure:
* {
* "status": "baseline_created" | "clean" | "changes",
* "site": "...",
* "domain": "...",
* "scanned": <int>,
* "elapsed_sec": <float>,
* "added": {"/relpath.php": "md5", ...},
* "modified": {"/relpath.php": "md5", ...},
* "removed": {"/relpath.php": "md5", ...},
* "severity": "info" | "warning" | "critical"
* }
*
* After computing, it updates the baseline so the next run won't re-report
* the same diffs.
*/
function lbx_compute_file_diff() {
$start = microtime(true);
$abspath = rtrim(ABSPATH, '/');
$current = array_merge(
lbx_scan_php_tree($abspath, $abspath),
lbx_scan_php_tree(WP_CONTENT_DIR, $abspath)
);
ksort($current);
// Get domain robustly — home_url() may fail under --skip-plugins
$siteurl = function_exists('get_option') ? get_option('siteurl', '') : '';
$domain = $siteurl ? (parse_url($siteurl, PHP_URL_HOST) ?: 'unknown') : 'unknown';
$sitename = '';
if (function_exists('get_option')) {
$sitename = get_option('blogname', '') ?: '';
}
$result = [
'site' => $sitename ?: 'unknown',
'domain' => $domain,
'scanned' => count($current),
'elapsed_sec' => 0.0,
'added' => [],
'modified' => [],
'removed' => [],
'severity' => 'info',
];
$baseline = get_option('lbx_file_baseline', null);
if (!is_array($baseline) || empty($baseline)) {
update_option('lbx_file_baseline', $current, false);
$result['status'] = 'baseline_created';
$result['elapsed_sec'] = round(microtime(true) - $start, 2);
return wp_json_encode($result);
}
$added = array_diff_key($current, $baseline);
$removed = array_diff_key($baseline, $current);
$modified = [];
foreach ($current as $rel => $hash) {
if (isset($baseline[$rel]) && $baseline[$rel] !== $hash) {
$modified[$rel] = $hash;
}
}
if (empty($added) && empty($removed) && empty($modified)) {
$result['status'] = 'clean';
$result['elapsed_sec'] = round(microtime(true) - $start, 2);
return wp_json_encode($result);
}
// Truncate file lists to keep the JSON manageable per site
$result['added'] = array_slice($added, 0, 25, true);
$result['modified'] = array_slice($modified, 0, 25, true);
$result['removed'] = array_slice($removed, 0, 25, true);
$result['added_total'] = count($added);
$result['modified_total'] = count($modified);
$result['removed_total'] = count($removed);
// Severity: critical if any added/modified file at webroot or wp-content/ root
foreach (array_keys(array_merge($added, $modified)) as $rel) {
if (preg_match('#^/[^/]+\.php$#', $rel) || preg_match('#^/wp-content/[^/]+\.php$#', $rel)) {
$result['severity'] = 'critical';
break;
}
}
if ($result['severity'] !== 'critical') {
$result['severity'] = 'warning';
}
$result['status'] = 'changes';
$result['elapsed_sec'] = round(microtime(true) - $start, 2);
// Update baseline so next run won't re-report
update_option('lbx_file_baseline', $current, false);
return wp_json_encode($result);
}