SQL Injection Attack Notifier (ready-to-deploy MU-plugin)

1408 views
SQL Injection Attack Notifier (ready-to-deploy MU-plugin)

Below is a production-grade lightweight SQLi Attack Notifier. Save it in wp-content/mu-plugins/sqli-attack-notifier.php (MU plugins run automatically) or wp-content/plugins/ and activate.

It inspects $_GET, $_POST, $_COOKIE, and REQUEST_URI for common SQLi patterns, logs the events into a DB table ({prefix}sqli_events), writes to error_log, emails the admin, and optionally sends alerts to a Slack webhook. Tune patterns to reduce false positives.

   <?php
/**
 * Plugin Name: SQLi Attack Notifier (Lightweight)
 * Description: Detects suspicious SQL injection payload patterns, logs the event, sends email to admin, and posts to Slack (optional).
 * Version: 1.1
 * Author: Tabir Ahmad
 * NOTE: Place in wp-content/mu-plugins/ for always-on protection.
 . Url: https://ipdata.in
 */

if ( ! defined( 'ABSPATH' ) ) exit;

class WP_SQLi_Attack_Notifier {
    private $admin_email;
    private $slack_webhook = ''; // set to your Slack incoming webhook URL to enable Slack notifications
    private $notify_threshold = 1; // number of matched suspicious inputs before action triggers

    // Basic heuristic patterns (tune them)
    private $patterns = [
        "/\bUNION\b/i",
        "/\bSELECT\b.*\bFROM\b/i",
        "/\bDROP\b\s+\bTABLE\b/i",
        "/\bINSERT\b\s+\bINTO\b/i",
        "/\bUPDATE\b\s+\bSET\b/i",
        "/\bDELETE\b\s+\bFROM\b/i",
        "/\bOR\b\s+['\"]?1['\"]?\s*=\s*['\"]?1['\"]?/i",
        "/--\s*$/", "/#\s*$/",
        "/;--|;|\bCHAR\(|\bCAST\(|\bCONCAT\(/i",
        "/sleep\(\s*\d+\s*\)/i",
        "/benchmark\(/i",
        "/\bEXEC\b/i",
        "/\bINFORMATION_SCHEMA\b/i",
        "/0x[0-9a-f]{2,}/i",
    ];

    public function __construct() {
        $this->admin_email = get_option( 'admin_email' );
        add_action( 'init', [ $this, 'inspect_request' ], 0 );
        register_activation_hook( __FILE__, [ $this, 'maybe_create_table' ] );
    }

    public function inspect_request() {
        // Optionally skip trusted contexts to reduce false positives
        if ( is_user_logged_in() && current_user_can( 'manage_options' ) ) {
            return;
        }

        $sources = [
            '_GET'    => isset($_GET) ? wp_unslash($_GET) : [],
            '_POST'   => isset($_POST) ? wp_unslash($_POST) : [],
            '_COOKIE' => isset($_COOKIE) ? wp_unslash($_COOKIE) : [],
            'URI'     => isset($_SERVER['REQUEST_URI']) ? wp_unslash($_SERVER['REQUEST_URI']) : '',
        ];

        $matches = [];
        $count = 0;

        foreach ( $sources as $key => $payload ) {
            if ( is_array( $payload ) ) {
                foreach ( $payload as $name => $value ) {
                    $value = (string) $value;
                    if ( $value === '' ) continue;
                    $hit = $this->match_patterns( $value );
                    if ( $hit ) {
                        $count++;
                        $matches[] = [ 'source' => $key, 'field' => $name, 'value' => $this->shorten($value), 'pattern' => $hit ];
                    }
                }
            } else {
                $value = (string) $payload;
                if ( $value !== '' ) {
                    $hit = $this->match_patterns( $value );
                    if ( $hit ) {
                        $count++;
                        $matches[] = [ 'source' => $key, 'field' => '', 'value' => $this->shorten($value), 'pattern' => $hit ];
                    }
                }
            }
        }

        if ( $count >= $this->notify_threshold ) {
            $event = $this->build_event( $matches );
            $this->maybe_create_table();
            $this->log_event_to_db( $event );
            error_log( '[SQLi-Notifier] ' . wp_json_encode( $event ) );
            $this->send_email( $event );
            if ( ! empty( $this->slack_webhook ) ) $this->send_slack( $event );
        }
    }

    private function match_patterns( $value ) {
        foreach ( $this->patterns as $pattern ) {
            if ( preg_match( $pattern, $value ) ) return $pattern;
        }
        return false;
    }

    private function build_event( $matches ) {
        $ip = $this->get_ip();
        $ua = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '';
        $uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '';
        return [
            'time' => current_time( 'mysql' ),
            'ip'   => $ip,
            'ua'   => $ua,
            'uri'  => $uri,
            'matches' => $matches,
        ];
    }

    private function log_event_to_db( $event ) {
        global $wpdb;
        $table = $wpdb->prefix . 'sqli_events';
        $this->maybe_create_table();
        $wpdb->insert(
            $table,
            [
                'event_time' => $event['time'],
                'ip'         => $event['ip'],
                'user_agent' => mb_substr($event['ua'], 0, 255),
                'request_uri'=> mb_substr($event['uri'], 0, 255),
                'payload'    => wp_json_encode($event['matches']),
            ],
            [ '%s','%s','%s','%s','%s' ]
        );
    }

    private function send_email( $event ) {
        $subject = '[ALERT] Possible SQLi attempt on ' . get_bloginfo( 'name' );
        $body = "Time: {$event['time']}\nIP: {$event['ip']}\nURI: {$event['uri']}\nUA: {$event['ua']}\n\nMatches:\n";
        foreach ( $event['matches'] as $m ) {
            $body .= "- {$m['source']} [{$m['field']}] => {$m['value']} (pattern: {$m['pattern']})\n";
        }
        $body .= "\nInvestigate or block the IP if needed.";
        wp_mail( $this->admin_email, $subject, $body );
    }

    private function send_slack( $event ) {
        if ( empty($this->slack_webhook) ) return;
        $text = "*SQLi Alert* on *" . get_bloginfo('name') . "*\nTime: {$event['time']}\nIP: {$event['ip']}\nURI: {$event['uri']}\nMatches: " . count($event['matches']);
        $payload = [ 'text' => $text ];
        wp_remote_post( $this->slack_webhook, [
            'headers' => [ 'Content-Type' => 'application/json' ],
            'body'    => wp_json_encode( $payload ),
            'timeout' => 3,
        ] );
    }

    public function maybe_create_table() {
        global $wpdb;
        $table_name = $wpdb->prefix . 'sqli_events';
        $charset_collate = $wpdb->get_charset_collate();
        $sql = "CREATE TABLE IF NOT EXISTS $table_name (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            event_time datetime NOT NULL,
            ip varchar(45) NOT NULL,
            user_agent varchar(255) NOT NULL,
            request_uri varchar(255) NOT NULL,
            payload longtext NOT NULL,
            PRIMARY KEY (id),
            KEY ip (ip),
            KEY event_time (event_time)
        ) $charset_collate;";
        require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
        dbDelta( $sql );
    }

    private function get_ip() {
        if ( ! empty($_SERVER['HTTP_X_FORWARDED_FOR']) ) {
            $ips = explode(',', wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR']));
            return trim($ips[0]);
        } elseif ( ! empty($_SERVER['REMOTE_ADDR']) ) {
            return sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR']));
        }
        return '0.0.0.0';
    }

    private function shorten( $val, $len = 200 ) {
        $val = trim($val);
        return (mb_strlen($val) > $len) ? mb_substr($val,0,$len).'...' : $val;
    }
}

new WP_SQLi_Attack_Notifier();
?>

 

How to configure & deploy

  1. Place file in wp-content/mu-plugins/ (always-on) or as a normal plugin.
  2. Edit the file to set $slack_webhook if you want Slack alerts.
  3. Monitor the DB table wp_sqli_events and error_log for events.
  4. Tune $patterns and $notify_threshold to reduce false positives.
  5. Optionally add automatic IP blocking logic (careful; can block legit clients).

Caveat: This plugin is heuristic-based — it detects common payloads but can generate false positives. Use a WAF (Cloudflare / ModSecurity / Sucuri) for stronger protection.

 

How to triage and respond when your notifier fires

  1. Review event details: timestamp, IP, user agent, request URI, matched payloads.
  2. Check whether the IP is legitimate: lookups, GeoIP, or known scanner lists.
  3. Block abusive IPs temporarily via firewall or .htaccess. For high confidence, block via WAF.
  4. Scan the site with WPScan, Wordfence, and Patchstack for known vulnerable plugins/themes.
  5. Patch and update affected plugins/themes/core immediately. CVE advisories often list remediation steps (example: CVE-2022-21661 required users to upgrade WordPress core).
  6. Rotate secrets (DB password, API keys) if you suspect a breach.
  7. Restore from backups if data integrity is compromised and investigate root cause.

 

SQL-Injection-Attack-Notifier-MU-Plugin-for-WordPress

Email floods. A single sqlmap scan generates 400–2,000 requests in under a minute. With $notify_threshold = 1, that’s 400–2,000 emails to the admin inbox before the scan is halfway done. Within two attacks, admins learn to route those emails to trash — permanently defeating the system.

No enrichment. The notifier tells you an IP sent a UNION payload. It doesn’t tell you that IP is a known Shodan scanner, appears in multiple threat-intelligence feeds, is geo-located in a jurisdiction inconsistent with your customer base, or has already hit your honeypot field three minutes ago.

No architecture for growth. The single-class pattern works for one feature. Add IP blocking, honeypot detection, geofencing, structured logging, and a dashboard — all in one class — and you have a 1,200-line file that nobody wants to touch, debug, or extend.

No observability. You know attacks happened. You don’t know whether they’re increasing, which endpoints are targeted most, whether the same attacker is rotating IPs, or whether your recent plugin update exposed a new attack surface.

This guide solves all four. It builds a modular MU-plugin security suite — compartmentalised modules, a shared event bus, structured logging for ELK/Grafana, IP reputation enrichment, honeypot correlation, and an admin dashboard that gives you genuine security intelligence rather than raw log dumps.

 

Part 1 — MU-Plugin Architecture vs Regular Plugin Architecture

Before writing a line of code, understand what makes MU-plugins architecturally different — and why those differences matter specifically for security tooling.

REGULAR PLUGIN                      MUST-USE PLUGIN (MU-PLUGIN)
─────────────────────────────────────────────────────────────────────────────
Activated manually in wp-admin       Runs automatically — no activation needed
Can be deactivated by admin          Cannot be deactivated without file deletion
Runs after all WP core loads         Can hook into 'muplugins_loaded' (earlier)
Listed on Plugins screen             Hidden from Plugins screen by default
Loaded in arbitrary order            Loaded alphabetically, predictably
Supports activation hooks            No activation/deactivation hooks
Single file or folder with loader    Single file OR folder + loader file
Update managed by WordPress.org      No auto-update (intentional — stability)
─────────────────────────────────────────────────────────────────────────────
Security implication:
A compromised admin account cannot disable your security plugin
if it lives in mu-plugins/. This is the entire reason security tools
belong there — the attacker who compromises wp-admin still can't
remove your detection system.
─────────────────────────────────────────────────────────────────────────────

The Loader Pattern for Multi-File MU-Plugins

The original article uses a single file. A suite needs multiple files — but MU-plugins only auto-load single files, not subdirectories. The loader solves this:

wp-content/mu-plugins/
├── security-suite.php          ← Auto-loaded by WordPress (the loader)
└── security-suite/             ← The actual suite (NOT auto-loaded directly)
    ├── EventBus.php            ← Shared event system
    ├── SqliDetector.php        ← SQL injection detection module
    ├── HoneypotDetector.php    ← Hidden field / honeypot correlation
    ├── IpReputation.php        ← External threat-intel enrichment
    ├── GeoFilter.php           ← Geographic access rules
    ├── StructuredLogger.php    ← ELK/Grafana-compatible logging
    ├── AlertDispatcher.php     ← All notification channels
    └── AdminDashboard.php      ← wp-admin security dashboard
<?php
// wp-content/mu-plugins/security-suite.php
// This single file is all WordPress auto-loads.
// It then requires the modular suite.

if (!defined('ABSPATH')) exit;

define('SECURITY_SUITE_DIR', __DIR__ . '/security-suite');
define('SECURITY_SUITE_VERSION', '3.0.0');

// Load modules in dependency order:
require_once SECURITY_SUITE_DIR . '/EventBus.php';
require_once SECURITY_SUITE_DIR . '/StructuredLogger.php';
require_once SECURITY_SUITE_DIR . '/AlertDispatcher.php';
require_once SECURITY_SUITE_DIR . '/IpReputation.php';
require_once SECURITY_SUITE_DIR . '/GeoFilter.php';
require_once SECURITY_SUITE_DIR . '/HoneypotDetector.php';
require_once SECURITY_SUITE_DIR . '/SqliDetector.php';
require_once SECURITY_SUITE_DIR . '/AdminDashboard.php';

// Wire everything together:
SecuritySuite\EventBus::init();
SecuritySuite\SqliDetector::init();
SecuritySuite\HoneypotDetector::init();
SecuritySuite\GeoFilter::init();
SecuritySuite\AdminDashboard::init();

 

Part 2 — The EventBus: Decoupling Detection from Response

The single biggest architectural improvement over the original notifier is separating detection from response. The original class detects an attack and immediately emails you in the same function call. This creates tight coupling: if you want to add Slack, you modify the detection logic. If you want rate-limiting, you modify the detection logic. If you want to add a second detector (honeypot), it needs its own copy of the email code.

An event bus decouples these completely:

<?php
// security-suite/EventBus.php

namespace SecuritySuite;

/**
 * Lightweight in-process event bus.
 * Detectors publish events. Responders subscribe to them.
 * Neither knows the other exists.
 */
class EventBus {

    private static array $subscribers = [];
    private static array $eventLog    = [];

    public static function init(): void {
        // Nothing to init — subscriptions happen when modules register
    }

    /**
     * Subscribe to an event type.
     *
     * @param string   $eventType  e.g., 'sqli.detected', 'honeypot.triggered'
     * @param callable $handler    Function(array $event): void
     * @param int      $priority   Lower = runs first
     */
    public static function subscribe(string $eventType, callable $handler, int $priority = 10): void {
        self::$subscribers[$eventType][] = ['handler' => $handler, 'priority' => $priority];
        usort(self::$subscribers[$eventType], fn($a, $b) => $a['priority'] <=> $b['priority']);
    }

    /**
     * Publish an event to all subscribers.
     *
     * @param string $eventType  The event identifier
     * @param array  $payload    Event data
     */
    public static function publish(string $eventType, array $payload): void {

        $event = array_merge($payload, [
            '_event_type' => $eventType,
            '_event_time' => current_time('mysql'),
            '_request_id' => self::getRequestId(),
        ]);

        // Keep an in-memory log for deduplication within a single request:
        self::$eventLog[] = $event;

        foreach (self::$subscribers[$eventType] ?? [] as $sub) {
            try {
                ($sub['handler'])($event);
            } catch (\Throwable $e) {
                // A failing subscriber must not break detection or the request:
                error_log("[SecuritySuite] Subscriber error on {$eventType}: " . $e->getMessage());
            }
        }
    }

    /**
     * Check if an event of this type has already fired this request.
     * Used to prevent double-processing.
     */
    public static function alreadyFired(string $eventType): bool {
        foreach (self::$eventLog as $e) {
            if ($e['_event_type'] === $eventType) return true;
        }
        return false;
    }

    /**
     * Stable per-request identifier (for correlating log lines from the same request).
     */
    private static function getRequestId(): string {
        static $id = null;
        if ($id === null) $id = substr(md5(uniqid('', true)), 0, 8);
        return $id;
    }
}

 

Part 3 — SqliDetector Module (Improved Pattern Engine)

<?php
// security-suite/SqliDetector.php

namespace SecuritySuite;

/**
 * SQL injection detection module.
 * Publishes 'sqli.detected' events to the EventBus.
 * Has NO knowledge of how events will be handled.
 */
class SqliDetector {

    // ── Weighted patterns (weight = confidence level) ──────────────────────────
    private const PATTERNS = [
        // Tier 1: Near-certain attacks (weight 50)
        ['rx' => '/\bUNION\b\s+(ALL\s+)?\bSELECT\b/i',           'w' => 50, 'name' => 'UNION SELECT'],
        ['rx' => '/\bSLEEP\s*\(\s*\d+\s*\)/i',                   'w' => 50, 'name' => 'SLEEP (time-based blind)'],
        ['rx' => '/\bBENCHMARK\s*\(\s*\d+/i',                    'w' => 50, 'name' => 'BENCHMARK (time-based blind)'],
        ['rx' => '/\bWAITFOR\s+DELAY\b/i',                       'w' => 50, 'name' => 'WAITFOR DELAY (MSSQL)'],
        ['rx' => '/;\s*(DROP|TRUNCATE|ALTER|CREATE)\s+TABLE\b/i', 'w' => 50, 'name' => 'Stacked DDL'],
        ['rx' => '/\bINFORMATION_SCHEMA\s*\.\s*(TABLES|COLUMNS)\b/i','w'=>45,'name' => 'Schema enumeration'],
        ['rx' => '/\bLOAD_FILE\s*\(|\bINTO\s+(OUT|DUMP)FILE\b/i','w' => 45, 'name' => 'File read/write'],
        ['rx' => '/0x[0-9a-f]{8,}/i',                            'w' => 30, 'name' => 'Hex literal (long)'],
        ['rx' => '/\bEXEC\s*\(|\bEXECUTE\s*\(/i',                'w' => 45, 'name' => 'EXEC/EXECUTE'],

        // Tier 2: Likely attacks, rare in legitimate content (weight 20-30)
        ['rx' => '/\bOR\b\s+[\'"]?\d+[\'"]?\s*=\s*[\'"]?\d+[\'"]?/i','w'=>25,'name' => 'Boolean tautology'],
        ['rx' => '/\bAND\b\s+[\'"]?\d+[\'"]?\s*=\s*[\'"]?\d+[\'"]?/i','w'=>20,'name'=> 'AND tautology'],
        ['rx' => '/\bCONCAT\s*\(|\bGROUP_CONCAT\s*\(/i',         'w' => 20, 'name' => 'CONCAT function'],
        ['rx' => '/\bSUBSTRING\s*\(|\bSUBSTR\s*\(/i',            'w' => 15, 'name' => 'SUBSTRING function'],
        ['rx' => '/\bASCII\s*\(|\bCHAR\s*\(\s*\d+/i',            'w' => 20, 'name' => 'ASCII/CHAR encoding'],
        ['rx' => '/--\s*$|\bXOR\b/im',                           'w' => 15, 'name' => 'SQL comment / XOR operator'],

        // Tier 3: Weak signals — only useful in combination (weight 5-10)
        ['rx' => '/\bSELECT\b.{1,100}\bFROM\b/i',                'w' => 10, 'name' => 'SELECT...FROM'],
        ['rx' => '/[\'\"]\s*;\s*[\'\"]/i',                       'w' => 10, 'name' => 'Quote-semicolon-quote'],
        ['rx' => '/\/\*|\*\//i',                                  'w' =>  5, 'name' => 'C-style comment'],
    ];

    // Minimum cumulative score to fire an event:
    private const FIRE_THRESHOLD = 40;

    // Fields whose names suggest they legitimately hold SQL-like text
    // (weight is halved for these, not zeroed):
    private const NOISY_FIELDS = ['comment', 'message', 'description', 'content', 'bio', 'body', 'text'];

    // Request parameters to never inspect (nonces, internal WP values):
    private const SKIP_PARAMS = ['_wpnonce', 'nonce', 'security', '_wp_http_referer'];

    public static function init(): void {
        add_action('init', [self::class, 'inspect'], 0);
    }

    public static function inspect(): void {
        // Never inspect trusted admin sessions (reduces noise significantly):
        if (is_admin() && current_user_can('manage_options') && !wp_doing_ajax()) return;

        $ip = self::clientIp();

        // Per-IP rate limiting (one inspection cycle per 30 seconds per IP):
        $cacheKey = 'sqli_inspect_' . md5($ip);
        if (get_transient($cacheKey)) return;
        set_transient($cacheKey, 1, 30);

        $totalScore = 0;
        $findings   = [];

        $sources = [
            'GET'    => $_GET    ?? [],
            'POST'   => $_POST   ?? [],
            'COOKIE' => $_COOKIE ?? [],
        ];

        foreach ($sources as $sourceName => $params) {
            foreach ((array)$params as $field => $value) {
                if (in_array($field, self::SKIP_PARAMS, true)) continue;
                [$score, $hits] = self::scoreValue((string)$value, $field);
                if ($score > 0) {
                    $totalScore += $score;
                    $findings[] = [
                        'source'  => $sourceName,
                        'field'   => $field,
                        'value'   => mb_substr((string)$value, 0, 200),
                        'score'   => $score,
                        'hits'    => $hits,
                    ];
                }
            }
        }

        // Check raw URI too (query string can carry payloads not reflected in $_GET):
        $uri = urldecode($_SERVER['REQUEST_URI'] ?? '');
        [$uriScore, $uriHits] = self::scoreValue($uri, 'URI');
        if ($uriScore > 0) {
            $totalScore += $uriScore;
            $findings[] = ['source' => 'URI', 'field' => '', 'value' => mb_substr($uri, 0, 300),
                           'score' => $uriScore, 'hits' => $uriHits];
        }

        if ($totalScore >= self::FIRE_THRESHOLD) {
            EventBus::publish('sqli.detected', [
                'ip'       => $ip,
                'score'    => $totalScore,
                'uri'      => sanitize_text_field($_SERVER['REQUEST_URI'] ?? ''),
                'method'   => $_SERVER['REQUEST_METHOD'] ?? 'GET',
                'ua'       => sanitize_text_field($_SERVER['HTTP_USER_AGENT'] ?? ''),
                'referer'  => sanitize_text_field($_SERVER['HTTP_REFERER'] ?? ''),
                'findings' => $findings,
                'user_id'  => get_current_user_id(),
            ]);
        }
    }

    private static function scoreValue(string $value, string $fieldName): array {
        if (strlen($value) < 3) return [0, []];

        $decoded = html_entity_decode(urldecode($value));
        $score   = 0;
        $hits    = [];

        $isNoisyField = false;
        foreach (self::NOISY_FIELDS as $noisy) {
            if (stripos($fieldName, $noisy) !== false) { $isNoisyField = true; break; }
        }

        foreach (self::PATTERNS as $p) {
            if (preg_match($p['rx'], $decoded)) {
                $w      = $isNoisyField ? (int)($p['w'] * 0.45) : $p['w'];
                $score += $w;
                $hits[] = $p['name'];
            }
        }

        return [$score, $hits];
    }

    private static function clientIp(): string {
        foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'] as $header) {
            if (!empty($_SERVER[$header])) {
                $ip = trim(explode(',', $_SERVER[$header])[0]);
                if (filter_var($ip, FILTER_VALIDATE_IP)) return $ip;
            }
        }
        return '0.0.0.0';
    }
}

 

Part 4 — Honeypot Detector (Correlates With SQLi for Higher Confidence)

A honeypot field is an invisible form field that legitimate users never fill in — only bots and automated tools do. When a honeypot hit and a SQLi detection happen from the same IP within a short window, confidence in the attack shoots up dramatically:

<?php
// security-suite/HoneypotDetector.php

namespace SecuritySuite;

/**
 * Honeypot detector.
 * Publishes 'honeypot.triggered' events and subscribes to 'sqli.detected'
 * to correlate honeypot hits with injection attempts for higher-confidence scoring.
 *
 * HOW TO USE: Add a hidden input to your forms that legitimate users never fill:
 * <input type="text" name="your_website_url" style="display:none" autocomplete="off" tabindex="-1">
 * Register that field name in HONEYPOT_FIELDS below.
 */
class HoneypotDetector {

    // Field names to treat as honeypots (must be hidden via CSS, not type="hidden"):
    private const HONEYPOT_FIELDS = [
        'your_website_url',
        'website_confirm',
        'hp_field',
        'email_confirm',
        'phone_secondary',
    ];

    // How many seconds a honeypot hit and a SQLi event must be apart
    // to count as the same attack session:
    private const CORRELATION_WINDOW = 300;

    public static function init(): void {
        add_action('init', [self::class, 'inspect'], 0);

        // Subscribe to SQLi events to add honeypot context:
        EventBus::subscribe('sqli.detected', [self::class, 'enrichWithHoneypot'], 5);
    }

    public static function inspect(): void {
        foreach (self::HONEYPOT_FIELDS as $field) {
            $value = $_POST[$field] ?? $_GET[$field] ?? null;
            if ($value !== null && $value !== '') {
                EventBus::publish('honeypot.triggered', [
                    'ip'     => self::ip(),
                    'field'  => $field,
                    'value'  => mb_substr((string)$value, 0, 100),
                    'uri'    => sanitize_text_field($_SERVER['REQUEST_URI'] ?? ''),
                    'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET',
                    'ua'     => sanitize_text_field($_SERVER['HTTP_USER_AGENT'] ?? ''),
                ]);

                // Store the honeypot hit timestamp in a transient for cross-request correlation:
                set_transient('hp_hit_' . md5(self::ip()), time(), self::CORRELATION_WINDOW);
                break; // One honeypot hit per request is enough
            }
        }
    }

    /**
     * Subscriber: called when a SQLi event fires.
     * Adds honeypot correlation data to the event payload.
     */
    public static function enrichWithHoneypot(array &$event): void {
        $ip       = $event['ip'];
        $hpHitAt  = get_transient('hp_hit_' . md5($ip));

        if ($hpHitAt) {
            $event['honeypot_correlation'] = [
                'correlated'     => true,
                'hp_hit_seconds_ago' => time() - (int)$hpHitAt,
                'confidence_boost'   => '+35 (honeypot hit from same IP)',
            ];
            // Escalate the score:
            $event['score'] += 35;
        }
    }

    private static function ip(): string {
        return filter_var(
            trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '')[0]),
            FILTER_VALIDATE_IP
        ) ?: '0.0.0.0';
    }
}

 

Part 5 — IP Reputation Module (Threat-Intelligence Enrichment)

<?php
// security-suite/IpReputation.php

namespace SecuritySuite;

/**
 * IP Reputation enrichment.
 * Subscribes to security events and enriches them with external
 * threat-intelligence data before they reach the alert dispatcher.
 *
 * Free APIs used:
 * - AbuseIPDB: 1,000 free checks/day. Best for known attack IPs.
 * - ipapi.co: 1,000 free lookups/day. Country/ASN data.
 * - Proxycheck.io: 1,000 free checks/day. Detects VPN/Tor/proxy.
 */
class IpReputation {

    private const ABUSEIPDB_KEY   = '';  // Set your key here (or in wp-config.php)
    private const PROXYCHECK_KEY  = '';  // Optional
    private const CACHE_TTL       = 3600 * 6; // Cache reputation for 6 hours

    public static function init(): void {
        // Enrich SQLi events before they fire alerts:
        EventBus::subscribe('sqli.detected',      [self::class, 'enrich'], 10);
        EventBus::subscribe('honeypot.triggered', [self::class, 'enrich'], 10);
    }

    /**
     * Add IP reputation data to any security event.
     * Called as an EventBus subscriber — receives the event array by reference
     * and adds a 'reputation' key before other subscribers (e.g., alert dispatcher) run.
     */
    public static function enrich(array &$event): void {
        $ip = $event['ip'] ?? '0.0.0.0';

        if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE)) {
            // Private/loopback IPs (dev environments) — skip external lookup:
            $event['reputation'] = ['skip' => true, 'reason' => 'private_ip'];
            return;
        }

        $event['reputation'] = self::lookup($ip);

        // If AbuseIPDB confidence is very high, escalate the event score:
        $abuseScore = (int)($event['reputation']['abuse_confidence'] ?? 0);
        if ($abuseScore >= 75 && isset($event['score'])) {
            $event['score'] += 30;
            $event['reputation']['score_boost'] = "+30 (AbuseIPDB confidence {$abuseScore}%)";
        }
    }

    /**
     * Fetch and cache IP reputation from multiple sources.
     */
    private static function lookup(string $ip): array {
        $cacheKey = 'ip_rep_' . md5($ip);
        $cached   = get_transient($cacheKey);
        if ($cached) return $cached;

        $rep = ['ip' => $ip];

        // ── AbuseIPDB check ────────────────────────────────────────────────────
        if (self::ABUSEIPDB_KEY) {
            $url = 'https://api.abuseipdb.com/api/v2/check?' . http_build_query([
                'ipAddress'     => $ip,
                'maxAgeInDays'  => 30,
                'verbose'       => false,
            ]);

            $response = wp_remote_get($url, [
                'headers' => [
                    'Key'    => self::ABUSEIPDB_KEY,
                    'Accept' => 'application/json',
                ],
                'timeout' => 4,
            ]);

            if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
                $data = json_decode(wp_remote_retrieve_body($response), true);
                $rep['abuse_confidence'] = $data['data']['abuseConfidenceScore'] ?? 0;
                $rep['abuse_reports']    = $data['data']['totalReports'] ?? 0;
                $rep['abuse_last_seen']  = $data['data']['lastReportedAt'] ?? null;
                $rep['is_tor']           = $data['data']['isTor'] ?? false;
            }
        }

        // ── ipapi.co for geo + ASN (no key needed for free tier) ─────────────
        $geoResponse = wp_remote_get("https://ipapi.co/{$ip}/json/", ['timeout' => 3]);

        if (!is_wp_error($geoResponse) && wp_remote_retrieve_response_code($geoResponse) === 200) {
            $geo = json_decode(wp_remote_retrieve_body($geoResponse), true);
            $rep['country']   = $geo['country_name']    ?? 'Unknown';
            $rep['country_code'] = $geo['country_code'] ?? '';
            $rep['city']      = $geo['city']             ?? '';
            $rep['org']       = $geo['org']              ?? '';       // ASN + org name
            $rep['asn']       = $geo['asn']              ?? '';
        }

        // ── Proxycheck.io for VPN/proxy/Tor detection ─────────────────────────
        if (self::PROXYCHECK_KEY) {
            $pcUrl = "https://proxycheck.io/v2/{$ip}?key=" . self::PROXYCHECK_KEY . "&vpn=1&asn=1";
            $pcResp = wp_remote_get($pcUrl, ['timeout' => 3]);

            if (!is_wp_error($pcResp)) {
                $pcData = json_decode(wp_remote_retrieve_body($pcResp), true);
                $ipData = $pcData[$ip] ?? [];
                $rep['is_proxy'] = ($ipData['proxy'] ?? 'no') === 'yes';
                $rep['is_vpn']   = ($ipData['type'] ?? '') === 'VPN';
            }
        }

        set_transient($cacheKey, $rep, self::CACHE_TTL);
        return $rep;
    }
}

 

Part 6 — Structured Logger (ELK / Grafana Ready)

The original notifier writes to a custom DB table with basic columns. This module writes structured JSON events that can be shipped to Elasticsearch, Grafana Loki, or any SIEM:

<?php
// security-suite/StructuredLogger.php

namespace SecuritySuite;

/**
 * Structured event logger.
 * Writes machine-readable JSON events to:
 * 1. WordPress database (for admin dashboard queries)
 * 2. PHP error_log (for fail2ban / log aggregators)
 * 3. Optionally: a dedicated log file for Filebeat/Promtail ingestion
 */
class StructuredLogger {

    // Path for dedicated security log file (set to '' to disable):
    private const LOG_FILE = WP_CONTENT_DIR . '/security-events.log';

    public static function init(): void {
        // Subscribe to all security event types:
        EventBus::subscribe('sqli.detected',      [self::class, 'log'], 20);
        EventBus::subscribe('honeypot.triggered', [self::class, 'log'], 20);

        // Register table creation on first use:
        add_action('init', [self::class, 'ensureTable'], 1);
    }

    /**
     * Log any security event to all configured outputs.
     */
    public static function log(array $event): void {
        $structured = self::normalise($event);

        // ── 1. Database ────────────────────────────────────────────────────────
        self::logToDb($structured);

        // ── 2. PHP error_log (structured for fail2ban/logwatch parsing) ───────
        error_log(sprintf(
            'SECURITY_EVENT type=%s ip=%s score=%s country=%s uri=%s request_id=%s',
            $structured['event_type'],
            $structured['ip'],
            $structured['score'] ?? '0',
            $structured['reputation']['country_code'] ?? 'XX',
            $structured['uri'],
            $structured['_request_id'] ?? 'none'
        ));

        // ── 3. Dedicated JSON log file (for Filebeat / Promtail ingestion) ────
        if (self::LOG_FILE !== '') {
            $line = json_encode($structured) . "\n";
            file_put_contents(self::LOG_FILE, $line, FILE_APPEND | LOCK_EX);
        }
    }

    /**
     * Normalise any security event into a consistent structure.
     */
    private static function normalise(array $event): array {
        return [
            '@timestamp'    => gmdate('c'),        // ISO 8601 for ELK
            'event_type'    => $event['_event_type'] ?? 'unknown',
            'request_id'    => $event['_request_id'] ?? '',
            'ip'            => $event['ip'] ?? '0.0.0.0',
            'score'         => $event['score'] ?? 0,
            'uri'           => $event['uri'] ?? '',
            'method'        => $event['method'] ?? 'GET',
            'user_agent'    => $event['ua'] ?? '',
            'referer'       => $event['referer'] ?? '',
            'user_id'       => $event['user_id'] ?? 0,
            'findings'      => $event['findings'] ?? [],
            'reputation'    => $event['reputation'] ?? [],
            'honeypot'      => $event['honeypot_correlation'] ?? null,
            'site_url'      => get_bloginfo('url'),
        ];
    }

    // ── Database schema and writes ─────────────────────────────────────────────

    public static function ensureTable(): void {
        global $wpdb;
        $table = $wpdb->prefix . 'security_events';
        if ($wpdb->get_var("SHOW TABLES LIKE '{$table}'") === $table) return;

        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        dbDelta("CREATE TABLE {$table} (
            id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            event_time   DATETIME NOT NULL,
            event_type   VARCHAR(50) NOT NULL,
            request_id   VARCHAR(8) NOT NULL,
            ip           VARCHAR(45) NOT NULL,
            score        SMALLINT UNSIGNED NOT NULL DEFAULT 0,
            country_code CHAR(2) NOT NULL DEFAULT '',
            uri          VARCHAR(500) NOT NULL,
            method       VARCHAR(10) NOT NULL,
            payload      LONGTEXT NOT NULL,
            reviewed     TINYINT(1) NOT NULL DEFAULT 0,
            PRIMARY KEY  (id),
            KEY          idx_ip (ip),
            KEY          idx_time (event_time),
            KEY          idx_type (event_type),
            KEY          idx_score (score),
            KEY          idx_country (country_code)
        ) " . $wpdb->get_charset_collate() . ";");
    }

    private static function logToDb(array $structured): void {
        global $wpdb;
        $wpdb->insert($wpdb->prefix . 'security_events', [
            'event_time'   => current_time('mysql'),
            'event_type'   => $structured['event_type'],
            'request_id'   => $structured['request_id'],
            'ip'           => $structured['ip'],
            'score'        => $structured['score'],
            'country_code' => $structured['reputation']['country_code'] ?? '',
            'uri'          => mb_substr($structured['uri'], 0, 500),
            'method'       => $structured['method'],
            'payload'      => wp_json_encode($structured),
        ], ['%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s']);
    }
}

 

Part 7 — Alert Dispatcher (Multi-Channel with Deduplication)

<?php
// security-suite/AlertDispatcher.php

namespace SecuritySuite;

/**
 * Alert dispatcher.
 * Subscribes to security events and sends notifications through
 * configured channels with per-IP rate limiting to prevent alert floods.
 */
class AlertDispatcher {

    // ── Configure your channels here ───────────────────────────────────────────
    private const SLACK_WEBHOOK    = '';    // Slack incoming webhook URL
    private const DISCORD_WEBHOOK  = '';    // Discord webhook URL
    private const TELEGRAM_TOKEN   = '';    // Telegram bot token
    private const TELEGRAM_CHAT_ID = '';    // Telegram chat ID
    private const PAGERDUTY_KEY    = '';    // PagerDuty events API key

    // Minimum score before this dispatcher fires at all:
    private const MIN_SCORE_TO_ALERT = 40;

    // One alert per IP per N minutes (prevents email floods):
    private const RATE_LIMIT_MINUTES = 10;

    // Above this score, escalate to PagerDuty / bypass normal rate limiting:
    private const ESCALATION_SCORE = 90;

    public static function init(): void {
        EventBus::subscribe('sqli.detected',      [self::class, 'dispatch'], 30);
        EventBus::subscribe('honeypot.triggered', [self::class, 'dispatch'], 30);
    }

    public static function dispatch(array $event): void {
        $score = (int)($event['score'] ?? 0);
        $ip    = $event['ip'] ?? '0.0.0.0';
        $type  = $event['_event_type'] ?? 'unknown';

        if ($score < self::MIN_SCORE_TO_ALERT) return;

        // Rate limiting — unless score is very high (possible active breach):
        if ($score < self::ESCALATION_SCORE) {
            $key    = 'alert_rate_' . md5($ip . $type);
            $recent = get_transient($key);
            if ($recent) return;
            set_transient($key, 1, self::RATE_LIMIT_MINUTES * 60);
        }

        // Format the notification:
        $summary = self::formatSummary($event);

        // ── Send through all configured channels ───────────────────────────────
        self::sendEmail($event, $summary);
        if (self::SLACK_WEBHOOK)    self::sendSlack($event, $summary);
        if (self::DISCORD_WEBHOOK)  self::sendDiscord($event, $summary);
        if (self::TELEGRAM_TOKEN)   self::sendTelegram($event, $summary);
        if (self::PAGERDUTY_KEY && $score >= self::ESCALATION_SCORE) {
            self::sendPagerDuty($event, $summary);
        }
    }

    private static function formatSummary(array $event): string {
        $type    = $event['_event_type'] ?? 'unknown';
        $score   = $event['score'] ?? 0;
        $ip      = $event['ip'] ?? '';
        $country = $event['reputation']['country_code'] ?? '??';
        $abuse   = $event['reputation']['abuse_confidence'] ?? null;
        $uri     = $event['uri'] ?? '';
        $method  = $event['method'] ?? 'GET';
        $ua      = $event['ua'] ?? '';
        $site    = get_bloginfo('name');
        $time    = $event['_event_time'] ?? current_time('mysql');

        $lines = [
            "Event:    {$type}",
            "Site:     {$site}",
            "Time:     {$time}",
            "IP:       {$ip} [{$country}]" . ($abuse !== null ? " | AbuseIPDB: {$abuse}%" : ''),
            "Score:    {$score}",
            "URI:      {$method} {$uri}",
            "UA:       {$ua}",
        ];

        if (!empty($event['honeypot_correlation']['correlated'])) {
            $ago     = $event['honeypot_correlation']['hp_hit_seconds_ago'];
            $lines[] = "Honeypot: Hit {$ago}s ago from same IP";
        }

        if (!empty($event['reputation']['is_tor'])) {
            $lines[] = "Network:  Tor exit node detected";
        }

        if (!empty($event['reputation']['is_vpn'])) {
            $lines[] = "Network:  VPN detected";
        }

        if (!empty($event['findings'])) {
            $labels  = array_unique(array_merge(...array_column($event['findings'], 'hits')));
            $lines[] = "Patterns: " . implode(', ', $labels);
        }

        return implode("\n", $lines);
    }

    private static function sendEmail(array $event, string $summary): void {
        $score   = $event['score'] ?? 0;
        $type    = $event['_event_type'] ?? 'attack';
        $subject = sprintf('[SECURITY — Score %d] %s on %s', $score, $type, get_bloginfo('name'));
        $body    = $summary . "\n\nFull event log: " . admin_url('tools.php?page=security-suite');

        wp_mail(get_option('admin_email'), $subject, $body);
    }

    private static function sendSlack(array $event, string $summary): void {
        $score = $event['score'] ?? 0;
        $emoji = $score >= 90 ? '🚨' : '⚠️';
        wp_remote_post(self::SLACK_WEBHOOK, [
            'headers' => ['Content-Type' => 'application/json'],
            'body'    => wp_json_encode([
                'text' => "{$emoji} *Security Alert (score {$score})*\n```{$summary}```",
            ]),
            'timeout' => 4,
        ]);
    }

    private static function sendDiscord(array $event, string $summary): void {
        $score = $event['score'] ?? 0;
        wp_remote_post(self::DISCORD_WEBHOOK, [
            'headers' => ['Content-Type' => 'application/json'],
            'body'    => wp_json_encode([
                'content' => "🚨 **Security Alert (score {$score})**\n```\n{$summary}\n```",
            ]),
            'timeout' => 4,
        ]);
    }

    private static function sendTelegram(array $event, string $summary): void {
        $score = $event['score'] ?? 0;
        wp_remote_post(
            "https://api.telegram.org/bot" . self::TELEGRAM_TOKEN . "/sendMessage",
            [
                'body' => [
                    'chat_id'    => self::TELEGRAM_CHAT_ID,
                    'text'       => "🚨 Security Alert (score {$score})\n\n{$summary}",
                    'parse_mode' => 'HTML',
                ],
                'timeout' => 4,
            ]
        );
    }

    private static function sendPagerDuty(array $event, string $summary): void {
        wp_remote_post('https://events.pagerduty.com/v2/enqueue', [
            'headers' => ['Content-Type' => 'application/json'],
            'body'    => wp_json_encode([
                'routing_key'  => self::PAGERDUTY_KEY,
                'event_action' => 'trigger',
                'payload'      => [
                    'summary'   => "SQLi attack (score {$event['score']}) on " . get_bloginfo('name'),
                    'severity'  => 'critical',
                    'source'    => $event['ip'],
                    'custom_details' => $event,
                ],
            ]),
            'timeout' => 5,
        ]);
    }
}

 

Part 8 — Geographic Filtering Module

<?php
// security-suite/GeoFilter.php

namespace SecuritySuite;

/**
 * Geographic access rules.
 * Optionally block or flag requests from countries not in your customer base.
 * Requires the IP reputation module to have run first (for country_code data).
 *
 * NOTE: Geo-blocking has real risks:
 * - Legitimate users behind VPNs appear in unexpected countries
 * - CDN and shared hosting can misreport country
 * - Administrative access from travel should not be blocked
 * Enable with care and always maintain an IP allowlist.
 */
class GeoFilter {

    // Set MODE to:
    // 'off'        — feature disabled (default, safest)
    // 'flag'       — publish event but don't block
    // 'block_anon' — block unauthenticated requests from disallowed countries
    private const MODE = 'flag';

    // Countries that are allowed (ISO 3166-1 alpha-2).
    // If empty, all countries are allowed (only use for flagging mode then).
    private const ALLOWED_COUNTRIES = [];  // e.g., ['GB', 'US', 'IN', 'AU']

    // Countries to always block regardless of ALLOWED_COUNTRIES:
    private const BLOCKED_COUNTRIES = [];  // e.g., ['CN', 'RU', 'KP', 'IR']

    // IPs to never geo-block (your team, CI/CD, payment providers):
    private const ALLOWLISTED_IPS = [];

    public static function init(): void {
        if (self::MODE === 'off') return;

        // Run after reputation module has enriched the event:
        EventBus::subscribe('sqli.detected', [self::class, 'evaluate'], 15);
    }

    public static function evaluate(array &$event): void {
        $ip          = $event['ip'] ?? '';
        $countryCode = $event['reputation']['country_code'] ?? '';

        if (in_array($ip, self::ALLOWLISTED_IPS, true)) return;
        if (empty($countryCode)) return;

        $isBlocked = in_array($countryCode, self::BLOCKED_COUNTRIES, true);
        $isAllowed = empty(self::ALLOWED_COUNTRIES)
                     || in_array($countryCode, self::ALLOWED_COUNTRIES, true);

        if ($isBlocked || !$isAllowed) {
            $event['geo_flag'] = [
                'country'    => $countryCode,
                'blocked'    => $isBlocked,
                'disallowed' => !$isAllowed,
            ];
            // Boost score for requests from disallowed/blocked regions:
            $event['score'] += 20;

            EventBus::publish('geo.flagged', array_merge($event, ['_event_type' => 'geo.flagged']));

            if (self::MODE === 'block_anon' && !is_user_logged_in()) {
                status_header(403);
                wp_die(
                    'Access from your region is not permitted.',
                    'Forbidden',
                    ['response' => 403]
                );
            }
        }
    }
}

 

Part 9 — Admin Dashboard Module

<?php
// security-suite/AdminDashboard.php

namespace SecuritySuite;

class AdminDashboard {

    public static function init(): void {
        add_action('admin_menu', [self::class, 'registerPages']);
        add_action('admin_init', [self::class, 'handleActions']);
    }

    public static function registerPages(): void {
        add_management_page(
            'Security Suite',
            '🛡️ Security Suite',
            'manage_options',
            'security-suite',
            [self::class, 'renderDashboard']
        );
    }

    public static function handleActions(): void {
        if (!current_user_can('manage_options')) return;

        // Mark event as reviewed:
        if (!empty($_GET['sqli_review']) && check_admin_referer('sqli_review')) {
            global $wpdb;
            $wpdb->update(
                $wpdb->prefix . 'security_events',
                ['reviewed' => 1],
                ['id' => absint($_GET['sqli_review'])],
                ['%d'],
                ['%d']
            );
            wp_redirect(admin_url('tools.php?page=security-suite&reviewed=1'));
            exit;
        }

        // Clear old events:
        if (!empty($_POST['sqli_clear_days']) && check_admin_referer('sqli_clear')) {
            global $wpdb;
            $days = absint($_POST['sqli_clear_days']);
            $wpdb->query($wpdb->prepare(
                "DELETE FROM {$wpdb->prefix}security_events
                 WHERE event_time < DATE_SUB(NOW(), INTERVAL %d DAY)",
                $days
            ));
        }
    }

    public static function renderDashboard(): void {
        if (!current_user_can('manage_options')) wp_die('Unauthorized');

        global $wpdb;
        $table = $wpdb->prefix . 'security_events';

        // ── Summary stats ──────────────────────────────────────────────────────
        $stats = $wpdb->get_row(
            "SELECT
                COUNT(*) AS total,
                SUM(score >= 90) AS critical,
                SUM(score >= 40 AND score < 90) AS high,
                SUM(reviewed = 0) AS unreviewed,
                COUNT(DISTINCT ip) AS unique_ips
             FROM {$table}
             WHERE event_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)"
        );

        // ── Top attacking IPs ──────────────────────────────────────────────────
        $topIps = $wpdb->get_results(
            "SELECT ip, COUNT(*) AS hits, MAX(score) AS max_score,
                    MAX(country_code) AS country
             FROM {$table}
             WHERE event_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)
             GROUP BY ip ORDER BY hits DESC LIMIT 10"
        );

        // ── Recent events ──────────────────────────────────────────────────────
        $events = $wpdb->get_results(
            "SELECT * FROM {$table} ORDER BY event_time DESC LIMIT 50"
        );

        // ── Render ─────────────────────────────────────────────────────────────
        ?>
        <div class="wrap">
            <h1>🛡️ Security Suite — Event Dashboard</h1>

            <?php if (!empty($_GET['reviewed'])): ?>
            <div class="notice notice-success is-dismissible"><p>Event marked as reviewed.</p></div>
            <?php endif; ?>

            <!-- Summary Cards -->
            <div style="display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin:20px 0">
                <?php foreach ([
                    ['label' => '7-day Events',  'value' => $stats->total ?? 0,       'color' => '#1a1a2e'],
                    ['label' => '🚨 Critical',   'value' => $stats->critical ?? 0,    'color' => '#dc2626'],
                    ['label' => '⚠️ High',       'value' => $stats->high ?? 0,        'color' => '#f59e0b'],
                    ['label' => '👁 Unreviewed', 'value' => $stats->unreviewed ?? 0,  'color' => '#7c3aed'],
                    ['label' => '🌐 Unique IPs', 'value' => $stats->unique_ips ?? 0,  'color' => '#0891b2'],
                ] as $card): ?>
                <div style="background:<?php echo esc_attr($card['color']); ?>;color:#fff;
                            padding:18px;border-radius:10px;text-align:center">
                    <div style="font-size:2rem;font-weight:700"><?php echo intval($card['value']); ?></div>
                    <div style="font-size:.8rem;opacity:.85"><?php echo esc_html($card['label']); ?></div>
                </div>
                <?php endforeach; ?>
            </div>

            <!-- Top IPs -->
            <h2>Top Source IPs (last 7 days)</h2>
            <table class="wp-list-table widefat fixed striped" style="max-width:700px">
                <thead><tr><th>IP</th><th>Country</th><th>Attempts</th><th>Max Score</th></tr></thead>
                <tbody>
                <?php foreach ($topIps as $row): ?>
                    <tr>
                        <td><code><?php echo esc_html($row->ip); ?></code></td>
                        <td><?php echo esc_html($row->country); ?></td>
                        <td><?php echo intval($row->hits); ?></td>
                        <td style="font-weight:<?php echo $row->max_score >= 90 ? '700' : 'normal'; ?>;
                                   color:<?php echo $row->max_score >= 90 ? '#dc2626' : 'inherit'; ?>">
                            <?php echo intval($row->max_score); ?>
                        </td>
                    </tr>
                <?php endforeach; ?>
                </tbody>
            </table>

            <!-- Recent Events -->
            <h2 style="margin-top:32px">Recent Events</h2>
            <table class="wp-list-table widefat striped">
                <thead>
                    <tr><th>Time</th><th>Type</th><th>IP</th><th>Score</th>
                        <th>Country</th><th>URI</th><th>Payload</th><th>Action</th></tr>
                </thead>
                <tbody>
                <?php foreach ($events as $ev):
                    $payload = json_decode($ev->payload, true);
                    $isHigh  = (int)$ev->score >= 90;
                ?>
                    <tr style="<?php echo $ev->reviewed ? 'opacity:.5' : ''; ?>">
                        <td><small><?php echo esc_html($ev->event_time); ?></small></td>
                        <td><code><?php echo esc_html($ev->event_type); ?></code></td>
                        <td><code><?php echo esc_html($ev->ip); ?></code></td>
                        <td style="font-weight:<?php echo $isHigh ? '700' : 'normal'; ?>;
                                   color:<?php echo $isHigh ? '#dc2626' : 'inherit'; ?>">
                            <?php echo intval($ev->score); ?>
                        </td>
                        <td><?php echo esc_html($ev->country_code); ?></td>
                        <td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
                            <small title="<?php echo esc_attr($ev->uri); ?>"><?php echo esc_html($ev->uri); ?></small>
                        </td>
                        <td>
                            <details>
                                <summary style="cursor:pointer">View</summary>
                                <pre style="font-size:10px;max-height:150px;overflow:auto;max-width:300px"><?php
                                    echo esc_html(json_encode($payload, JSON_PRETTY_PRINT));
                                ?></pre>
                            </details>
                        </td>
                        <td>
                            <?php if (!$ev->reviewed): ?>
                            <a href="<?php echo wp_nonce_url(
                                admin_url("tools.php?page=security-suite&sqli_review={$ev->id}"),
                                'sqli_review'
                            ); ?>" class="button button-small">✓ Reviewed</a>
                            <?php else: ?>
                            <em style="color:#888">Done</em>
                            <?php endif; ?>
                        </td>
                    </tr>
                <?php endforeach; ?>
                </tbody>
            </table>

            <!-- Maintenance -->
            <h2 style="margin-top:32px">Maintenance</h2>
            <form method="post">
                <?php wp_nonce_field('sqli_clear'); ?>
                <label>Delete events older than
                    <select name="sqli_clear_days">
                        <option value="30">30 days</option>
                        <option value="60">60 days</option>
                        <option value="90">90 days</option>
                    </select>
                </label>
                <button type="submit" class="button" onclick="return confirm('Delete old events?')">
                    Clear Old Events
                </button>
            </form>
        </div>
        <?php
    }
}

 

Part 10 — Self-Test, fail2ban, and Grafana Wiring

Self-Test Harness

# Run via WP-CLI to verify all modules are working before relying on them:
wp eval '
namespace SecuritySuite;

// Simulate a high-confidence SQLi event through the full pipeline:
EventBus::publish("sqli.detected", [
    "ip"       => "198.51.100.1",  // TEST-NET — safe to use in tests
    "score"    => 95,
    "uri"      => "/wp-admin/admin-ajax.php?action=test&q=1%27+UNION+SELECT+user_login%2Cuser_pass+FROM+wp_users--",
    "method"   => "GET",
    "ua"       => "sqlmap/1.8 (https://sqlmap.org)",
    "referer"  => "",
    "findings" => [["source"=>"GET","field"=>"q","value"=>"1\' UNION SELECT user_login,user_pass FROM wp_users--","score"=>95,"hits"=>["UNION SELECT"]]],
    "user_id"  => 0,
]);

// Check the event was logged:
global \$wpdb;
\$count = \$wpdb->get_var("SELECT COUNT(*) FROM {\$wpdb->prefix}security_events WHERE ip = \"198.51.100.1\"");
echo "Events logged for test IP: {\$count}\n";
echo "✅ Pipeline test complete — check admin email and Slack for the test alert\n";
'

fail2ban Integration

# /etc/fail2ban/filter.d/wordpress-security-suite.conf

[Definition]
# Match the structured log line format from StructuredLogger.php
failregex = ^.*SECURITY_EVENT type=sqli\.detected ip=<HOST> score=([4-9]\d|\d{3,}) .*$
ignoreregex =

# /etc/fail2ban/jail.local
[wordpress-security-suite]
enabled   = true
filter    = wordpress-security-suite
logpath   = /var/log/php/error.log
            /var/log/nginx/error.log
maxretry  = 1
bantime   = 3600
findtime  = 300
action    = iptables-multiport[name=wordpress-security, port="http,https"]
            sendmail-whois[name=wordpress-security, dest=admin@yoursite.com]

Grafana/Loki Integration (One-Command Setup)

# /etc/promtail/config.yaml
# Promtail ships the structured security log to Grafana Loki.
# Each log line is JSON from security-events.log — Loki can parse every field.

scrape_configs:
  - job_name: wordpress_security
    static_configs:
      - targets: [localhost]
        labels:
          job:        wordpress_security
          site:       yoursite.com
          __path__:   /var/www/html/wp-content/security-events.log

    pipeline_stages:
      - json:
          expressions:
            event_type:   event_type
            ip:           ip
            score:        score
            country_code: reputation.country_code
            uri:          uri
      - labels:
          event_type:
          country_code:
      - metrics:
          attack_score:
            type:        Histogram
            description: Distribution of attack scores
            source:      score
            config:
              buckets: [40, 60, 80, 90, 100, 150]

Grafana panel queries you can build immediately:

# Events per hour (last 24h):
sum by (event_type) (count_over_time({job="wordpress_security"}[1h]))

# Top attacking IPs:
topk(10, sum by (ip) (count_over_time({job="wordpress_security"}[7d])))

# High-score events only:
{job="wordpress_security"} | json | score > 80

# Tor exit node attacks:
{job="wordpress_security"} | json | line_format "{{.reputation}}" | is_tor=true

 

Part 11 — Deployment Checklist and Maintenance Calendar

DEPLOYMENT (one-time)
──────────────────────────────────────────────────────────────────────
□ Upload security-suite/ directory to wp-content/mu-plugins/
□ Upload security-suite.php loader to wp-content/mu-plugins/
□ Configure API keys in the module constants (never commit these to git)
□ Add honeypot field CSS to your theme (display:none, not type="hidden")
□ Register honeypot field names in HoneypotDetector::HONEYPOT_FIELDS
□ Run WP-CLI self-test (Part 10) and verify:
    □ Event appears in admin dashboard
    □ Email received
    □ Slack/Discord/Telegram received (if configured)
□ Enable fail2ban jail and reload fail2ban
□ Verify security-events.log is being created and writable
□ Confirm log file is NOT web-accessible (add to .htaccess or Nginx deny):
  location /wp-content/security-events.log { deny all; }

WEEKLY
──────────────────────────────────────────────────────────────────────
□ Review admin dashboard — mark reviewed events
□ Check "Top Source IPs" table for persistent attackers (add to firewall)
□ Scan log for any events with country_code you don't serve (adjust GeoFilter)
□ Spot-check 3-5 high-score events manually — confirm they're real attacks

MONTHLY
──────────────────────────────────────────────────────────────────────
□ Run self-test harness to confirm pipeline still works end-to-end
□ Clear events older than 60 days (use dashboard Maintenance section)
□ Review false-positive rate: if > 10% of alerts are benign, lower NOISY_FIELDS
  or raise MIN_SCORE_TO_ALERT
□ Check fail2ban ban list for legitimate IPs and clear if needed
□ Rotate API keys (AbuseIPDB, Proxycheck) if usage limit is frequently hit

AFTER ANY PLUGIN UPDATE
──────────────────────────────────────────────────────────────────────
□ Run self-test — plugin updates can affect hooks that the suite relies on
□ Run a quick sqlmap scan on staging to confirm detection still fires
□ Check the security-events.log for any new URI patterns showing up
  (new endpoints = new potential attack surface worth reviewing)
──────────────────────────────────────────────────────────────────────

 

From a Single-Class Notifier to a Security Intelligence Platform

The original MU-plugin article delivers exactly what its title promises: a ready-to-deploy notifier in a single file that logs events, emails the admin, and optionally posts to Slack. For most sites, that’s genuinely useful.

This guide delivers the next step: what happens when that notifier is producing more alerts than you can meaningfully review, when you need to understand who is attacking (not just that someone is), when you want the log data in a format that a monitoring platform can graph, and when you need the detection logic to be modular enough that adding a honeypot or a geofencing rule doesn’t require editing the same class that handles your email notifications.

The architectural choices — EventBus for decoupling, StructuredLogger for observability, IpReputation for enrichment, HoneypotDetector for correlation, AlertDispatcher for multi-channel delivery with rate limiting — are the same choices made by commercial security plugins that charge $200/year. They exist in this guide as open PHP that you own, understand, and can extend without a subscription.

The fail2ban integration and the Grafana/Loki wiring close the loop: attacks are detected in PHP, logged to structured JSON, shipped to Loki, graphed in Grafana, and automatically banned at the network layer by fail2ban — all from a free, self-hosted stack running in your existing WordPress infrastructure.

 

Frequently Asked Questions

+

What is the SQL Injection Attack Notifier MU Plugin?

The SQL Injection Attack Notifier is a Must-Use (MU) WordPress plugin that automatically detects suspicious SQL Injection attempts, logs attack details, and sends instant email alerts to administrators without requiring manual activation.
+

What is an MU Plugin in WordPress?

An MU (Must-Use) plugin is a special type of WordPress plugin that loads automatically from the wp-content/mu-plugins/ directory. Unlike regular plugins, it cannot be accidentally deactivated from the WordPress admin dashboard.
+

How does the SQL Injection Attack Notifier work?

The plugin monitors incoming HTTP requests (GET, POST, REQUEST, and URL parameters) for common SQL Injection patterns. When suspicious input is detected, it logs the event and sends an email notification with attack details.
+

What SQL Injection patterns can the plugin detect?

The plugin can identify many common SQLi payloads, including:

  • ' OR 1=1 --
  • UNION SELECT
  • DROP TABLE
  • INSERT INTO
  • UPDATE
  • DELETE FROM
  • SLEEP()
  • BENCHMARK()
  • SQL comments (--, #, /* */)
  • Time-based and Boolean-based SQL Injection attempts
+

Does this plugin block SQL Injection attacks?

Its primary purpose is detection and alerting. It helps administrators identify suspicious requests quickly. You should still use prepared statements, input validation, and a Web Application Firewall (WAF) for complete protection.
+

Will this plugin slow down my WordPress website?

No. The plugin performs lightweight pattern matching on incoming requests and has minimal impact on website performance when implemented correctly.
+

Does the plugin work with custom PHP applications?

The plugin is designed specifically for WordPress MU Plugins. However, the same detection logic can be adapted for custom PHP applications with minor modifications.
+

Where are SQL Injection attempts logged?

Depending on your implementation, attacks can be:

  • Logged in a database table
  • Written to a log file
  • Sent via email
  • Integrated with external monitoring systems
+

Can the plugin detect attacks from bots?

Yes. Since it inspects every incoming request, it can detect suspicious SQL Injection attempts generated by automated bots as well as manual attackers.
+

Does the plugin work with WooCommerce and custom plugins?

Yes. Because it monitors incoming requests before your application processes them, it can help detect attacks targeting WooCommerce, custom themes, custom plugins, and other WordPress components.
+

Is the plugin compatible with the latest WordPress versions?

The plugin uses standard WordPress MU Plugin functionality and is generally compatible with modern WordPress versions. Always test in a staging environment before deploying to production.
+

Can I customize the SQL Injection detection rules?

Yes. Developers can extend or modify the list of SQL keywords, regular expressions, and notification logic to meet their application's security requirements.
+

Is the SQL Injection Attack Notifier free to use?

If you developed or published the MU plugin yourself, you can distribute and customize it according to your chosen license. Check your project's documentation for licensing details.
Previous Article

Unsafe SQL Calls in WordPress — Hidden Attack Surfaces and a Production-Grade SQLi Notifier

Next Article

How to Generate a Personalized “I’m Attending” Event Banner Dynamically Using PHP and GD Library

Write a Comment

Leave a Comment

Your email address will not be published. Required fields are marked *

Subscribe to our Newsletter

Subscribe to our email newsletter to get the latest posts delivered right to your email.
Pure inspiration, zero spam ✨