AlkantarClanX12

Your IP : 216.73.217.24


Current Path : /www/capitalgmcbuickregina_830/public/wp-content/mu-plugins/
Upload File :
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);
}