diff --git a/api/.env.example b/api/.env.example
new file mode 100644
index 0000000..79b0dbb
--- /dev/null
+++ b/api/.env.example
@@ -0,0 +1,3 @@
+PORT=3000
+DB_PATH=/data/honeypot.db
+NODE_ENV=production
diff --git a/api/Dockerfile b/api/Dockerfile
new file mode 100644
index 0000000..0432ad3
--- /dev/null
+++ b/api/Dockerfile
@@ -0,0 +1,17 @@
+FROM node:20-alpine
+
+# Native deps for better-sqlite3
+RUN apk add --no-cache python3 make g++
+
+WORKDIR /app
+COPY package*.json ./
+RUN npm ci --omit=dev
+
+COPY . .
+
+RUN mkdir -p /data
+
+EXPOSE 3000
+VOLUME ["/data"]
+
+CMD ["node", "server.js"]
diff --git a/api/docker-compose.yml b/api/docker-compose.yml
new file mode 100644
index 0000000..f6caf3e
--- /dev/null
+++ b/api/docker-compose.yml
@@ -0,0 +1,22 @@
+services:
+ honeypot-api:
+ build: .
+ container_name: honeypot-api
+ restart: unless-stopped
+ ports:
+ - "3000:3000"
+ volumes:
+ - honeypot-data:/data
+ environment:
+ - PORT=3000
+ - DB_PATH=/data/honeypot.db
+ - NODE_ENV=production
+ healthcheck:
+ test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/v1/health"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+
+volumes:
+ honeypot-data:
+ driver: local
diff --git a/api/package.json b/api/package.json
new file mode 100644
index 0000000..13c272b
--- /dev/null
+++ b/api/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "honeypot-api",
+ "version": "1.0.0",
+ "description": "Centralized honeypot block aggregation API + dashboard",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js",
+ "dev": "node --watch server.js"
+ },
+ "dependencies": {
+ "better-sqlite3": "^9.4.3",
+ "express": "^4.18.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+}
diff --git a/api/public/index.html b/api/public/index.html
new file mode 100644
index 0000000..1444e83
--- /dev/null
+++ b/api/public/index.html
@@ -0,0 +1,621 @@
+
+
+
+
+
+HONEYPOT // NETWORK MONITOR
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ▶ 24H ACTIVITY TREND
+
+
+
+
+
+
+
+
+
+
▶ ATTACK BREAKDOWN // LAST 30 DAYS
+
+
+
+
+
+
▶ BLOCK REASONS // LAST 30 DAYS
+
+
+
+
+
+
+
+
+ ▶ LIVE THREAT FEED
+ 0 events
+
+
+
+
+
+
+
+
+
+
▶ TOP ATTACKERS // LAST 30 DAYS
+
+
+
+
+ RANK
+ IP ADDRESS
+ TOTAL HITS
+ FREQUENCY
+
+
+
+ Loading…
+
+
+
+
+
+
+
+
+ HONEYPOT NETWORK MONITOR // ANONYMOUS THREAT INTELLIGENCE // ALL DATA IS ANONYMISED
+ REFRESHED: --
+
+
+
+
+
+
diff --git a/api/server.js b/api/server.js
new file mode 100644
index 0000000..6aeb140
--- /dev/null
+++ b/api/server.js
@@ -0,0 +1,237 @@
+'use strict';
+
+const express = require('express');
+const Database = require('better-sqlite3');
+const path = require('path');
+
+const app = express();
+const PORT = Number(process.env.PORT) || 3000;
+const DB = new Database(process.env.DB_PATH || '/data/honeypot.db');
+
+// ── Database setup ────────────────────────────────────────────────────────────
+
+DB.pragma('journal_mode = WAL');
+DB.pragma('synchronous = NORMAL');
+DB.pragma('cache_size = -8000');
+
+DB.exec(`
+ CREATE TABLE IF NOT EXISTS blocks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ received_at INTEGER NOT NULL DEFAULT (unixepoch()),
+ site_id TEXT NOT NULL DEFAULT '',
+ ip_masked TEXT NOT NULL DEFAULT '',
+ form_type TEXT NOT NULL DEFAULT '',
+ reason TEXT NOT NULL DEFAULT '',
+ ua_family TEXT NOT NULL DEFAULT ''
+ );
+ CREATE TABLE IF NOT EXISTS sites (
+ site_id TEXT PRIMARY KEY,
+ first_seen INTEGER NOT NULL DEFAULT (unixepoch()),
+ last_seen INTEGER NOT NULL DEFAULT (unixepoch()),
+ block_count INTEGER NOT NULL DEFAULT 0
+ );
+ CREATE INDEX IF NOT EXISTS idx_recv ON blocks(received_at DESC);
+ CREATE INDEX IF NOT EXISTS idx_ip ON blocks(ip_masked);
+ CREATE INDEX IF NOT EXISTS idx_site ON blocks(site_id);
+`);
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+function maskIP(ip = '') {
+ ip = String(ip).trim();
+ if (!ip) return 'x.x.x.x';
+ if (ip.includes(':')) { // IPv6 — keep first 2 groups
+ const p = ip.split(':');
+ return `${p[0]||'x'}:${p[1]||'x'}:…:x`;
+ }
+ const p = ip.split('.'); // IPv4 — keep first 2 octets
+ return p.length === 4 ? `${p[0]}.${p[1]}.x.x` : 'x.x.x.x';
+}
+
+const UA_MAP = [
+ [/curl\//i, 'curl'],
+ [/python-requests|python\//i, 'Python'],
+ [/go-http-client/i, 'Go'],
+ [/wget\//i, 'Wget'],
+ [/java\//i, 'Java'],
+ [/ruby/i, 'Ruby'],
+ [/perl\//i, 'Perl'],
+ [/php\//i, 'PHP'],
+ [/scrapy/i, 'Scrapy'],
+ [/postman/i, 'Postman'],
+ [/axios/i, 'Axios'],
+ [/node-fetch|node\.js/i, 'Node.js'],
+ [/headlesschrome|phantomjs/i, 'Headless Browser'],
+ [/(bot|crawler|spider|slurp)/i, 'Bot/Crawler'],
+ [/chrome/i, 'Chrome'],
+ [/firefox/i, 'Firefox'],
+ [/safari/i, 'Safari'],
+ [/edge|edg\//i, 'Edge'],
+ [/opera|opr\//i, 'Opera'],
+ [/msie|trident/i, 'IE'],
+];
+
+function parseUA(ua = '') {
+ for (const [re, label] of UA_MAP) if (re.test(ua)) return label;
+ return ua.length ? 'Other' : 'No UA';
+}
+
+// ── In-memory rate limiter ────────────────────────────────────────────────────
+
+const rl = new Map();
+setInterval(() => { const n = Date.now(); for (const [k, v] of rl) if (n > v.r) rl.delete(k); }, 30_000);
+
+function allowed(ip, max = 30, win = 60_000) {
+ const n = Date.now();
+ let e = rl.get(ip);
+ if (!e || n > e.r) { e = { c: 0, r: n + win }; rl.set(ip, e); }
+ return ++e.c <= max;
+}
+
+// ── Stats cache (30s TTL) ─────────────────────────────────────────────────────
+
+let _cache = null, _cacheTs = 0;
+
+function getStats() {
+ if (_cache && Date.now() - _cacheTs < 30_000) return _cache;
+ const now = Math.floor(Date.now() / 1000);
+
+ _cache = {
+ total: DB.prepare('SELECT COUNT(*) n FROM blocks').get().n,
+ today: DB.prepare('SELECT COUNT(*) n FROM blocks WHERE received_at > ?').get(now - 86400).n,
+ last_7d: DB.prepare('SELECT COUNT(*) n FROM blocks WHERE received_at > ?').get(now - 604800).n,
+ last_30d: DB.prepare('SELECT COUNT(*) n FROM blocks WHERE received_at > ?').get(now - 2592000).n,
+ total_sites: DB.prepare('SELECT COUNT(*) n FROM sites').get().n,
+ top_ips: DB.prepare(`
+ SELECT ip_masked ip, COUNT(*) hits
+ FROM blocks WHERE received_at > ?
+ GROUP BY ip_masked ORDER BY hits DESC LIMIT 10
+ `).all(now - 2592000),
+ top_forms: DB.prepare(`
+ SELECT form_type, COUNT(*) hits
+ FROM blocks WHERE received_at > ?
+ GROUP BY form_type ORDER BY hits DESC LIMIT 8
+ `).all(now - 2592000),
+ top_reasons: DB.prepare(`
+ SELECT reason, COUNT(*) hits
+ FROM blocks WHERE received_at > ?
+ GROUP BY reason ORDER BY hits DESC LIMIT 8
+ `).all(now - 2592000),
+ top_ua: DB.prepare(`
+ SELECT ua_family, COUNT(*) hits
+ FROM blocks WHERE received_at > ?
+ GROUP BY ua_family ORDER BY hits DESC LIMIT 8
+ `).all(now - 2592000),
+ recent: DB.prepare(`
+ SELECT received_at, ip_masked ip, form_type, reason, ua_family
+ FROM blocks ORDER BY id DESC LIMIT 40
+ `).all(),
+ hourly: DB.prepare(`
+ SELECT (received_at / 3600) * 3600 h, COUNT(*) n
+ FROM blocks WHERE received_at > ?
+ GROUP BY h ORDER BY h ASC
+ `).all(now - 86400),
+ };
+ _cacheTs = Date.now();
+ return _cache;
+}
+
+// ── SSE live stream ───────────────────────────────────────────────────────────
+
+const sseClients = new Set();
+let lastId = DB.prepare('SELECT MAX(id) id FROM blocks').get().id || 0;
+
+setInterval(() => {
+ if (!sseClients.size) return;
+ const rows = DB.prepare('SELECT * FROM blocks WHERE id > ? ORDER BY id ASC LIMIT 20').all(lastId);
+ if (!rows.length) return;
+ lastId = rows.at(-1).id;
+ const msg = `data: ${JSON.stringify(rows)}\n\n`;
+ for (const r of sseClients) { try { r.write(msg); } catch { sseClients.delete(r); } }
+}, 2000);
+
+// ── Prepared statements ───────────────────────────────────────────────────────
+
+const stmtIns = DB.prepare(
+ 'INSERT INTO blocks (received_at, site_id, ip_masked, form_type, reason, ua_family) VALUES (?,?,?,?,?,?)'
+);
+const stmtSite = DB.prepare(`
+ INSERT INTO sites (site_id, first_seen, last_seen, block_count) VALUES (?,?,?,?)
+ ON CONFLICT(site_id) DO UPDATE SET
+ last_seen = excluded.last_seen,
+ block_count = block_count + excluded.block_count
+`);
+
+const insertBatch = DB.transaction((siteId, blocks) => {
+ const now = Math.floor(Date.now() / 1000);
+ for (const b of blocks) {
+ const ts = b.blocked_at ? Math.floor(new Date(b.blocked_at) / 1000) : now;
+ stmtIns.run(
+ ts, siteId,
+ maskIP(b.ip),
+ String(b.form_type || '').slice(0, 100),
+ String(b.reason || '').slice(0, 255),
+ parseUA(b.user_agent || '')
+ );
+ }
+ stmtSite.run(siteId, now, now, blocks.length);
+});
+
+// ── Express routes ────────────────────────────────────────────────────────────
+
+app.use(express.json({ limit: '128kb' }));
+app.use(express.static(path.join(__dirname, 'public')));
+
+// Submit blocks from a WordPress site
+app.post('/api/v1/submit', (req, res) => {
+ const clientIP = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
+ || req.socket.remoteAddress || '';
+
+ if (!allowed(clientIP)) {
+ return res.status(429).json({ error: 'Rate limit exceeded' });
+ }
+
+ const { site_hash, blocks } = req.body || {};
+
+ if (!site_hash || typeof site_hash !== 'string' || site_hash.length < 8) {
+ return res.status(400).json({ error: 'Invalid site_hash' });
+ }
+ if (!Array.isArray(blocks) || !blocks.length || blocks.length > 50) {
+ return res.status(400).json({ error: 'blocks must be array of 1–50 items' });
+ }
+
+ try {
+ insertBatch(site_hash.slice(0, 20), blocks);
+ _cache = null; // invalidate stats cache
+ res.json({ ok: true, received: blocks.length });
+ } catch (e) {
+ console.error('[submit]', e.message);
+ res.status(500).json({ error: 'Internal error' });
+ }
+});
+
+// Public aggregated stats
+app.get('/api/v1/stats', (_, res) => res.json(getStats()));
+
+// SSE live feed
+app.get('/api/v1/stream', (req, res) => {
+ res.writeHead(200, {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ 'Connection': 'keep-alive',
+ 'X-Accel-Buffering': 'no',
+ });
+ res.write(':\n\n'); // flush headers
+ sseClients.add(res);
+ req.on('close', () => sseClients.delete(res));
+});
+
+// Health check
+app.get('/api/v1/health', (_, res) =>
+ res.json({ ok: true, uptime: process.uptime(), sse_clients: sseClients.size })
+);
+
+app.listen(PORT, '0.0.0.0', () => {
+ console.log(`[honeypot-api] listening on :${PORT}`);
+ console.log(`[honeypot-api] db: ${process.env.DB_PATH || '/data/honeypot.db'}`);
+});
diff --git a/honeypot-fields.php b/honeypot-fields.php
index 3a367f3..fea0236 100644
--- a/honeypot-fields.php
+++ b/honeypot-fields.php
@@ -3,7 +3,7 @@
* Plugin Name: Honeypot Fields
* Plugin URI: https://informatiq.services
* Description: Adds invisible honeypot fields to all forms to block spam bots. Works with WordPress core forms, Elementor, Gravity Forms, Contact Form 7, WooCommerce, and more.
- * Version: 2.1.0
+ * Version: 2.2.0
* Author: Malin
* Author URI: https://malin.ro
* License: GPL v2 or later
@@ -19,7 +19,7 @@ if (!defined('ABSPATH')) {
* ====================================================================*/
class SmartHoneypotDB {
- const TABLE_VERSION = 1;
+ const TABLE_VERSION = 1;
const TABLE_VERSION_OPTION = 'hp_db_version';
public static function table(): string {
@@ -29,7 +29,7 @@ class SmartHoneypotDB {
public static function install() {
global $wpdb;
- $table = self::table();
+ $table = self::table();
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
@@ -58,8 +58,8 @@ class SmartHoneypotDB {
self::table(),
[
'blocked_at' => current_time('mysql'),
- 'ip_address' => sanitize_text_field($data['ip'] ?? ''),
- 'form_type' => sanitize_text_field($data['form'] ?? 'Unknown'),
+ 'ip_address' => sanitize_text_field($data['ip'] ?? ''),
+ 'form_type' => sanitize_text_field($data['form'] ?? 'Unknown'),
'reason' => sanitize_text_field($data['reason'] ?? ''),
'request_uri' => esc_url_raw(substr($data['uri'] ?? '', 0, 1000)),
'user_agent' => sanitize_textarea_field($data['ua'] ?? ''),
@@ -72,7 +72,7 @@ class SmartHoneypotDB {
global $wpdb;
$table = self::table();
$limit = max(1, intval($args['per_page'] ?? 25));
- $offset = max(0, intval($args['offset'] ?? 0));
+ $offset = max(0, intval($args['offset'] ?? 0));
$where = '1=1';
$params = [];
@@ -97,12 +97,7 @@ class SmartHoneypotDB {
$params[] = $offset;
$sql = "SELECT * FROM {$table} WHERE {$where} ORDER BY blocked_at DESC LIMIT %d OFFSET %d";
-
- if ($params) {
- $sql = $wpdb->prepare($sql, $params);
- }
-
- return $wpdb->get_results($sql) ?: [];
+ return $wpdb->get_results($wpdb->prepare($sql, $params)) ?: [];
}
public static function count(array $args = []): int {
@@ -128,10 +123,7 @@ class SmartHoneypotDB {
}
$sql = "SELECT COUNT(*) FROM {$table} WHERE {$where}";
- if ($params) {
- $sql = $wpdb->prepare($sql, $params);
- }
- return (int) $wpdb->get_var($sql);
+ return (int) $wpdb->get_var($params ? $wpdb->prepare($sql, $params) : $sql);
}
public static function get_form_types(): array {
@@ -139,12 +131,12 @@ class SmartHoneypotDB {
return $wpdb->get_col("SELECT DISTINCT form_type FROM " . self::table() . " ORDER BY form_type ASC") ?: [];
}
- public static function clear(): int {
+ public static function clear(): void {
global $wpdb;
- return (int) $wpdb->query("TRUNCATE TABLE " . self::table());
+ $wpdb->query("TRUNCATE TABLE " . self::table());
}
- public static function delete_older_than_days(int $days) {
+ public static function delete_older_than_days(int $days): void {
global $wpdb;
$wpdb->query(
$wpdb->prepare(
@@ -155,6 +147,85 @@ class SmartHoneypotDB {
}
}
+/* ======================================================================
+ * CENTRAL API CLIENT
+ * Queues blocked submissions and batch-sends to a central dashboard.
+ * ====================================================================*/
+class SmartHoneypotAPIClient {
+
+ const OPT_SETTINGS = 'hp_api_settings';
+ const OPT_QUEUE = 'hp_api_queue';
+ const QUEUE_MAX = 500;
+ const BATCH_SIZE = 50;
+
+ public static function defaults(): array {
+ return [
+ 'enabled' => false,
+ 'api_url' => '',
+ 'last_sync' => 0,
+ 'sent_total' => 0,
+ ];
+ }
+
+ public static function settings(): array {
+ return wp_parse_args(get_option(self::OPT_SETTINGS, []), self::defaults());
+ }
+
+ /** Called from log_spam() — very fast, just appends to option. */
+ public static function enqueue(array $data): void {
+ $s = self::settings();
+ if (!$s['enabled'] || empty($s['api_url'])) {
+ return;
+ }
+ $queue = (array) get_option(self::OPT_QUEUE, []);
+ if (count($queue) >= self::QUEUE_MAX) {
+ array_shift($queue); // drop oldest when full
+ }
+ $queue[] = $data;
+ update_option(self::OPT_QUEUE, $queue, false); // no autoload
+ }
+
+ /** Called by WP-cron every 5 minutes. Sends pending batch to the API. */
+ public static function flush(): void {
+ $s = self::settings();
+ if (!$s['enabled'] || empty($s['api_url'])) {
+ return;
+ }
+ $queue = (array) get_option(self::OPT_QUEUE, []);
+ if (empty($queue)) {
+ return;
+ }
+
+ $batch = array_splice($queue, 0, self::BATCH_SIZE);
+ $site_hash = hash('sha256', home_url());
+
+ $response = wp_remote_post(
+ trailingslashit(esc_url_raw($s['api_url'])) . 'api/v1/submit',
+ [
+ 'timeout' => 15,
+ 'blocking' => true,
+ 'headers' => ['Content-Type' => 'application/json'],
+ 'body' => wp_json_encode([
+ 'site_hash' => $site_hash,
+ 'blocks' => $batch,
+ ]),
+ ]
+ );
+
+ if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
+ update_option(self::OPT_QUEUE, $queue, false);
+ $s['last_sync'] = time();
+ $s['sent_total'] = ($s['sent_total'] ?? 0) + count($batch);
+ update_option(self::OPT_SETTINGS, $s);
+ }
+ }
+
+ /** Number of items currently waiting to be sent. */
+ public static function queue_size(): int {
+ return count((array) get_option(self::OPT_QUEUE, []));
+ }
+}
+
/* ======================================================================
* ADMIN PAGE
* ====================================================================*/
@@ -165,16 +236,15 @@ class SmartHoneypotAdmin {
const PER_PAGE = 25;
public static function register() {
- add_action('admin_menu', [self::class, 'add_menu']);
- add_action('admin_init', [self::class, 'handle_actions']);
- add_action('admin_enqueue_scripts', [self::class, 'enqueue_styles']);
+ add_action('admin_menu', [self::class, 'add_menu']);
+ add_action('admin_init', [self::class, 'handle_actions']);
+ add_action('admin_enqueue_scripts', [self::class, 'enqueue_styles']);
add_filter('plugin_action_links_' . plugin_basename(HP_PLUGIN_FILE), [self::class, 'plugin_links']);
}
public static function plugin_links($links) {
- $log_link = 'View Logs ';
- array_unshift($links, $log_link);
- array_push($links, 'Documentation ');
+ array_unshift($links, 'View Logs ');
+ $links[] = 'Documentation ';
return $links;
}
@@ -194,39 +264,30 @@ class SmartHoneypotAdmin {
if ($hook !== 'toplevel_page_' . self::MENU_SLUG) {
return;
}
- // Inline styles — no external file needed
- $css = '
- #hp-log-wrap { max-width: 1400px; }
- #hp-log-wrap .hp-stats { display:flex; gap:16px; margin:16px 0; flex-wrap:wrap; }
- #hp-log-wrap .hp-stat-card {
- background:#fff; border:1px solid #c3c4c7; border-radius:4px;
- padding:16px 24px; min-width:140px; text-align:center;
- }
- #hp-log-wrap .hp-stat-card .hp-stat-num { font-size:2em; font-weight:700; color:#2271b1; line-height:1.2; }
- #hp-log-wrap .hp-stat-card .hp-stat-lbl { color:#646970; font-size:12px; }
- #hp-log-wrap .hp-filters { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
- #hp-log-wrap .hp-filters input, #hp-log-wrap .hp-filters select { height:32px; }
- #hp-log-wrap table.hp-log-table { width:100%; border-collapse:collapse; background:#fff; }
- #hp-log-wrap table.hp-log-table th {
- background:#f0f0f1; padding:8px 12px; text-align:left;
- border-bottom:2px solid #c3c4c7; white-space:nowrap;
- }
- #hp-log-wrap table.hp-log-table td { padding:8px 12px; border-bottom:1px solid #f0f0f1; vertical-align:top; }
- #hp-log-wrap table.hp-log-table tr:hover td { background:#f6f7f7; }
- #hp-log-wrap .hp-ua { font-size:11px; color:#646970; max-width:300px; word-break:break-all; }
- #hp-log-wrap .hp-badge {
- display:inline-block; padding:2px 8px; border-radius:3px; font-size:11px; font-weight:600;
- background:#ffecec; color:#b32d2e; border:1px solid #f7c5c5;
- }
- #hp-log-wrap .hp-pagination { margin:12px 0; display:flex; align-items:center; gap:8px; }
- #hp-log-wrap .hp-pagination a, #hp-log-wrap .hp-pagination span {
- display:inline-block; padding:4px 10px; border:1px solid #c3c4c7;
- border-radius:3px; background:#fff; text-decoration:none; color:#2271b1;
- }
- #hp-log-wrap .hp-pagination span.current { background:#2271b1; color:#fff; border-color:#2271b1; }
- #hp-log-wrap .hp-clear-btn { color:#b32d2e; }
- ';
- wp_add_inline_style('common', $css);
+ wp_add_inline_style('common', '
+ #hp-wrap { max-width:1400px; }
+ #hp-wrap .hp-tabs { margin:16px 0 0; }
+ #hp-wrap .hp-stats { display:flex; gap:14px; margin:16px 0; flex-wrap:wrap; }
+ #hp-wrap .hp-stat-card { background:#fff; border:1px solid #c3c4c7; border-radius:4px; padding:14px 22px; min-width:130px; text-align:center; }
+ #hp-wrap .hp-stat-num { font-size:2em; font-weight:700; color:#2271b1; line-height:1.2; }
+ #hp-wrap .hp-stat-lbl { color:#646970; font-size:12px; }
+ #hp-wrap .hp-filters { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
+ #hp-wrap .hp-filters input, #hp-wrap .hp-filters select { height:32px; }
+ #hp-wrap table.hp-log { width:100%; border-collapse:collapse; background:#fff; }
+ #hp-wrap table.hp-log th { background:#f0f0f1; padding:8px 12px; text-align:left; border-bottom:2px solid #c3c4c7; white-space:nowrap; }
+ #hp-wrap table.hp-log td { padding:8px 12px; border-bottom:1px solid #f0f0f1; vertical-align:top; }
+ #hp-wrap table.hp-log tr:hover td { background:#f6f7f7; }
+ #hp-wrap .hp-ua { font-size:11px; color:#646970; max-width:300px; word-break:break-all; }
+ #hp-wrap .hp-badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:11px; font-weight:600; background:#ffecec; color:#b32d2e; border:1px solid #f7c5c5; }
+ #hp-wrap .hp-pager { margin:12px 0; display:flex; align-items:center; gap:8px; }
+ #hp-wrap .hp-pager a, #hp-wrap .hp-pager span { display:inline-block; padding:4px 10px; border:1px solid #c3c4c7; border-radius:3px; background:#fff; text-decoration:none; color:#2271b1; }
+ #hp-wrap .hp-pager span.current { background:#2271b1; color:#fff; border-color:#2271b1; }
+ #hp-wrap .hp-red { color:#b32d2e; }
+ #hp-wrap .hp-api-status { display:inline-flex; align-items:center; gap:6px; font-weight:600; }
+ #hp-wrap .hp-api-status .dot { width:10px; height:10px; border-radius:50%; display:inline-block; }
+ #hp-wrap .dot-on { background:#00a32a; }
+ #hp-wrap .dot-off { background:#646970; }
+ ');
}
public static function handle_actions() {
@@ -236,9 +297,29 @@ class SmartHoneypotAdmin {
if (!current_user_can('manage_options')) {
wp_die('Unauthorized');
}
+
if ($_POST['hp_action'] === 'clear_logs') {
SmartHoneypotDB::clear();
- wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'cleared' => 1], admin_url('admin.php')));
+ wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => 'logs', 'cleared' => 1], admin_url('admin.php')));
+ exit;
+ }
+
+ if ($_POST['hp_action'] === 'save_api_settings') {
+ $current = SmartHoneypotAPIClient::settings();
+ $new = [
+ 'enabled' => !empty($_POST['hp_api_enabled']),
+ 'api_url' => esc_url_raw(trim($_POST['hp_api_url'] ?? '')),
+ 'last_sync' => $current['last_sync'],
+ 'sent_total' => $current['sent_total'],
+ ];
+ update_option(SmartHoneypotAPIClient::OPT_SETTINGS, $new);
+ wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => 'settings', 'saved' => 1], admin_url('admin.php')));
+ exit;
+ }
+
+ if ($_POST['hp_action'] === 'flush_queue') {
+ SmartHoneypotAPIClient::flush();
+ wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => 'settings', 'flushed' => 1], admin_url('admin.php')));
exit;
}
}
@@ -248,165 +329,217 @@ class SmartHoneypotAdmin {
return;
}
- // Filters from query string
- $search = sanitize_text_field($_GET['hp_search'] ?? '');
- $filter_ip = sanitize_text_field($_GET['hp_ip'] ?? '');
- $filter_form = sanitize_text_field($_GET['hp_form'] ?? '');
- $paged = max(1, intval($_GET['paged'] ?? 1));
- $per_page = self::PER_PAGE;
- $offset = ($paged - 1) * $per_page;
-
- $query_args = array_filter([
- 'ip' => $filter_ip,
- 'form' => $filter_form,
- 'search' => $search,
- 'per_page' => $per_page,
- 'offset' => $offset,
- ]);
-
- $rows = SmartHoneypotDB::get_rows($query_args);
- $total = SmartHoneypotDB::count($query_args);
- $total_ever = SmartHoneypotDB::count();
- $form_types = SmartHoneypotDB::get_form_types();
- $total_pages = max(1, ceil($total / $per_page));
-
- // Unique IPs total
- global $wpdb;
- $unique_ips = (int) $wpdb->get_var("SELECT COUNT(DISTINCT ip_address) FROM " . SmartHoneypotDB::table());
- $today = (int) $wpdb->get_var(
- "SELECT COUNT(*) FROM " . SmartHoneypotDB::table() . " WHERE blocked_at >= CURDATE()"
- );
-
- $base_url = admin_url('admin.php?page=' . self::MENU_SLUG);
+ $tab = sanitize_key($_GET['tab'] ?? 'logs');
?>
-
-
Honeypot Logs
+
+
Honeypot Fields
-
All logs have been cleared.
+
+
+
+
+
+
+
Queue flushed to central API.
-
-
-
-
= number_format($total_ever) ?>
-
Total Blocked
-
-
-
= number_format($today) ?>
-
Blocked Today
-
-
-
= number_format($unique_ips) ?>
-
Unique IPs
-
-
-
= count($form_types) ?>
-
Form Types Hit
-
-
+
+
+ Blocked Logs
+
+
+ Central API
+
+
-
-
+ $filter_ip, 'form' => $filter_form,
+ 'search' => $search, 'per_page' => $per_page, 'offset' => $offset,
+ ]);
+
+ $rows = SmartHoneypotDB::get_rows($qargs);
+ $total = SmartHoneypotDB::count($qargs);
+ $total_ever = SmartHoneypotDB::count();
+ $form_types = SmartHoneypotDB::get_form_types();
+ $total_pages = max(1, ceil($total / $per_page));
+
+ global $wpdb;
+ $unique_ips = (int) $wpdb->get_var("SELECT COUNT(DISTINCT ip_address) FROM " . SmartHoneypotDB::table());
+ $today = (int) $wpdb->get_var("SELECT COUNT(*) FROM " . SmartHoneypotDB::table() . " WHERE blocked_at >= CURDATE()");
+
+ $base = admin_url('admin.php?page=' . self::MENU_SLUG . '&tab=logs');
+ ?>
+
+
+
= number_format($total_ever) ?>
Total Blocked
+
= number_format($today) ?>
Today
+
= number_format($unique_ips) ?>
Unique IPs
+
= count($form_types) ?>
Form Types Hit
+
+
+
+
+
+
+
+
+
+
+ All form types
+
+ >= esc_html($ft) ?>
+
+
+
Filter
+
+
Reset
+
+
+
+
+
+ Clear All Logs
+
+
+
+
+
Showing = number_format($total) ?> result= $total !== 1 ? 's' : '' ?> (page = $paged ?> of = $total_pages ?>)
+
+
+
+ # Date / Time IP Address Form Type Reason URI User Agent
+
+
+
+ No blocked attempts recorded yet.
+
+
+ = esc_html($row->id) ?>
+ = esc_html($row->blocked_at) ?>
+
+ = esc_html($row->ip_address) ?>
+ filter
+ lookup ↗
+
+ = esc_html($row->form_type) ?>
+ = esc_html($row->reason) ?>
+ = esc_html($row->request_uri) ?>
+ = esc_html($row->user_agent) ?>
+
+
+
+
+
+ 1): ?>
+
+
+
+
+
Central API Settings
+
+ Submit blocked attempts anonymously to a central dashboard for aggregate threat intelligence.
+ Only anonymised data is sent: masked IPs (first 2 octets only), form type, block reason, and UA family. No site URL, no full IPs.
+
+
+
+
+
+
+
+
+
-
Showing = number_format($total) ?> result= $total !== 1 ? 's' : '' ?>
- (page = $paged ?> of = $total_pages ?>)
+
-
-
-
-
- #
- Date / Time
- IP Address
- Form Type
- Reason
- URI
- User Agent
-
-
-
-
-
- No blocked attempts recorded yet.
-
-
-
-
- = esc_html($row->id) ?>
- = esc_html($row->blocked_at) ?>
-
- = esc_html($row->ip_address) ?>
- filter
-
- lookup ↗
-
- = esc_html($row->form_type) ?>
- = esc_html($row->reason) ?>
- = esc_html($row->request_uri) ?>
- = esc_html($row->user_agent) ?>
-
-
-
-
+ Submission Status
+
-
- 1): ?>
-
+ 0 && $s['enabled'] && $s['api_url']): ?>
+
+
+
+ Flush Queue Now (= $queue_size ?> pending)
+
-
-
+
Website URL Confirmation
@@ -496,7 +617,7 @@ class SmartHoneypotAntiSpam {
esc_attr($this->hp_name),
esc_attr($this->token_name),
esc_attr($this->time_name),
- esc_attr($ts)
+ esc_attr(time())
);
}
@@ -504,9 +625,7 @@ class SmartHoneypotAntiSpam {
echo $this->get_honeypot_html();
}
- /* ------------------------------------------------------------------
- * INJECTION HELPERS
- * ----------------------------------------------------------------*/
+ /* ── Injection helpers ─────────────────────────────────────────── */
public function add_to_content_forms($content) {
if (is_admin() || is_feed()) {
return $content;
@@ -524,10 +643,7 @@ class SmartHoneypotAntiSpam {
public function add_to_elementor_form($field, $instance) {
static $done = false;
- if (!$done && $field['type'] === 'submit') {
- $done = true;
- echo $this->get_honeypot_html();
- }
+ if (!$done && $field['type'] === 'submit') { $done = true; echo $this->get_honeypot_html(); }
}
public function filter_elementor_widget($content, $widget) {
@@ -545,9 +661,7 @@ class SmartHoneypotAntiSpam {
return preg_replace('/(\[submit[^\]]*\])/i', $this->get_honeypot_html() . '$1', $form, 1);
}
- /* ------------------------------------------------------------------
- * VALIDATION
- * ----------------------------------------------------------------*/
+ /* ── Validation ────────────────────────────────────────────────── */
private function check_submission(bool $require_fields = true): bool {
if ($require_fields && !isset($_POST[$this->hp_name])) {
$this->log_spam('Honeypot field missing (direct POST)');
@@ -571,14 +685,8 @@ class SmartHoneypotAntiSpam {
}
if (isset($_POST[$this->time_name])) {
$diff = time() - intval($_POST[$this->time_name]);
- if ($diff < self::MIN_SUBMIT_TIME) {
- $this->log_spam("Submitted too fast ({$diff}s — bot behaviour)");
- return false;
- }
- if ($diff > self::MAX_SUBMIT_TIME) {
- $this->log_spam("Timestamp expired ({$diff}s old)");
- return false;
- }
+ if ($diff < self::MIN_SUBMIT_TIME) { $this->log_spam("Submitted too fast ({$diff}s)"); return false; }
+ if ($diff > self::MAX_SUBMIT_TIME) { $this->log_spam("Timestamp expired ({$diff}s)"); return false; }
}
$this->clean_post_data();
return true;
@@ -604,7 +712,7 @@ class SmartHoneypotAntiSpam {
$uri = $_SERVER['REQUEST_URI'] ?? '';
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
- // Write to DB
+ // Write to local DB
SmartHoneypotDB::insert([
'ip' => $ip,
'form' => $this->current_form_type,
@@ -613,13 +721,19 @@ class SmartHoneypotAntiSpam {
'ua' => $ua,
]);
- // Also write to PHP error log for server-level monitoring
+ // Queue for central API
+ SmartHoneypotAPIClient::enqueue([
+ 'ip' => $ip,
+ 'form_type' => $this->current_form_type,
+ 'reason' => $reason,
+ 'user_agent' => $ua,
+ 'blocked_at' => current_time('mysql'),
+ ]);
+
error_log("[Honeypot] {$reason} | form={$this->current_form_type} | ip={$ip} | uri={$uri}");
}
- /* ------------------------------------------------------------------
- * RATE LIMITING
- * ----------------------------------------------------------------*/
+ /* ── Rate limiting ─────────────────────────────────────────────── */
private function check_rate_limit(): bool {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
if (!$ip) {
@@ -628,17 +742,14 @@ class SmartHoneypotAntiSpam {
$key = 'hp_rate_' . md5($ip);
$count = (int) get_transient($key);
if ($count >= self::RATE_LIMIT) {
- $this->current_form_type .= ' (rate limited)';
- $this->log_spam("Rate limit hit — {$count} attempts this hour from {$ip}");
+ $this->log_spam("Rate limit exceeded ({$count}/hr from {$ip})");
return false;
}
set_transient($key, $count + 1, HOUR_IN_SECONDS);
return true;
}
- /* ------------------------------------------------------------------
- * WOOCOMMERCE
- * ----------------------------------------------------------------*/
+ /* ── WooCommerce ───────────────────────────────────────────────── */
public function validate_wc_registration($errors, $username, $password, $email) {
$this->current_form_type = 'WooCommerce Registration';
if (!$this->check_submission(true)) {
@@ -646,7 +757,7 @@ class SmartHoneypotAntiSpam {
return $errors;
}
if (!$this->check_rate_limit()) {
- $errors->add('honeypot_rate', __('Error : Too many registration attempts. Try again later.', 'smart-honeypot'));
+ $errors->add('honeypot_rate', __('Error : Too many attempts. Try again later.', 'smart-honeypot'));
}
return $errors;
}
@@ -666,9 +777,7 @@ class SmartHoneypotAntiSpam {
}
}
- /* ------------------------------------------------------------------
- * WORDPRESS CORE
- * ----------------------------------------------------------------*/
+ /* ── WordPress core ────────────────────────────────────────────── */
public function validate_wp_registration($errors, $login, $email) {
$this->current_form_type = 'WP Registration';
if (!$this->check_submission(true)) {
@@ -696,9 +805,7 @@ class SmartHoneypotAntiSpam {
return $commentdata;
}
- /* ------------------------------------------------------------------
- * ELEMENTOR
- * ----------------------------------------------------------------*/
+ /* ── Elementor ─────────────────────────────────────────────────── */
public function validate_elementor_form($record, $ajax_handler) {
$this->current_form_type = 'Elementor Form';
if (!$this->check_submission(true)) {
@@ -706,9 +813,7 @@ class SmartHoneypotAntiSpam {
}
}
- /* ------------------------------------------------------------------
- * GENERIC CATCH-ALL
- * ----------------------------------------------------------------*/
+ /* ── Generic catch-all ─────────────────────────────────────────── */
public function validate_generic_post() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
return;
@@ -716,17 +821,15 @@ class SmartHoneypotAntiSpam {
if (!isset($_POST[$this->hp_name]) && !isset($_POST[$this->token_name])) {
return;
}
- // Skip forms handled by specific hooks
if (
- isset($_POST['woocommerce-register-nonce']) ||
- isset($_POST['woocommerce-login-nonce']) ||
- isset($_POST['woocommerce-process-checkout-nonce']) ||
- isset($_POST['comment_post_ID']) ||
+ isset($_POST['woocommerce-register-nonce']) ||
+ isset($_POST['woocommerce-login-nonce']) ||
+ isset($_POST['woocommerce-process-checkout-nonce']) ||
+ isset($_POST['comment_post_ID']) ||
(isset($_POST['action']) && $_POST['action'] === 'elementor_pro_forms_send_form')
) {
return;
}
-
$this->current_form_type = 'Generic Form';
if (!$this->check_submission(false)) {
if (wp_doing_ajax()) {
@@ -737,21 +840,16 @@ class SmartHoneypotAntiSpam {
}
}
- /* ------------------------------------------------------------------
- * CSS
- * ----------------------------------------------------------------*/
+ /* ── CSS ───────────────────────────────────────────────────────── */
public function print_css() {
echo '';
}
- /* ------------------------------------------------------------------
- * JS — HMAC token via SubtleCrypto
- * ----------------------------------------------------------------*/
+ /* ── JS — HMAC token via SubtleCrypto ──────────────────────────── */
public function print_js() {
$secret = esc_js($this->secret);
$token_name = esc_js($this->token_name);
$time_name = esc_js($this->time_name);
-
echo <<
(function(){
@@ -783,15 +881,13 @@ class SmartHoneypotAntiSpam {
JSBLOCK;
}
- /* ------------------------------------------------------------------
- * ADMIN NOTICE
- * ----------------------------------------------------------------*/
+ /* ── Admin notice ──────────────────────────────────────────────── */
public function activation_notice() {
if (get_transient('smart_honeypot_activated')) {
- echo '
-
Honeypot Fields is now active. All forms are protected. View logs →
-
';
+ echo '
+ Honeypot Fields is now active. All forms are protected.
+ View logs →
+
';
delete_transient('smart_honeypot_activated');
}
}
@@ -803,7 +899,6 @@ JSBLOCK;
define('HP_PLUGIN_FILE', __FILE__);
add_action('plugins_loaded', function () {
- // Run DB upgrade if needed
if ((int) get_option(SmartHoneypotDB::TABLE_VERSION_OPTION) < SmartHoneypotDB::TABLE_VERSION) {
SmartHoneypotDB::install();
}
@@ -811,19 +906,29 @@ add_action('plugins_loaded', function () {
SmartHoneypotAdmin::register();
});
+// Custom cron interval (5 minutes)
+add_filter('cron_schedules', function ($s) {
+ if (!isset($s['hp_5min'])) {
+ $s['hp_5min'] = ['interval' => 300, 'display' => 'Every 5 Minutes'];
+ }
+ return $s;
+});
+
+// Cron hooks
+add_action('hp_api_flush', ['SmartHoneypotAPIClient', 'flush']);
+add_action('hp_daily_cleanup', function () {
+ SmartHoneypotDB::delete_older_than_days(90);
+});
+
register_activation_hook(__FILE__, function () {
SmartHoneypotDB::install();
set_transient('smart_honeypot_activated', true, 30);
+ if (!wp_next_scheduled('hp_api_flush')) wp_schedule_event(time(), 'hp_5min', 'hp_api_flush');
+ if (!wp_next_scheduled('hp_daily_cleanup')) wp_schedule_event(time(), 'daily', 'hp_daily_cleanup');
});
register_deactivation_hook(__FILE__, function () {
delete_transient('smart_honeypot_activated');
+ wp_clear_scheduled_hook('hp_api_flush');
+ wp_clear_scheduled_hook('hp_daily_cleanup');
});
-
-// Auto-prune logs older than 90 days (runs once daily)
-add_action('hp_daily_cleanup', function () {
- SmartHoneypotDB::delete_older_than_days(90);
-});
-if (!wp_next_scheduled('hp_daily_cleanup')) {
- wp_schedule_event(time(), 'daily', 'hp_daily_cleanup');
-}