prefix . 'honeypot_log'; } public static function install() { global $wpdb; $table = self::table(); $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, blocked_at DATETIME NOT NULL, ip_address VARCHAR(45) NOT NULL DEFAULT '', form_type VARCHAR(100) NOT NULL DEFAULT '', reason VARCHAR(255) NOT NULL DEFAULT '', request_uri VARCHAR(1000) NOT NULL DEFAULT '', user_agent TEXT NOT NULL, PRIMARY KEY (id), KEY ip_address (ip_address), KEY blocked_at (blocked_at), KEY form_type (form_type) ) {$charset_collate};"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta($sql); update_option(self::TABLE_VERSION_OPTION, self::TABLE_VERSION); } public static function insert(array $data) { global $wpdb; $wpdb->insert( self::table(), [ 'blocked_at' => current_time('mysql'), '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'] ?? ''), ], ['%s', '%s', '%s', '%s', '%s', '%s'] ); } public static function get_rows(array $args = []): array { global $wpdb; $table = self::table(); $limit = max(1, intval($args['per_page'] ?? 25)); $offset = max(0, intval($args['offset'] ?? 0)); $where = '1=1'; $params = []; if (!empty($args['ip'])) { $where .= ' AND ip_address = %s'; $params[] = sanitize_text_field($args['ip']); } if (!empty($args['form'])) { $where .= ' AND form_type = %s'; $params[] = sanitize_text_field($args['form']); } if (!empty($args['search'])) { $like = '%' . $wpdb->esc_like($args['search']) . '%'; $where .= ' AND (ip_address LIKE %s OR user_agent LIKE %s OR reason LIKE %s)'; $params[] = $like; $params[] = $like; $params[] = $like; } $params[] = $limit; $params[] = $offset; $sql = "SELECT * FROM {$table} WHERE {$where} ORDER BY blocked_at DESC LIMIT %d OFFSET %d"; return $wpdb->get_results($wpdb->prepare($sql, $params)) ?: []; } public static function count(array $args = []): int { global $wpdb; $table = self::table(); $where = '1=1'; $params = []; if (!empty($args['ip'])) { $where .= ' AND ip_address = %s'; $params[] = sanitize_text_field($args['ip']); } if (!empty($args['form'])) { $where .= ' AND form_type = %s'; $params[] = sanitize_text_field($args['form']); } if (!empty($args['search'])) { $like = '%' . $wpdb->esc_like($args['search']) . '%'; $where .= ' AND (ip_address LIKE %s OR user_agent LIKE %s OR reason LIKE %s)'; $params[] = $like; $params[] = $like; $params[] = $like; } $sql = "SELECT COUNT(*) FROM {$table} WHERE {$where}"; return (int) $wpdb->get_var($params ? $wpdb->prepare($sql, $params) : $sql); } public static function get_form_types(): array { global $wpdb; return $wpdb->get_col("SELECT DISTINCT form_type FROM " . self::table() . " ORDER BY form_type ASC") ?: []; } public static function clear(): void { global $wpdb; $wpdb->query("TRUNCATE TABLE " . self::table()); } public static function delete_older_than_days(int $days): void { global $wpdb; $wpdb->query( $wpdb->prepare( "DELETE FROM " . self::table() . " WHERE blocked_at < DATE_SUB(NOW(), INTERVAL %d DAY)", $days ) ); } } /* ====================================================================== * 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' => '', 'api_token' => '', 'last_sync' => 0, 'sent_total' => 0, 'connection_ok' => null, // null=untested, true=ok, false=failed 'last_verified' => 0, 'last_error' => '', ]; } /** * Tests reachability (health endpoint) and auth (submit with bad payload). * Returns ['ok' => bool, 'message' => string] */ public static function test_connection(): array { $s = self::settings(); if (empty($s['api_url'])) { return ['ok' => false, 'message' => 'No API URL configured.']; } $base = trailingslashit(esc_url_raw($s['api_url'])); $headers = ['Content-Type' => 'application/json']; $token = self::resolve_token(); if ($token !== '') { $headers['Authorization'] = 'Bearer ' . $token; } // Step 1 — reachability $health = wp_remote_get($base . 'api/v1/health', ['timeout' => 8]); if (is_wp_error($health)) { return ['ok' => false, 'message' => 'Cannot reach API: ' . $health->get_error_message()]; } $code = wp_remote_retrieve_response_code($health); if ($code !== 200) { return ['ok' => false, 'message' => "API returned HTTP {$code}. Verify the URL is correct."]; } // Step 2 — token validation: POST with an intentionally invalid payload. // Auth passes → 400 (bad payload). Wrong/missing token → 403. $auth = wp_remote_post($base . 'api/v1/submit', [ 'timeout' => 8, 'headers' => $headers, 'body' => wp_json_encode(['site_hash' => 'connectivity_test', 'blocks' => []]), ]); if (is_wp_error($auth)) { return ['ok' => false, 'message' => 'Token check request failed: ' . $auth->get_error_message()]; } $auth_code = wp_remote_retrieve_response_code($auth); if ($auth_code === 403) { return ['ok' => false, 'message' => 'API reachable but token rejected (HTTP 403). Check the token matches API_TOKEN in Docker.']; } // 400 = auth passed, payload correctly rejected — exactly what we expect return ['ok' => true, 'message' => 'Connection verified. API is reachable and token is accepted.']; } /** * Sends one batch of existing local log records to the central API. * Picks up from where it left off using the hp_history_last_id option. * * Returns ['ok', 'sent', 'remaining', 'has_more', 'message'] */ public static function send_history_batch(int $batch_size = 50): array { $s = self::settings(); if (empty($s['api_url'])) { return ['ok' => false, 'message' => 'No API URL configured.']; } global $wpdb; $table = SmartHoneypotDB::table(); $last_id = (int) get_option('hp_history_last_id', 0); $total = SmartHoneypotDB::count(); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE id > %d ORDER BY id ASC LIMIT %d", $last_id, $batch_size ), ARRAY_A ); if (empty($rows)) { return ['ok' => true, 'sent' => 0, 'remaining' => 0, 'has_more' => false, 'message' => 'All records have already been sent.']; } $blocks = array_map(fn($r) => [ 'ip' => $r['ip_address'], 'form_type' => $r['form_type'], 'reason' => $r['reason'], 'user_agent' => $r['user_agent'], 'blocked_at' => $r['blocked_at'], ], $rows); $headers = ['Content-Type' => 'application/json']; $token = self::resolve_token(); if ($token !== '') { $headers['Authorization'] = 'Bearer ' . $token; } $response = wp_remote_post( trailingslashit(esc_url_raw($s['api_url'])) . 'api/v1/submit', [ 'timeout' => 30, 'headers' => $headers, 'body' => wp_json_encode([ 'site_hash' => hash('sha256', home_url()), 'blocks' => $blocks, ]), ] ); if (is_wp_error($response)) { return ['ok' => false, 'message' => 'Request failed: ' . $response->get_error_message()]; } $code = wp_remote_retrieve_response_code($response); if ($code !== 200) { return ['ok' => false, 'message' => "API returned HTTP {$code}. Check connection settings."]; } $new_last_id = (int) end($rows)['id']; $sent_total = (int) get_option('hp_history_total_sent', 0) + count($rows); update_option('hp_history_last_id', $new_last_id); update_option('hp_history_total_sent', $sent_total); $remaining = max(0, $total - $sent_total); return [ 'ok' => true, 'sent' => count($rows), 'remaining' => $remaining, 'has_more' => $remaining > 0, 'message' => sprintf('Sent %d records. %d remaining.', count($rows), $remaining), ]; } 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()); $headers = ['Content-Type' => 'application/json']; $token = self::resolve_token(); if ($token !== '') { $headers['Authorization'] = 'Bearer ' . $token; } $response = wp_remote_post( trailingslashit(esc_url_raw($s['api_url'])) . 'api/v1/submit', [ 'timeout' => 15, 'blocking' => true, 'headers' => $headers, 'body' => wp_json_encode([ 'site_hash' => $site_hash, 'blocks' => $batch, ]), ] ); if (is_wp_error($response)) { $s['connection_ok'] = false; $s['last_error'] = 'Flush failed: ' . $response->get_error_message(); update_option(self::OPT_SETTINGS, $s); error_log('[Honeypot] flush() wp_error: ' . $response->get_error_message()); return; } $code = wp_remote_retrieve_response_code($response); if ($code === 200) { update_option(self::OPT_QUEUE, $queue, false); $s['last_sync'] = time(); $s['sent_total'] = ($s['sent_total'] ?? 0) + count($batch); $s['last_error'] = ''; $s['connection_ok'] = true; update_option(self::OPT_SETTINGS, $s); } else { $s['connection_ok'] = false; $s['last_error'] = "Flush failed: API returned HTTP {$code}. Check URL and token."; update_option(self::OPT_SETTINGS, $s); error_log("[Honeypot] flush() API returned HTTP {$code}"); } } /** Number of items currently waiting to be sent. */ public static function queue_size(): int { return count((array) get_option(self::OPT_QUEUE, [])); } /** * Fallback flush triggered on every PHP shutdown. * A transient lock ensures we attempt at most once per 5 minutes * regardless of traffic volume, so WP-Cron is not the sole trigger. */ public static function maybe_flush_overdue(): void { $s = self::settings(); if (!$s['enabled'] || empty($s['api_url'])) return; if (self::queue_size() === 0) return; if (get_transient('hp_flush_lock')) return; set_transient('hp_flush_lock', 1, 300); // 5-min lock self::flush(); } /** * Returns the API token. * Checks the HP_API_TOKEN constant (defined in wp-config.php) first, * then falls back to the value stored in the database. */ private static function resolve_token(): string { if (defined('HP_API_TOKEN') && HP_API_TOKEN !== '') { return (string) HP_API_TOKEN; } return self::settings()['api_token'] ?? ''; } } /* ====================================================================== * INTERNATIONALISATION * ====================================================================*/ class SmartHoneypotI18n { const USER_META = 'hp_lang'; // phpcs:disable Generic.Files.LineLength const STRINGS = [ 'en' => [ 'tab_logs' => 'Blocked Logs', 'tab_api' => 'Central API', 'cleared' => 'Logs cleared.', 'saved' => 'API settings saved.', 'flushed' => 'Queue flushed to central API.', 'history_reset_msg' => 'History sync progress has been reset. You can re-send from the beginning.', 'click_next' => 'Click "Send Next Batch" to continue.', 'total_blocked' => 'Total Blocked', 'today' => 'Today', 'unique_ips' => 'Unique IPs', 'form_types_hit' => 'Form Types Hit', 'search_ph' => 'Search IP, UA, reason…', 'filter_ip_ph' => 'Filter by IP', 'all_forms' => 'All form types', 'filter_btn' => 'Filter', 'reset_btn' => 'Reset', 'clear_logs' => 'Clear All Logs', 'clear_confirm' => 'Delete ALL log entries permanently?', 'col_date' => 'Date / Time', 'col_ip' => 'IP Address', 'col_form' => 'Form Type', 'col_reason' => 'Reason', 'col_uri' => 'URI', 'col_ua' => 'User Agent', 'no_results' => 'No blocked attempts recorded yet.', 'filter_link' => 'filter', 'lookup_link' => 'lookup ↗', 'prev' => '← Prev', 'next' => 'Next →', 'showing' => 'Showing', 'results' => 'result(s)', 'page_of' => 'page %d of %d', 'settings_title' => 'Central API Settings', 'settings_desc' => 'Submit blocked attempts to a central dashboard for aggregate threat intelligence. Data sent: full IP address, form type, block reason, UA family, and timestamp. No site URL or usernames are ever sent.', 'enable_label' => 'Enable Submission', 'enable_cb' => 'Send blocked attempts to the central API', 'url_label' => 'API Endpoint URL', 'url_desc' => 'Base URL of your Honeypot API Docker container.', 'token_label' => 'API Token', 'token_desc' => 'Must match the API_TOKEN set in your Docker container\'s environment. Leave empty only if the API runs without a token (not recommended).', 'token_const_note' => 'Token is set via the HP_API_TOKEN constant in wp-config.php.', 'save_settings' => 'Save Settings', 'conn_status' => 'Connection Status', 'conn_label' => 'Connection', 'conn_ok' => 'Verified — connected and token accepted', 'conn_fail' => 'Connection failed', 'conn_untested' => 'Not tested yet', 'last_verified' => 'Last Verified', 'enabled_lbl' => 'Enabled', 'yes' => 'Yes', 'no' => 'No', 'last_sync' => 'Last Sync', 'never' => 'Never', 'total_sent' => 'Total Sent', 'blocks_unit' => 'blocks', 'queue_size' => 'Queue Size', 'pending_unit' => 'pending', 'next_flush' => 'Next Auto-Flush', 'not_scheduled' => 'Not scheduled', 'test_conn' => 'Test Connection', 'flush_queue' => 'Flush Queue Now', 'history_title' => 'Send History to API', 'history_desc' => 'Populate the central dashboard with your existing log so charts and stats are meaningful right away, without waiting for new attacks. Records are sent in batches of 50.', 'local_log' => 'Local Log', 'local_log_val' => '%s records in this site\'s database', 'sent_api' => 'Sent to API', 'remaining_lbl' => 'Remaining', 'remaining_val' => '%s records not yet sent', 'send_history' => 'Send History', 'send_next' => 'Send Next Batch', 'all_sent' => '✓ All history sent', 'reset_progress' => 'Reset Progress', ], 'es' => [ 'tab_logs' => 'Registros Bloqueados', 'tab_api' => 'API Central', 'cleared' => 'Registros eliminados.', 'saved' => 'Configuración de API guardada.', 'flushed' => 'Cola enviada a la API central.', 'history_reset_msg' => 'El progreso de sincronización se ha restablecido. Puedes reenviar desde el principio.', 'click_next' => 'Haz clic en "Enviar Siguiente Lote" para continuar.', 'total_blocked' => 'Total Bloqueados', 'today' => 'Hoy', 'unique_ips' => 'IPs Únicas', 'form_types_hit' => 'Tipos de Formulario', 'search_ph' => 'Buscar IP, UA, razón…', 'filter_ip_ph' => 'Filtrar por IP', 'all_forms' => 'Todos los tipos', 'filter_btn' => 'Filtrar', 'reset_btn' => 'Reiniciar', 'clear_logs' => 'Borrar Todos los Registros', 'clear_confirm' => '¿Eliminar TODOS los registros permanentemente?', 'col_date' => 'Fecha / Hora', 'col_ip' => 'Dirección IP', 'col_form' => 'Tipo de Formulario', 'col_reason' => 'Razón', 'col_uri' => 'URI', 'col_ua' => 'User Agent', 'no_results' => 'No hay intentos bloqueados aún.', 'filter_link' => 'filtrar', 'lookup_link' => 'consultar ↗', 'prev' => '← Anterior', 'next' => 'Siguiente →', 'showing' => 'Mostrando', 'results' => 'resultado(s)', 'page_of' => 'página %d de %d', 'settings_title' => 'Configuración de la API Central', 'settings_desc' => 'Envía intentos bloqueados a un panel central de inteligencia de amenazas. Se envía: IP completa, tipo de formulario, razón de bloqueo, familia de UA y marca de tiempo. Nunca se envían URLs del sitio ni nombres de usuario.', 'enable_label' => 'Activar Envío', 'enable_cb' => 'Enviar intentos bloqueados a la API central', 'url_label' => 'URL del Endpoint de la API', 'url_desc' => 'URL base del contenedor Docker de la API Honeypot.', 'token_label' => 'Token de la API', 'token_desc' => 'Debe coincidir con API_TOKEN en el entorno del contenedor Docker.', 'token_const_note' => 'El token está definido mediante la constante HP_API_TOKEN en wp-config.php.', 'save_settings' => 'Guardar Configuración', 'conn_status' => 'Estado de la Conexión', 'conn_label' => 'Conexión', 'conn_ok' => 'Verificada — conectada y token aceptado', 'conn_fail' => 'Error de conexión', 'conn_untested' => 'No probada todavía', 'last_verified' => 'Última Verificación', 'enabled_lbl' => 'Activado', 'yes' => 'Sí', 'no' => 'No', 'last_sync' => 'Última Sincronización', 'never' => 'Nunca', 'total_sent' => 'Total Enviados', 'blocks_unit' => 'bloqueos', 'queue_size' => 'Tamaño de Cola', 'pending_unit' => 'pendientes', 'next_flush' => 'Próximo Envío Automático', 'not_scheduled' => 'No programado', 'test_conn' => 'Probar Conexión', 'flush_queue' => 'Enviar Cola Ahora', 'history_title' => 'Enviar Historial a la API', 'history_desc' => 'Rellena el panel central con tu registro existente para que las estadísticas sean significativas de inmediato, sin esperar nuevos ataques. Los registros se envían en lotes de 50.', 'local_log' => 'Registro Local', 'local_log_val' => '%s registros en la base de datos de este sitio', 'sent_api' => 'Enviados a la API', 'remaining_lbl' => 'Restantes', 'remaining_val' => '%s registros aún no enviados', 'send_history' => 'Enviar Historial', 'send_next' => 'Enviar Siguiente Lote', 'all_sent' => '✓ Todo el historial enviado', 'reset_progress' => 'Reiniciar Progreso', ], 'ro' => [ 'tab_logs' => 'Jurnale Blocate', 'tab_api' => 'API Central', 'cleared' => 'Jurnale șterse.', 'saved' => 'Setări API salvate.', 'flushed' => 'Coada trimisă la API-ul central.', 'history_reset_msg' => 'Progresul sincronizării a fost resetat. Poți retrimite de la început.', 'click_next' => 'Apasă „Trimite Lotul Următor" pentru a continua.', 'total_blocked' => 'Total Blocate', 'today' => 'Azi', 'unique_ips' => 'IP-uri Unice', 'form_types_hit' => 'Tipuri de Formulare Atacate', 'search_ph' => 'Caută IP, UA, motiv…', 'filter_ip_ph' => 'Filtrează după IP', 'all_forms' => 'Toate tipurile', 'filter_btn' => 'Filtrează', 'reset_btn' => 'Resetează', 'clear_logs' => 'Șterge Toate Jurnalele', 'clear_confirm' => 'Ștergi TOATE înregistrările permanent?', 'col_date' => 'Dată / Oră', 'col_ip' => 'Adresă IP', 'col_form' => 'Tip Formular', 'col_reason' => 'Motiv', 'col_uri' => 'URI', 'col_ua' => 'User Agent', 'no_results' => 'Nu există tentative blocate încă.', 'filter_link' => 'filtrează', 'lookup_link' => 'caută ↗', 'prev' => '← Anterior', 'next' => 'Următor →', 'showing' => 'Afișând', 'results' => 'rezultat(e)', 'page_of' => 'pagina %d din %d', 'settings_title' => 'Setări API Central', 'settings_desc' => 'Trimite tentativele blocate la un panou central de informații despre amenințări. Date trimise: IP complet, tip formular, motiv blocare, familie UA și marcă temporală. Nu se trimit niciodată URL-ul site-ului sau nume de utilizatori.', 'enable_label' => 'Activează Trimiterea', 'enable_cb' => 'Trimite tentativele blocate la API-ul central', 'url_label' => 'URL Endpoint API', 'url_desc' => 'URL-ul de bază al containerului Docker API Honeypot.', 'token_label' => 'Token API', 'token_desc' => 'Trebuie să coincidă cu API_TOKEN din mediul containerului Docker.', 'token_const_note' => 'Tokenul este setat prin constanta HP_API_TOKEN în wp-config.php.', 'save_settings' => 'Salvează Setările', 'conn_status' => 'Status Conexiune', 'conn_label' => 'Conexiune', 'conn_ok' => 'Verificată — conectată și token acceptat', 'conn_fail' => 'Conexiune eșuată', 'conn_untested' => 'Netestată încă', 'last_verified' => 'Ultima Verificare', 'enabled_lbl' => 'Activat', 'yes' => 'Da', 'no' => 'Nu', 'last_sync' => 'Ultima Sincronizare', 'never' => 'Niciodată', 'total_sent' => 'Total Trimise', 'blocks_unit' => 'blocări', 'queue_size' => 'Dimensiune Coadă', 'pending_unit' => 'în așteptare', 'next_flush' => 'Următoarea Trimitere Automată', 'not_scheduled' => 'Neprogramată', 'test_conn' => 'Testează Conexiunea', 'flush_queue' => 'Trimite Coada Acum', 'history_title' => 'Trimite Istoricul la API', 'history_desc' => 'Populează panoul central cu jurnalul existent pentru ca statisticile să fie relevante imediat, fără a aștepta noi atacuri. Înregistrările se trimit în loturi de 50.', 'local_log' => 'Jurnal Local', 'local_log_val' => '%s înregistrări în baza de date a acestui site', 'sent_api' => 'Trimise la API', 'remaining_lbl' => 'Rămase', 'remaining_val' => '%s înregistrări netrimise', 'send_history' => 'Trimite Istoricul', 'send_next' => 'Trimite Lotul Următor', 'all_sent' => '✓ Tot istoricul a fost trimis', 'reset_progress' => 'Resetează Progresul', ], ]; // phpcs:enable public static function get_lang(): string { $user_id = get_current_user_id(); $lang = $user_id ? (string) get_user_meta($user_id, self::USER_META, true) : ''; return in_array($lang, ['en', 'es', 'ro'], true) ? $lang : 'en'; } public static function t(string $key): string { $lang = self::get_lang(); $strings = self::STRINGS[$lang] ?? self::STRINGS['en']; return $strings[$key] ?? (self::STRINGS['en'][$key] ?? $key); } public static function flag_switcher(): string { $lang = self::get_lang(); $nonce = wp_nonce_field('hp_admin_action', '_wpnonce', true, false); $tab = sanitize_key($_GET['tab'] ?? 'logs'); $html = '
'; foreach (['en' => '🇬🇧', 'es' => '🇪🇸', 'ro' => '🇷🇴'] as $code => $flag) { $style = $lang === $code ? 'background:none;border:none;cursor:pointer;padding:2px;font-size:22px;opacity:1' : 'background:none;border:none;cursor:pointer;padding:2px;font-size:16px;opacity:0.45'; $html .= sprintf( '
%s' . '' . '' . '' . '
', $nonce, esc_attr($tab), esc_attr($code), esc_attr(strtoupper($code)), esc_attr($style), $flag ); } $html .= '
'; return $html; } } /* ====================================================================== * ADMIN PAGE * ====================================================================*/ class SmartHoneypotAdmin { const MENU_SLUG = 'honeypot-logs'; const NONCE_ACTION = 'hp_admin_action'; 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_filter('plugin_action_links_' . plugin_basename(HP_PLUGIN_FILE), [self::class, 'plugin_links']); } public static function plugin_links($links) { array_unshift($links, 'View Logs'); $links[] = 'Documentation'; return $links; } public static function add_menu() { add_menu_page( 'Honeypot Logs', 'Honeypot Logs', 'manage_options', self::MENU_SLUG, [self::class, 'render_page'], 'dashicons-shield-alt', 81 ); } public static function enqueue_styles($hook) { if ($hook !== 'toplevel_page_' . self::MENU_SLUG) { return; } 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() { if (!isset($_POST['hp_action']) || !check_admin_referer(self::NONCE_ACTION)) { return; } 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, 'tab' => 'logs', 'cleared' => 1], admin_url('admin.php'))); exit; } if ($_POST['hp_action'] === 'save_api_settings') { $current = SmartHoneypotAPIClient::settings(); $new_url = esc_url_raw(trim($_POST['hp_api_url'] ?? '')); $new_token = defined('HP_API_TOKEN') ? $current['api_token'] : sanitize_text_field($_POST['hp_api_token'] ?? ''); $url_changed = $new_url !== $current['api_url']; $tok_changed = $new_token !== $current['api_token']; $new = [ 'enabled' => !empty($_POST['hp_api_enabled']), 'api_url' => $new_url, 'api_token' => $new_token, 'last_sync' => $current['last_sync'], 'sent_total' => $current['sent_total'], // Reset verification if URL or token changed 'connection_ok' => ($url_changed || $tok_changed) ? null : $current['connection_ok'], 'last_verified' => ($url_changed || $tok_changed) ? 0 : $current['last_verified'], 'last_error' => ($url_changed || $tok_changed) ? '' : $current['last_error'], ]; 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'] === 'test_connection') { $result = SmartHoneypotAPIClient::test_connection(); $s = SmartHoneypotAPIClient::settings(); $s['connection_ok'] = $result['ok']; $s['last_verified'] = time(); $s['last_error'] = $result['ok'] ? '' : $result['message']; update_option(SmartHoneypotAPIClient::OPT_SETTINGS, $s); set_transient('hp_conn_result', $result, 60); wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => 'settings', 'tested' => 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; } if ($_POST['hp_action'] === 'send_history') { $result = SmartHoneypotAPIClient::send_history_batch(); set_transient('hp_history_result', $result, 60); wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => 'settings', 'history' => 1], admin_url('admin.php'))); exit; } if ($_POST['hp_action'] === 'reset_history') { delete_option('hp_history_last_id'); delete_option('hp_history_total_sent'); wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => 'settings', 'history_reset' => 1], admin_url('admin.php'))); exit; } if ($_POST['hp_action'] === 'set_language') { $lang = sanitize_key($_POST['hp_lang'] ?? 'en'); if (in_array($lang, ['en', 'es', 'ro'], true)) { update_user_meta(get_current_user_id(), SmartHoneypotI18n::USER_META, $lang); } $tab = sanitize_key($_GET['tab'] ?? 'logs'); wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => $tab], admin_url('admin.php'))); exit; } } public static function render_page() { if (!current_user_can('manage_options')) { return; } $tab = sanitize_key($_GET['tab'] ?? 'logs'); ?>

Honeypot Fields

 

SmartHoneypotI18n::t($k); $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; $qargs = array_filter([ 'ip' => $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'); ?>

()

#
id) ?> blocked_at) ?> ip_address) ?>
 
form_type) ?> reason) ?> request_uri) ?> user_agent) ?>
1): ?>
1): ?> $p]))); ?>
SmartHoneypotI18n::t($k); $s = SmartHoneypotAPIClient::settings(); $queue_size = SmartHoneypotAPIClient::queue_size(); $next_run = wp_next_scheduled('hp_api_flush'); $token_via_const = defined('HP_API_TOKEN') && HP_API_TOKEN !== ''; ?>


0 && $s['enabled'] && $s['api_url']): ?>

0 ? min(100, round($history_sent / $local_total * 100)) : 0; ?>
(%) 0): ?>
0): ?>
0): ?>
secret = wp_hash('honeypot_plugin_secret_v2'); $this->hp_name = 'website_url_confirm'; $this->token_name = 'form_session_id'; $this->time_name = 'form_render_ts'; add_action('init', [$this, 'init']); } /* ── Init ──────────────────────────────────────────────────────── */ public function init() { // CF7 and Elementor submit via admin-ajax.php where is_admin()=true, // so register their validation hooks before the early return. add_filter('wpcf7_spam', [$this, 'validate_cf7_spam'], 10, 2); add_action('elementor_pro/forms/validation', [$this, 'validate_elementor_form'], 10, 2); if (is_admin()) { add_action('admin_notices', [$this, 'activation_notice']); return; } // Inject honeypot add_filter('the_content', [$this, 'add_to_content_forms'], 99); add_action('comment_form_after_fields', [$this, 'echo_honeypot']); add_action('comment_form_logged_in_after', [$this, 'echo_honeypot']); add_action('woocommerce_register_form', [$this, 'echo_honeypot']); add_action('woocommerce_login_form', [$this, 'echo_honeypot']); add_action('woocommerce_after_order_notes',[$this, 'echo_honeypot']); add_action('register_form', [$this, 'echo_honeypot']); add_action('login_form', [$this, 'echo_honeypot']); add_action('lostpassword_form', [$this, 'echo_honeypot']); add_action('woocommerce_lostpassword_form',[$this, 'echo_honeypot']); add_action('elementor_pro/forms/render_field', [$this, 'add_to_elementor_form'], 10, 2); add_action('elementor/widget/render_content', [$this, 'filter_elementor_widget'], 10, 2); add_filter('gform_form_tag', [$this, 'add_to_gravity_forms'], 10, 2); add_filter('wpcf7_form_elements', [$this, 'add_to_cf7']); add_filter('get_search_form', [$this, 'add_to_search_form'], 99); // Validate add_filter('woocommerce_process_registration_errors', [$this, 'validate_wc_registration'], 10, 4); add_filter('woocommerce_process_login_errors', [$this, 'validate_wc_login'], 10, 3); add_action('woocommerce_after_checkout_validation', [$this, 'validate_wc_checkout'], 10, 2); add_filter('registration_errors', [$this, 'validate_wp_registration'], 10, 3); add_filter('authenticate', [$this, 'validate_wp_login'], 20, 3); add_action('lostpassword_post', [$this, 'validate_lost_password'], 10, 2); add_filter('preprocess_comment', [$this, 'validate_comment']); add_action('template_redirect', [$this, 'validate_generic_post']); // CSS & JS (wp-login.php uses login_head/login_footer, not wp_head/footer) add_action('wp_head', [$this, 'print_css']); add_action('wp_footer', [$this, 'print_js'], 99); add_action('login_head', [$this, 'print_css']); add_action('login_footer', [$this, 'print_js'], 99); } /* ── Honeypot HTML ─────────────────────────────────────────────── */ private function get_honeypot_html(): string { return sprintf( '', esc_attr($this->hp_name), esc_attr($this->token_name), esc_attr($this->time_name), esc_attr(time()) ); } public function echo_honeypot() { echo $this->get_honeypot_html(); } /* ── Injection helpers ─────────────────────────────────────────── */ public function add_to_content_forms($content) { if (is_admin() || is_feed()) { return $content; } return preg_replace_callback( '/(]*>)(.*?)(<\/form>)/is', fn($m) => $m[1] . $m[2] . $this->get_honeypot_html() . $m[3], $content ); } public function add_to_search_form($form) { return preg_replace('/(<\/form>)/i', $this->get_honeypot_html() . '$1', $form, 1); } public function add_to_elementor_form($field, $instance) { static $done = false; if (!$done && $field['type'] === 'submit') { $done = true; echo $this->get_honeypot_html(); } } public function filter_elementor_widget($content, $widget) { if ($widget->get_name() === 'form') { $content = preg_replace('/(<\/form>)/i', $this->get_honeypot_html() . '$1', $content, 1); } return $content; } public function add_to_gravity_forms($tag, $form) { return preg_replace('/(
self::MAX_SUBMIT_TIME) { $this->log_spam("Timestamp expired ({$diff}s)"); return false; } } $this->clean_post_data(); return true; } private function validate_js_token(string $token): bool { $parts = explode('|', $token); if (count($parts) !== 2) { return false; } $expected = hash_hmac('sha256', $parts[0] . '|honeypot_js_proof', $this->secret); return hash_equals(substr($expected, 0, 16), $parts[1]); } private function clean_post_data() { foreach ([$this->hp_name, $this->token_name, $this->time_name] as $k) { unset($_POST[$k], $_REQUEST[$k]); } } private function log_spam(string $reason) { $ip = $_SERVER['REMOTE_ADDR'] ?? ''; $uri = $_SERVER['REQUEST_URI'] ?? ''; $ua = $_SERVER['HTTP_USER_AGENT'] ?? ''; // Write to local DB SmartHoneypotDB::insert([ 'ip' => $ip, 'form' => $this->current_form_type, 'reason' => $reason, 'uri' => $uri, 'ua' => $ua, ]); // 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 ─────────────────────────────────────────────── */ private function check_rate_limit(): bool { $ip = $_SERVER['REMOTE_ADDR'] ?? ''; if (!$ip) { return false; } $key = 'hp_rate_' . md5($ip); $count = (int) get_transient($key); if ($count >= self::RATE_LIMIT) { $this->log_spam("Rate limit exceeded ({$count}/hr from {$ip})"); return false; } set_transient($key, $count + 1, HOUR_IN_SECONDS); return true; } /* ── WooCommerce ───────────────────────────────────────────────── */ public function validate_wc_registration($errors, $username, $password, $email) { $this->current_form_type = 'WooCommerce Registration'; if (!$this->check_submission(true)) { $errors->add('honeypot_spam', __('Error: Registration blocked. Enable JavaScript and try again.', 'smart-honeypot')); return $errors; } if (!$this->check_rate_limit()) { $errors->add('honeypot_rate', __('Error: Too many attempts. Try again later.', 'smart-honeypot')); } return $errors; } public function validate_wc_login($errors, $username, $password) { $this->current_form_type = 'WooCommerce Login'; if (!$this->check_submission(true)) { $errors->add('honeypot_spam', __('Error: Login blocked. Enable JavaScript and try again.', 'smart-honeypot')); } return $errors; } public function validate_wc_checkout($data, $errors) { $this->current_form_type = 'WooCommerce Checkout'; if (!$this->check_submission(false)) { $errors->add('honeypot_spam', __('Order could not be processed. Please try again.', 'smart-honeypot')); } } /* ── WordPress core ────────────────────────────────────────────── */ public function validate_wp_registration($errors, $login, $email) { $this->current_form_type = 'WP Registration'; if (!$this->check_submission(true)) { $errors->add('honeypot_spam', __('Error: Registration blocked. Enable JavaScript and try again.', 'smart-honeypot')); } if (!$this->check_rate_limit()) { $errors->add('honeypot_rate', __('Error: Too many attempts. Try again later.', 'smart-honeypot')); } return $errors; } public function validate_wp_login($user, $username, $password) { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { return $user; } // Only validate if our honeypot was injected (fields present in POST) if (!isset($_POST[$this->hp_name]) && !isset($_POST[$this->token_name])) { return $user; } // WooCommerce login has its own validator if (isset($_POST['woocommerce-login-nonce'])) { return $user; } $this->current_form_type = 'WP Login'; if (!$this->check_submission(true)) { return new \WP_Error( 'honeypot_spam', __('Error: Login blocked. Enable JavaScript and try again.', 'smart-honeypot') ); } return $user; } public function validate_lost_password($errors, $user_data) { $this->current_form_type = 'WP Password Reset'; if (!$this->check_submission(true)) { $errors->add( 'honeypot_spam', __('Error: Request blocked. Enable JavaScript and try again.', 'smart-honeypot') ); } } public function validate_comment($commentdata) { if (is_user_logged_in() && current_user_can('moderate_comments')) { $this->clean_post_data(); return $commentdata; } $this->current_form_type = 'Comment Form'; if (!$this->check_submission(true)) { wp_die( __('Comment blocked as spam. Enable JavaScript and try again.', 'smart-honeypot'), __('Spam Detected', 'smart-honeypot'), ['response' => 403, 'back_link' => true] ); } return $commentdata; } /* ── Elementor ─────────────────────────────────────────────────── */ public function validate_elementor_form($record, $ajax_handler) { $this->current_form_type = 'Elementor Form'; if (!$this->check_submission(true)) { $ajax_handler->add_error('honeypot', __('Spam detected. Please try again.', 'smart-honeypot')); } } /* ── Contact Form 7 ────────────────────────────────────────────── */ public function validate_cf7_spam($spam, $submission) { if ($spam) return true; // already flagged $this->current_form_type = 'Contact Form 7'; return !$this->check_submission(true); } /* ── Generic catch-all ─────────────────────────────────────────── */ public function validate_generic_post() { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { return; } if (!isset($_POST[$this->hp_name]) && !isset($_POST[$this->token_name])) { return; } if ( isset($_POST['woocommerce-register-nonce']) || isset($_POST['woocommerce-login-nonce']) || isset($_POST['woocommerce-process-checkout-nonce']) || isset($_POST['comment_post_ID']) || isset($_POST['_wpcf7']) || // CF7: handled by wpcf7_spam filter (isset($_POST['action']) && $_POST['action'] === 'elementor_pro_forms_send_form') || isset($_POST['woocommerce-lost-password-nonce']) // WC lost password: handled by lostpassword_post ) { return; } $this->current_form_type = 'Generic Form'; if (!$this->check_submission(false)) { if (wp_doing_ajax()) { wp_send_json_error(['message' => __('Spam detected.', 'smart-honeypot')]); } wp_safe_redirect(wp_get_referer() ?: home_url()); exit; } } /* ── CSS ───────────────────────────────────────────────────────── */ public function print_css() { echo ''; } /* ── 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(){ function computeToken(ts,secret){ var msg=ts+'|honeypot_js_proof',enc=new TextEncoder(); return crypto.subtle.importKey('raw',enc.encode(secret),{name:'HMAC',hash:'SHA-256'},false,['sign']) .then(function(k){return crypto.subtle.sign('HMAC',k,enc.encode(msg));}) .then(function(s){ var h=Array.from(new Uint8Array(s)).map(function(b){return b.toString(16).padStart(2,'0')}).join(''); return h.substring(0,16); }); } function fillTokens(){ document.querySelectorAll('input[name="{$token_name}"]').forEach(function(inp){ var p=inp.closest('div')||inp.parentElement; var tsInp=p?p.querySelector('input[name="{$time_name}"]'):null; var ts=tsInp?tsInp.value:String(Math.floor(Date.now()/1000)); computeToken(ts,'{$secret}').then(function(h){inp.value=ts+'|'+h;}); }); } if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fillTokens);}else{fillTokens();} new MutationObserver(function(mm){ mm.forEach(function(m){m.addedNodes.forEach(function(n){ if(n.nodeType===1&&n.querySelector&&n.querySelector('input[name="{$token_name}"]'))fillTokens(); });}); }).observe(document.body,{childList:true,subtree:true}); })(); JSBLOCK; } /* ── Admin notice ──────────────────────────────────────────────── */ public function activation_notice() { if (get_transient('smart_honeypot_activated')) { echo '

Honeypot Fields is now active. All forms are protected. View logs →

'; delete_transient('smart_honeypot_activated'); } } } /* ====================================================================== * BOOT * ====================================================================*/ define('HP_PLUGIN_FILE', __FILE__); add_action('plugins_loaded', function () { if ((int) get_option(SmartHoneypotDB::TABLE_VERSION_OPTION) < SmartHoneypotDB::TABLE_VERSION) { SmartHoneypotDB::install(); } new SmartHoneypotAntiSpam(); SmartHoneypotAdmin::register(); // Fallback: flush queue on every request's shutdown (rate-limited via transient) add_action('shutdown', ['SmartHoneypotAPIClient', 'maybe_flush_overdue']); }); // 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'); });