Changeset 3481785
- Timestamp:
- 03/13/2026 09:19:28 AM (3 weeks ago)
- Location:
- vulntitan
- Files:
-
- 2 added
- 18 edited
- 1 copied
-
tags/2.0.8 (copied) (copied from vulntitan/trunk)
-
tags/2.0.8/CHANGELOG.md (modified) (1 diff)
-
tags/2.0.8/assets/js/firewall.js (modified) (4 diffs)
-
tags/2.0.8/assets/js/firewall.min.js (modified) (4 diffs)
-
tags/2.0.8/includes/Admin/Ajax.php (modified) (1 diff)
-
tags/2.0.8/includes/Admin/Pages/Firewall.php (modified) (1 diff)
-
tags/2.0.8/includes/Plugin.php (modified) (6 diffs)
-
tags/2.0.8/includes/Services/FirewallService.php (modified) (5 diffs)
-
tags/2.0.8/includes/Services/WeeklySummaryEmailService.php (added)
-
tags/2.0.8/readme.txt (modified) (2 diffs)
-
tags/2.0.8/vulntitan.php (modified) (2 diffs)
-
trunk/CHANGELOG.md (modified) (1 diff)
-
trunk/assets/js/firewall.js (modified) (4 diffs)
-
trunk/assets/js/firewall.min.js (modified) (4 diffs)
-
trunk/includes/Admin/Ajax.php (modified) (1 diff)
-
trunk/includes/Admin/Pages/Firewall.php (modified) (1 diff)
-
trunk/includes/Plugin.php (modified) (6 diffs)
-
trunk/includes/Services/FirewallService.php (modified) (5 diffs)
-
trunk/includes/Services/WeeklySummaryEmailService.php (added)
-
trunk/readme.txt (modified) (2 diffs)
-
trunk/vulntitan.php (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
vulntitan/tags/2.0.8/CHANGELOG.md
r3481753 r3481785 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 8 ## [2.0.8] - 2026-03-13 9 ### Added 10 - Added a weekly executive security digest email for administrators with 7-day firewall telemetry, login abuse summaries, WAF detections, and top targeted paths/rules. 11 12 ### Changed 13 - Added digest delivery controls to the Firewall settings so administrators can enable the weekly report and override the recipient email address. 14 - Upgraded the weekly digest into a fully branded HTML email template with VulnTitan logo, metric cards, activity timeline, and protection profile summary. 7 15 8 16 ## [2.0.7] - 2026-03-13 -
vulntitan/tags/2.0.8/assets/js/firewall.js
r3481425 r3481785 19 19 const $firewallLockoutMinutes = $('#vulntitan-firewall-lockout-minutes'); 20 20 const $firewallLogRetention = $('#vulntitan-firewall-log-retention'); 21 const $firewallWeeklySummaryEmailEnabled = $('#vulntitan-firewall-weekly-summary-email-enabled'); 22 const $firewallWeeklySummaryEmailRecipient = $('#vulntitan-firewall-weekly-summary-email-recipient'); 21 23 const $firewallSaveSettings = $('#vulntitan-firewall-save-settings'); 22 24 const $firewallRefresh = $('#vulntitan-firewall-refresh'); … … 249 251 $firewallLockoutMinutes.val(Number(data.lockout_minutes || 15)); 250 252 $firewallLogRetention.val(Number(data.log_retention_days || 30)); 253 $firewallWeeklySummaryEmailEnabled.prop('checked', !!Number(data.weekly_summary_email_enabled || 0)); 254 $firewallWeeklySummaryEmailRecipient.val(String(data.weekly_summary_email_recipient || '')); 251 255 } 252 256 … … 307 311 $firewallLockoutMinutes.prop('disabled', isBusy); 308 312 $firewallLogRetention.prop('disabled', isBusy); 313 $firewallWeeklySummaryEmailEnabled.prop('disabled', isBusy); 314 $firewallWeeklySummaryEmailRecipient.prop('disabled', isBusy); 309 315 } 310 316 … … 357 363 max_attempts: Number($firewallMaxAttempts.val() || 5), 358 364 lockout_minutes: Number($firewallLockoutMinutes.val() || 15), 359 log_retention_days: Number($firewallLogRetention.val() || 30) 365 log_retention_days: Number($firewallLogRetention.val() || 30), 366 weekly_summary_email_enabled: $firewallWeeklySummaryEmailEnabled.is(':checked') ? 1 : 0, 367 weekly_summary_email_recipient: String($firewallWeeklySummaryEmailRecipient.val() || '') 360 368 }, function (response) { 361 369 if (!response || !response.success) { -
vulntitan/tags/2.0.8/assets/js/firewall.min.js
r3481425 r3481785 19 19 const $firewallLockoutMinutes = $('#vulntitan-firewall-lockout-minutes'); 20 20 const $firewallLogRetention = $('#vulntitan-firewall-log-retention'); 21 const $firewallWeeklySummaryEmailEnabled = $('#vulntitan-firewall-weekly-summary-email-enabled'); 22 const $firewallWeeklySummaryEmailRecipient = $('#vulntitan-firewall-weekly-summary-email-recipient'); 21 23 const $firewallSaveSettings = $('#vulntitan-firewall-save-settings'); 22 24 const $firewallRefresh = $('#vulntitan-firewall-refresh'); … … 249 251 $firewallLockoutMinutes.val(Number(data.lockout_minutes || 15)); 250 252 $firewallLogRetention.val(Number(data.log_retention_days || 30)); 253 $firewallWeeklySummaryEmailEnabled.prop('checked', !!Number(data.weekly_summary_email_enabled || 0)); 254 $firewallWeeklySummaryEmailRecipient.val(String(data.weekly_summary_email_recipient || '')); 251 255 } 252 256 … … 307 311 $firewallLockoutMinutes.prop('disabled', isBusy); 308 312 $firewallLogRetention.prop('disabled', isBusy); 313 $firewallWeeklySummaryEmailEnabled.prop('disabled', isBusy); 314 $firewallWeeklySummaryEmailRecipient.prop('disabled', isBusy); 309 315 } 310 316 … … 357 363 max_attempts: Number($firewallMaxAttempts.val() || 5), 358 364 lockout_minutes: Number($firewallLockoutMinutes.val() || 15), 359 log_retention_days: Number($firewallLogRetention.val() || 30) 365 log_retention_days: Number($firewallLogRetention.val() || 30), 366 weekly_summary_email_enabled: $firewallWeeklySummaryEmailEnabled.is(':checked') ? 1 : 0, 367 weekly_summary_email_recipient: String($firewallWeeklySummaryEmailRecipient.val() || '') 360 368 }, function (response) { 361 369 if (!response || !response.success) { -
vulntitan/tags/2.0.8/includes/Admin/Ajax.php
r3481425 r3481785 73 73 'lockout_minutes' => isset($_POST['lockout_minutes']) ? (int) wp_unslash($_POST['lockout_minutes']) : 15, 74 74 'log_retention_days' => isset($_POST['log_retention_days']) ? (int) wp_unslash($_POST['log_retention_days']) : 30, 75 'weekly_summary_email_enabled' => isset($_POST['weekly_summary_email_enabled']) ? (int) wp_unslash($_POST['weekly_summary_email_enabled']) : 1, 76 'weekly_summary_email_recipient' => isset($_POST['weekly_summary_email_recipient']) ? (string) wp_unslash($_POST['weekly_summary_email_recipient']) : '', 75 77 ]; 76 78 -
vulntitan/tags/2.0.8/includes/Admin/Pages/Firewall.php
r3481425 r3481785 201 201 </div> 202 202 </div> 203 204 <div class="vulntitan-firewall-section"> 205 <div class="vulntitan-firewall-section-title"><?php esc_html_e('Executive Weekly Digest', 'vulntitan'); ?></div> 206 <div class="vulntitan-firewall-section-desc"><?php esc_html_e('Send a polished HTML security summary to the main administrator with the previous 7 complete days of blocked events, login abuse, and WAF detections.', 'vulntitan'); ?></div> 207 208 <label class="vulntitan-firewall-toggle"> 209 <input type="checkbox" id="vulntitan-firewall-weekly-summary-email-enabled" class="vulntitan-firewall-checkbox" checked> 210 <span><?php esc_html_e('Enable Weekly Security Email', 'vulntitan'); ?></span> 211 </label> 212 213 <div class="vulntitan-firewall-field-grid"> 214 <label class="vulntitan-firewall-field"> 215 <span class="vulntitan-firewall-field-label"><?php esc_html_e('Recipient Email', 'vulntitan'); ?></span> 216 <input type="email" id="vulntitan-firewall-weekly-summary-email-recipient" class="vulntitan-firewall-input" placeholder="admin@example.com" autocomplete="email" spellcheck="false"> 217 <small class="vulntitan-firewall-field-help"><?php esc_html_e('Defaults to the primary WordPress administrator email when left blank or invalid.', 'vulntitan'); ?></small> 218 <small class="vulntitan-firewall-field-help"><?php esc_html_e('Digest is scheduled weekly and reports on the previous 7 complete days.', 'vulntitan'); ?></small> 219 </label> 220 </div> 221 </div> 203 222 </section> 204 223 </div> -
vulntitan/tags/2.0.8/includes/Plugin.php
r3481425 r3481785 6 6 use VulnTitan\Services\FirewallService; 7 7 use VulnTitan\Services\LoginAccessService; 8 use VulnTitan\Services\WeeklySummaryEmailService; 8 9 9 10 class Plugin { … … 15 16 protected const MALWARE_BACKUP_MAX_FILES = 500; 16 17 protected const FIREWALL_LOG_CLEANUP_HOOK = 'vulntitan_cleanup_firewall_logs'; 18 protected const WEEKLY_SUMMARY_EMAIL_HOOK = 'vulntitan_send_weekly_summary_email'; 17 19 18 20 public $admin; … … 30 32 public static function activate(): void 31 33 { 34 add_filter('cron_schedules', [self::class, 'registerCronSchedules']); 32 35 self::schedule_integrity_queue_cleanup(); 33 36 self::schedule_malware_backup_cleanup(); 34 37 self::schedule_firewall_log_cleanup(); 38 self::schedule_weekly_summary_email(); 35 39 FirewallService::createTable(); 36 40 FirewallService::installMuLoader(); … … 42 46 wp_clear_scheduled_hook(self::MALWARE_BACKUP_CLEANUP_HOOK); 43 47 wp_clear_scheduled_hook(self::FIREWALL_LOG_CLEANUP_HOOK); 48 wp_clear_scheduled_hook(self::WEEKLY_SUMMARY_EMAIL_HOOK); 44 49 FirewallService::removeMuLoader(); 45 50 } … … 47 52 protected function register_scheduled_events(): void 48 53 { 54 add_filter('cron_schedules', [self::class, 'registerCronSchedules']); 49 55 add_action(self::INTEGRITY_QUEUE_CLEANUP_HOOK, [$this, 'cleanup_integrity_queue_files']); 50 56 add_action(self::MALWARE_BACKUP_CLEANUP_HOOK, [$this, 'cleanup_malware_backup_files']); 51 57 add_action(self::FIREWALL_LOG_CLEANUP_HOOK, [$this, 'cleanup_firewall_logs']); 58 add_action(self::WEEKLY_SUMMARY_EMAIL_HOOK, [WeeklySummaryEmailService::class, 'sendScheduledDigest']); 52 59 self::schedule_integrity_queue_cleanup(); 53 60 self::schedule_malware_backup_cleanup(); 54 61 self::schedule_firewall_log_cleanup(); 62 self::schedule_weekly_summary_email(); 55 63 } 56 64 … … 80 88 81 89 wp_schedule_event(time() + (3 * HOUR_IN_SECONDS), 'daily', self::FIREWALL_LOG_CLEANUP_HOOK); 90 } 91 92 protected static function schedule_weekly_summary_email(): void 93 { 94 if (wp_next_scheduled(self::WEEKLY_SUMMARY_EMAIL_HOOK)) { 95 return; 96 } 97 98 wp_schedule_event( 99 WeeklySummaryEmailService::getNextScheduleTimestamp(), 100 'vulntitan_weekly', 101 self::WEEKLY_SUMMARY_EMAIL_HOOK 102 ); 103 } 104 105 public static function registerCronSchedules(array $schedules): array 106 { 107 if (!isset($schedules['vulntitan_weekly'])) { 108 $schedules['vulntitan_weekly'] = [ 109 'interval' => 7 * DAY_IN_SECONDS, 110 'display' => __('Once Weekly (VulnTitan)', 'vulntitan'), 111 ]; 112 } 113 114 return $schedules; 82 115 } 83 116 -
vulntitan/tags/2.0.8/includes/Services/FirewallService.php
r3481425 r3481785 28 28 'lockout_minutes' => 15, 29 29 'log_retention_days' => self::DEFAULT_LOG_RETENTION_DAYS, 30 'weekly_summary_email_enabled' => 1, 31 'weekly_summary_email_recipient' => sanitize_email((string) get_option('admin_email', '')), 30 32 ]; 31 33 } … … 90 92 'url' => $slug !== '' ? self::getCustomLoginUrl() : '', 91 93 ]; 94 } 95 96 public static function isWeeklySummaryEmailEnabled(): bool 97 { 98 $settings = self::getSettings(); 99 100 return !empty($settings['weekly_summary_email_enabled']); 101 } 102 103 public static function getWeeklySummaryEmailRecipient(): string 104 { 105 $settings = self::getSettings(); 106 $recipient = sanitize_email((string) ($settings['weekly_summary_email_recipient'] ?? '')); 107 108 if ($recipient === '') { 109 $recipient = sanitize_email((string) get_option('admin_email', '')); 110 } 111 112 return $recipient; 92 113 } 93 114 … … 441 462 } 442 463 464 public static function getWeeklyDigestData(int $days = 7): array 465 { 466 $windowDays = max(1, min(30, $days)); 467 $timezone = self::getWpTimezone(); 468 $todayStartLocal = (new \DateTimeImmutable('now', $timezone))->setTime(0, 0, 0); 469 $periodEndLocal = $todayStartLocal; 470 $periodStartLocal = $periodEndLocal->modify('-' . $windowDays . ' days'); 471 $periodStartUtc = $periodStartLocal->setTimezone(new \DateTimeZone('UTC')); 472 $periodEndUtc = $periodEndLocal->setTimezone(new \DateTimeZone('UTC')); 473 474 $data = [ 475 'window_days' => $windowDays, 476 'period' => [ 477 'key' => $periodStartLocal->format('Ymd') . '-' . $periodEndLocal->format('Ymd'), 478 'start_gmt' => $periodStartUtc->format('Y-m-d H:i:s'), 479 'end_gmt' => $periodEndUtc->format('Y-m-d H:i:s'), 480 'start_local' => $periodStartLocal->format('Y-m-d H:i:s'), 481 'end_local' => $periodEndLocal->format('Y-m-d H:i:s'), 482 'start_local_display' => self::formatLocalTimestamp($periodStartLocal->getTimestamp(), 'M j, Y'), 483 'end_local_display' => self::formatLocalTimestamp($periodEndLocal->modify('-1 second')->getTimestamp(), 'M j, Y'), 484 ], 485 'totals' => self::emptyDigestCounts(), 486 'daily' => [], 487 'top_paths' => [], 488 'top_rules' => [], 489 ]; 490 491 for ($index = 0; $index < $windowDays; $index++) { 492 $dayStartLocal = $periodStartLocal->modify('+' . $index . ' days'); 493 494 $data['daily'][] = [ 495 'date' => $dayStartLocal->format('Y-m-d'), 496 'label' => self::formatLocalTimestamp($dayStartLocal->getTimestamp(), 'D, M j'), 497 'counts' => self::emptyDigestCounts(), 498 ]; 499 } 500 501 if (!self::ensureTable()) { 502 return $data; 503 } 504 505 $data['totals'] = self::queryDigestCountsForWindow( 506 $periodStartUtc->format('Y-m-d H:i:s'), 507 $periodEndUtc->format('Y-m-d H:i:s') 508 ); 509 510 foreach ($data['daily'] as $index => $day) { 511 $dayStartLocal = (new \DateTimeImmutable($day['date'] . ' 00:00:00', $timezone)); 512 $dayEndLocal = $dayStartLocal->modify('+1 day'); 513 $dayStartUtc = $dayStartLocal->setTimezone(new \DateTimeZone('UTC')); 514 $dayEndUtc = $dayEndLocal->setTimezone(new \DateTimeZone('UTC')); 515 516 $data['daily'][$index]['counts'] = self::queryDigestCountsForWindow( 517 $dayStartUtc->format('Y-m-d H:i:s'), 518 $dayEndUtc->format('Y-m-d H:i:s') 519 ); 520 } 521 522 $data['top_paths'] = self::queryTopBlockedPaths( 523 $periodStartUtc->format('Y-m-d H:i:s'), 524 $periodEndUtc->format('Y-m-d H:i:s') 525 ); 526 $data['top_rules'] = self::queryTopBlockedRules( 527 $periodStartUtc->format('Y-m-d H:i:s'), 528 $periodEndUtc->format('Y-m-d H:i:s') 529 ); 530 531 return $data; 532 } 533 443 534 public static function detectClientIp(): string 444 535 { … … 614 705 $defaults = self::getDefaultSettings(); 615 706 $wafWhitelistPaths = self::sanitizeWhitelistPaths($settings['waf_whitelist_paths'] ?? $defaults['waf_whitelist_paths']); 707 $weeklySummaryRecipient = sanitize_email((string) ($settings['weekly_summary_email_recipient'] ?? $defaults['weekly_summary_email_recipient'])); 708 709 if ($weeklySummaryRecipient === '') { 710 $weeklySummaryRecipient = sanitize_email((string) $defaults['weekly_summary_email_recipient']); 711 } 616 712 617 713 return [ … … 625 721 'lockout_minutes' => max(5, min(240, (int)($settings['lockout_minutes'] ?? $defaults['lockout_minutes']))), 626 722 'log_retention_days' => max(1, min(365, (int)($settings['log_retention_days'] ?? $defaults['log_retention_days']))), 723 'weekly_summary_email_enabled' => !empty($settings['weekly_summary_email_enabled']) ? 1 : 0, 724 'weekly_summary_email_recipient' => $weeklySummaryRecipient, 627 725 ]; 726 } 727 728 protected static function emptyDigestCounts(): array 729 { 730 return [ 731 'total_events' => 0, 732 'blocked' => 0, 733 'login_failed' => 0, 734 'login_blocked' => 0, 735 'login_lockout' => 0, 736 'login_recovered' => 0, 737 'sqli' => 0, 738 'command_injection' => 0, 739 'path_traversal' => 0, 740 'sensitive_file_probe' => 0, 741 'unique_attackers' => 0, 742 ]; 743 } 744 745 protected static function queryDigestCountsForWindow(string $startGmt, string $endGmt): array 746 { 747 global $wpdb; 748 749 $tableName = self::getTableName(); 750 $blockedCondition = self::getBlockedSqlCondition(); 751 752 $row = $wpdb->get_row( 753 $wpdb->prepare( 754 "SELECT 755 COUNT(*) AS total_events, 756 SUM(CASE WHEN {$blockedCondition} THEN 1 ELSE 0 END) AS blocked, 757 SUM(CASE WHEN event_type = 'login_failed' THEN 1 ELSE 0 END) AS login_failed, 758 SUM(CASE WHEN event_type = 'login_blocked' THEN 1 ELSE 0 END) AS login_blocked, 759 SUM(CASE WHEN event_type = 'login_lockout' THEN 1 ELSE 0 END) AS login_lockout, 760 SUM(CASE WHEN event_type = 'login_recovered' THEN 1 ELSE 0 END) AS login_recovered, 761 SUM(CASE WHEN event_type = 'request_blocked' AND rule_group = 'waf_sqli' THEN 1 ELSE 0 END) AS sqli, 762 SUM(CASE WHEN event_type = 'request_blocked' AND rule_group = 'waf_command_injection' THEN 1 ELSE 0 END) AS command_injection, 763 SUM(CASE WHEN event_type = 'request_blocked' AND rule_id = 'path_traversal' THEN 1 ELSE 0 END) AS path_traversal, 764 SUM(CASE WHEN event_type = 'request_blocked' AND rule_id = 'sensitive_file_probe' THEN 1 ELSE 0 END) AS sensitive_file_probe, 765 COUNT(DISTINCT CASE WHEN {$blockedCondition} AND ip_hash <> '' THEN ip_hash ELSE NULL END) AS unique_attackers 766 FROM {$tableName} 767 WHERE created_at >= %s AND created_at < %s", 768 $startGmt, 769 $endGmt 770 ), 771 ARRAY_A 772 ); 773 774 $defaults = self::emptyDigestCounts(); 775 776 if (!is_array($row)) { 777 return $defaults; 778 } 779 780 foreach ($defaults as $key => $value) { 781 $defaults[$key] = (int) ($row[$key] ?? 0); 782 } 783 784 return $defaults; 785 } 786 787 protected static function queryTopBlockedPaths(string $startGmt, string $endGmt, int $limit = 5): array 788 { 789 global $wpdb; 790 791 $tableName = self::getTableName(); 792 $safeLimit = max(1, min(10, $limit)); 793 $blockedCondition = self::getBlockedSqlCondition(); 794 795 $rows = $wpdb->get_results( 796 $wpdb->prepare( 797 "SELECT request_path, COUNT(*) AS hits 798 FROM {$tableName} 799 WHERE created_at >= %s 800 AND created_at < %s 801 AND {$blockedCondition} 802 AND request_path <> '' 803 GROUP BY request_path 804 ORDER BY hits DESC, request_path ASC 805 LIMIT {$safeLimit}", 806 $startGmt, 807 $endGmt 808 ), 809 ARRAY_A 810 ); 811 812 if (!is_array($rows)) { 813 return []; 814 } 815 816 return array_map(static function (array $row): array { 817 return [ 818 'path' => (string) ($row['request_path'] ?? ''), 819 'hits' => (int) ($row['hits'] ?? 0), 820 ]; 821 }, $rows); 822 } 823 824 protected static function queryTopBlockedRules(string $startGmt, string $endGmt, int $limit = 5): array 825 { 826 global $wpdb; 827 828 $tableName = self::getTableName(); 829 $safeLimit = max(1, min(10, $limit)); 830 831 $rows = $wpdb->get_results( 832 $wpdb->prepare( 833 "SELECT rule_group, rule_id, matched_pattern, COUNT(*) AS hits 834 FROM {$tableName} 835 WHERE created_at >= %s 836 AND created_at < %s 837 AND event_type = 'request_blocked' 838 AND (rule_group <> '' OR rule_id <> '' OR matched_pattern <> '') 839 GROUP BY rule_group, rule_id, matched_pattern 840 ORDER BY hits DESC, rule_group ASC, rule_id ASC 841 LIMIT {$safeLimit}", 842 $startGmt, 843 $endGmt 844 ), 845 ARRAY_A 846 ); 847 848 if (!is_array($rows)) { 849 return []; 850 } 851 852 return array_map(static function (array $row): array { 853 return [ 854 'rule_group' => (string) ($row['rule_group'] ?? ''), 855 'rule_id' => (string) ($row['rule_id'] ?? ''), 856 'matched_pattern' => (string) ($row['matched_pattern'] ?? ''), 857 'hits' => (int) ($row['hits'] ?? 0), 858 ]; 859 }, $rows); 860 } 861 862 protected static function getBlockedSqlCondition(): string 863 { 864 return "(blocked = 1 OR event_type IN ('request_blocked', 'login_blocked', 'login_lockout'))"; 865 } 866 867 protected static function getWpTimezone(): \DateTimeZone 868 { 869 if (function_exists('wp_timezone')) { 870 return wp_timezone(); 871 } 872 873 $timezoneString = (string) get_option('timezone_string', 'UTC'); 874 if ($timezoneString !== '') { 875 try { 876 return new \DateTimeZone($timezoneString); 877 } catch (\Exception $exception) { 878 // Fall through to UTC. 879 } 880 } 881 882 return new \DateTimeZone('UTC'); 883 } 884 885 protected static function formatLocalTimestamp(int $timestamp, string $format): string 886 { 887 if (function_exists('wp_date')) { 888 return wp_date($format, $timestamp, self::getWpTimezone()); 889 } 890 891 return date_i18n($format, $timestamp, false); 628 892 } 629 893 -
vulntitan/tags/2.0.8/readme.txt
r3481753 r3481785 4 4 Tested up to: 6.9 5 5 Requires PHP: 7.4 6 Stable tag: 2.0. 76 Stable tag: 2.0.8 7 7 License: GPLv2 8 8 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 133 133 == Changelog == 134 134 135 = v2.0.8 - 13 Mar, 2026 = 136 * Added a weekly executive security digest email with 7-day firewall telemetry, login abuse summaries, WAF detections, and top targeted paths/rules. 137 * Added Firewall settings for enabling the weekly digest and overriding the recipient email address. 138 * Upgraded the digest into a professional branded HTML email template with VulnTitan logo, metric cards, timeline, and protection profile summary. 139 135 140 = v2.0.7 - 13 Mar, 2026 = 136 141 * Fixed custom login logout requests on some Nginx-backed WordPress sites so hidden login logout no longer triggers `502 Bad Gateway` responses. -
vulntitan/tags/2.0.8/vulntitan.php
r3481753 r3481785 4 4 * Plugin URI: https://vulntitan.com/vulntitan/ 5 5 * Description: VulnTitan is a lightweight WordPress security plugin with vulnerability scanning, malware detection, file integrity monitoring, and a built-in firewall with WAF payload rules and login protection. 6 * Version: 2.0. 76 * Version: 2.0.8 7 7 * Author: Jaroslav Svetlik 8 8 * Author URI: https://vulntitan.com … … 30 30 31 31 // Define plugin constants 32 define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.0. 7');32 define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.0.8'); 33 33 define('VULNTITAN_PLUGIN_BASENAME', plugin_basename(__FILE__)); 34 34 define('VULNTITAN_PLUGIN_DIR', untrailingslashit(plugin_dir_path(__FILE__))); -
vulntitan/trunk/CHANGELOG.md
r3481753 r3481785 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 8 ## [2.0.8] - 2026-03-13 9 ### Added 10 - Added a weekly executive security digest email for administrators with 7-day firewall telemetry, login abuse summaries, WAF detections, and top targeted paths/rules. 11 12 ### Changed 13 - Added digest delivery controls to the Firewall settings so administrators can enable the weekly report and override the recipient email address. 14 - Upgraded the weekly digest into a fully branded HTML email template with VulnTitan logo, metric cards, activity timeline, and protection profile summary. 7 15 8 16 ## [2.0.7] - 2026-03-13 -
vulntitan/trunk/assets/js/firewall.js
r3481425 r3481785 19 19 const $firewallLockoutMinutes = $('#vulntitan-firewall-lockout-minutes'); 20 20 const $firewallLogRetention = $('#vulntitan-firewall-log-retention'); 21 const $firewallWeeklySummaryEmailEnabled = $('#vulntitan-firewall-weekly-summary-email-enabled'); 22 const $firewallWeeklySummaryEmailRecipient = $('#vulntitan-firewall-weekly-summary-email-recipient'); 21 23 const $firewallSaveSettings = $('#vulntitan-firewall-save-settings'); 22 24 const $firewallRefresh = $('#vulntitan-firewall-refresh'); … … 249 251 $firewallLockoutMinutes.val(Number(data.lockout_minutes || 15)); 250 252 $firewallLogRetention.val(Number(data.log_retention_days || 30)); 253 $firewallWeeklySummaryEmailEnabled.prop('checked', !!Number(data.weekly_summary_email_enabled || 0)); 254 $firewallWeeklySummaryEmailRecipient.val(String(data.weekly_summary_email_recipient || '')); 251 255 } 252 256 … … 307 311 $firewallLockoutMinutes.prop('disabled', isBusy); 308 312 $firewallLogRetention.prop('disabled', isBusy); 313 $firewallWeeklySummaryEmailEnabled.prop('disabled', isBusy); 314 $firewallWeeklySummaryEmailRecipient.prop('disabled', isBusy); 309 315 } 310 316 … … 357 363 max_attempts: Number($firewallMaxAttempts.val() || 5), 358 364 lockout_minutes: Number($firewallLockoutMinutes.val() || 15), 359 log_retention_days: Number($firewallLogRetention.val() || 30) 365 log_retention_days: Number($firewallLogRetention.val() || 30), 366 weekly_summary_email_enabled: $firewallWeeklySummaryEmailEnabled.is(':checked') ? 1 : 0, 367 weekly_summary_email_recipient: String($firewallWeeklySummaryEmailRecipient.val() || '') 360 368 }, function (response) { 361 369 if (!response || !response.success) { -
vulntitan/trunk/assets/js/firewall.min.js
r3481425 r3481785 19 19 const $firewallLockoutMinutes = $('#vulntitan-firewall-lockout-minutes'); 20 20 const $firewallLogRetention = $('#vulntitan-firewall-log-retention'); 21 const $firewallWeeklySummaryEmailEnabled = $('#vulntitan-firewall-weekly-summary-email-enabled'); 22 const $firewallWeeklySummaryEmailRecipient = $('#vulntitan-firewall-weekly-summary-email-recipient'); 21 23 const $firewallSaveSettings = $('#vulntitan-firewall-save-settings'); 22 24 const $firewallRefresh = $('#vulntitan-firewall-refresh'); … … 249 251 $firewallLockoutMinutes.val(Number(data.lockout_minutes || 15)); 250 252 $firewallLogRetention.val(Number(data.log_retention_days || 30)); 253 $firewallWeeklySummaryEmailEnabled.prop('checked', !!Number(data.weekly_summary_email_enabled || 0)); 254 $firewallWeeklySummaryEmailRecipient.val(String(data.weekly_summary_email_recipient || '')); 251 255 } 252 256 … … 307 311 $firewallLockoutMinutes.prop('disabled', isBusy); 308 312 $firewallLogRetention.prop('disabled', isBusy); 313 $firewallWeeklySummaryEmailEnabled.prop('disabled', isBusy); 314 $firewallWeeklySummaryEmailRecipient.prop('disabled', isBusy); 309 315 } 310 316 … … 357 363 max_attempts: Number($firewallMaxAttempts.val() || 5), 358 364 lockout_minutes: Number($firewallLockoutMinutes.val() || 15), 359 log_retention_days: Number($firewallLogRetention.val() || 30) 365 log_retention_days: Number($firewallLogRetention.val() || 30), 366 weekly_summary_email_enabled: $firewallWeeklySummaryEmailEnabled.is(':checked') ? 1 : 0, 367 weekly_summary_email_recipient: String($firewallWeeklySummaryEmailRecipient.val() || '') 360 368 }, function (response) { 361 369 if (!response || !response.success) { -
vulntitan/trunk/includes/Admin/Ajax.php
r3481425 r3481785 73 73 'lockout_minutes' => isset($_POST['lockout_minutes']) ? (int) wp_unslash($_POST['lockout_minutes']) : 15, 74 74 'log_retention_days' => isset($_POST['log_retention_days']) ? (int) wp_unslash($_POST['log_retention_days']) : 30, 75 'weekly_summary_email_enabled' => isset($_POST['weekly_summary_email_enabled']) ? (int) wp_unslash($_POST['weekly_summary_email_enabled']) : 1, 76 'weekly_summary_email_recipient' => isset($_POST['weekly_summary_email_recipient']) ? (string) wp_unslash($_POST['weekly_summary_email_recipient']) : '', 75 77 ]; 76 78 -
vulntitan/trunk/includes/Admin/Pages/Firewall.php
r3481425 r3481785 201 201 </div> 202 202 </div> 203 204 <div class="vulntitan-firewall-section"> 205 <div class="vulntitan-firewall-section-title"><?php esc_html_e('Executive Weekly Digest', 'vulntitan'); ?></div> 206 <div class="vulntitan-firewall-section-desc"><?php esc_html_e('Send a polished HTML security summary to the main administrator with the previous 7 complete days of blocked events, login abuse, and WAF detections.', 'vulntitan'); ?></div> 207 208 <label class="vulntitan-firewall-toggle"> 209 <input type="checkbox" id="vulntitan-firewall-weekly-summary-email-enabled" class="vulntitan-firewall-checkbox" checked> 210 <span><?php esc_html_e('Enable Weekly Security Email', 'vulntitan'); ?></span> 211 </label> 212 213 <div class="vulntitan-firewall-field-grid"> 214 <label class="vulntitan-firewall-field"> 215 <span class="vulntitan-firewall-field-label"><?php esc_html_e('Recipient Email', 'vulntitan'); ?></span> 216 <input type="email" id="vulntitan-firewall-weekly-summary-email-recipient" class="vulntitan-firewall-input" placeholder="admin@example.com" autocomplete="email" spellcheck="false"> 217 <small class="vulntitan-firewall-field-help"><?php esc_html_e('Defaults to the primary WordPress administrator email when left blank or invalid.', 'vulntitan'); ?></small> 218 <small class="vulntitan-firewall-field-help"><?php esc_html_e('Digest is scheduled weekly and reports on the previous 7 complete days.', 'vulntitan'); ?></small> 219 </label> 220 </div> 221 </div> 203 222 </section> 204 223 </div> -
vulntitan/trunk/includes/Plugin.php
r3481425 r3481785 6 6 use VulnTitan\Services\FirewallService; 7 7 use VulnTitan\Services\LoginAccessService; 8 use VulnTitan\Services\WeeklySummaryEmailService; 8 9 9 10 class Plugin { … … 15 16 protected const MALWARE_BACKUP_MAX_FILES = 500; 16 17 protected const FIREWALL_LOG_CLEANUP_HOOK = 'vulntitan_cleanup_firewall_logs'; 18 protected const WEEKLY_SUMMARY_EMAIL_HOOK = 'vulntitan_send_weekly_summary_email'; 17 19 18 20 public $admin; … … 30 32 public static function activate(): void 31 33 { 34 add_filter('cron_schedules', [self::class, 'registerCronSchedules']); 32 35 self::schedule_integrity_queue_cleanup(); 33 36 self::schedule_malware_backup_cleanup(); 34 37 self::schedule_firewall_log_cleanup(); 38 self::schedule_weekly_summary_email(); 35 39 FirewallService::createTable(); 36 40 FirewallService::installMuLoader(); … … 42 46 wp_clear_scheduled_hook(self::MALWARE_BACKUP_CLEANUP_HOOK); 43 47 wp_clear_scheduled_hook(self::FIREWALL_LOG_CLEANUP_HOOK); 48 wp_clear_scheduled_hook(self::WEEKLY_SUMMARY_EMAIL_HOOK); 44 49 FirewallService::removeMuLoader(); 45 50 } … … 47 52 protected function register_scheduled_events(): void 48 53 { 54 add_filter('cron_schedules', [self::class, 'registerCronSchedules']); 49 55 add_action(self::INTEGRITY_QUEUE_CLEANUP_HOOK, [$this, 'cleanup_integrity_queue_files']); 50 56 add_action(self::MALWARE_BACKUP_CLEANUP_HOOK, [$this, 'cleanup_malware_backup_files']); 51 57 add_action(self::FIREWALL_LOG_CLEANUP_HOOK, [$this, 'cleanup_firewall_logs']); 58 add_action(self::WEEKLY_SUMMARY_EMAIL_HOOK, [WeeklySummaryEmailService::class, 'sendScheduledDigest']); 52 59 self::schedule_integrity_queue_cleanup(); 53 60 self::schedule_malware_backup_cleanup(); 54 61 self::schedule_firewall_log_cleanup(); 62 self::schedule_weekly_summary_email(); 55 63 } 56 64 … … 80 88 81 89 wp_schedule_event(time() + (3 * HOUR_IN_SECONDS), 'daily', self::FIREWALL_LOG_CLEANUP_HOOK); 90 } 91 92 protected static function schedule_weekly_summary_email(): void 93 { 94 if (wp_next_scheduled(self::WEEKLY_SUMMARY_EMAIL_HOOK)) { 95 return; 96 } 97 98 wp_schedule_event( 99 WeeklySummaryEmailService::getNextScheduleTimestamp(), 100 'vulntitan_weekly', 101 self::WEEKLY_SUMMARY_EMAIL_HOOK 102 ); 103 } 104 105 public static function registerCronSchedules(array $schedules): array 106 { 107 if (!isset($schedules['vulntitan_weekly'])) { 108 $schedules['vulntitan_weekly'] = [ 109 'interval' => 7 * DAY_IN_SECONDS, 110 'display' => __('Once Weekly (VulnTitan)', 'vulntitan'), 111 ]; 112 } 113 114 return $schedules; 82 115 } 83 116 -
vulntitan/trunk/includes/Services/FirewallService.php
r3481425 r3481785 28 28 'lockout_minutes' => 15, 29 29 'log_retention_days' => self::DEFAULT_LOG_RETENTION_DAYS, 30 'weekly_summary_email_enabled' => 1, 31 'weekly_summary_email_recipient' => sanitize_email((string) get_option('admin_email', '')), 30 32 ]; 31 33 } … … 90 92 'url' => $slug !== '' ? self::getCustomLoginUrl() : '', 91 93 ]; 94 } 95 96 public static function isWeeklySummaryEmailEnabled(): bool 97 { 98 $settings = self::getSettings(); 99 100 return !empty($settings['weekly_summary_email_enabled']); 101 } 102 103 public static function getWeeklySummaryEmailRecipient(): string 104 { 105 $settings = self::getSettings(); 106 $recipient = sanitize_email((string) ($settings['weekly_summary_email_recipient'] ?? '')); 107 108 if ($recipient === '') { 109 $recipient = sanitize_email((string) get_option('admin_email', '')); 110 } 111 112 return $recipient; 92 113 } 93 114 … … 441 462 } 442 463 464 public static function getWeeklyDigestData(int $days = 7): array 465 { 466 $windowDays = max(1, min(30, $days)); 467 $timezone = self::getWpTimezone(); 468 $todayStartLocal = (new \DateTimeImmutable('now', $timezone))->setTime(0, 0, 0); 469 $periodEndLocal = $todayStartLocal; 470 $periodStartLocal = $periodEndLocal->modify('-' . $windowDays . ' days'); 471 $periodStartUtc = $periodStartLocal->setTimezone(new \DateTimeZone('UTC')); 472 $periodEndUtc = $periodEndLocal->setTimezone(new \DateTimeZone('UTC')); 473 474 $data = [ 475 'window_days' => $windowDays, 476 'period' => [ 477 'key' => $periodStartLocal->format('Ymd') . '-' . $periodEndLocal->format('Ymd'), 478 'start_gmt' => $periodStartUtc->format('Y-m-d H:i:s'), 479 'end_gmt' => $periodEndUtc->format('Y-m-d H:i:s'), 480 'start_local' => $periodStartLocal->format('Y-m-d H:i:s'), 481 'end_local' => $periodEndLocal->format('Y-m-d H:i:s'), 482 'start_local_display' => self::formatLocalTimestamp($periodStartLocal->getTimestamp(), 'M j, Y'), 483 'end_local_display' => self::formatLocalTimestamp($periodEndLocal->modify('-1 second')->getTimestamp(), 'M j, Y'), 484 ], 485 'totals' => self::emptyDigestCounts(), 486 'daily' => [], 487 'top_paths' => [], 488 'top_rules' => [], 489 ]; 490 491 for ($index = 0; $index < $windowDays; $index++) { 492 $dayStartLocal = $periodStartLocal->modify('+' . $index . ' days'); 493 494 $data['daily'][] = [ 495 'date' => $dayStartLocal->format('Y-m-d'), 496 'label' => self::formatLocalTimestamp($dayStartLocal->getTimestamp(), 'D, M j'), 497 'counts' => self::emptyDigestCounts(), 498 ]; 499 } 500 501 if (!self::ensureTable()) { 502 return $data; 503 } 504 505 $data['totals'] = self::queryDigestCountsForWindow( 506 $periodStartUtc->format('Y-m-d H:i:s'), 507 $periodEndUtc->format('Y-m-d H:i:s') 508 ); 509 510 foreach ($data['daily'] as $index => $day) { 511 $dayStartLocal = (new \DateTimeImmutable($day['date'] . ' 00:00:00', $timezone)); 512 $dayEndLocal = $dayStartLocal->modify('+1 day'); 513 $dayStartUtc = $dayStartLocal->setTimezone(new \DateTimeZone('UTC')); 514 $dayEndUtc = $dayEndLocal->setTimezone(new \DateTimeZone('UTC')); 515 516 $data['daily'][$index]['counts'] = self::queryDigestCountsForWindow( 517 $dayStartUtc->format('Y-m-d H:i:s'), 518 $dayEndUtc->format('Y-m-d H:i:s') 519 ); 520 } 521 522 $data['top_paths'] = self::queryTopBlockedPaths( 523 $periodStartUtc->format('Y-m-d H:i:s'), 524 $periodEndUtc->format('Y-m-d H:i:s') 525 ); 526 $data['top_rules'] = self::queryTopBlockedRules( 527 $periodStartUtc->format('Y-m-d H:i:s'), 528 $periodEndUtc->format('Y-m-d H:i:s') 529 ); 530 531 return $data; 532 } 533 443 534 public static function detectClientIp(): string 444 535 { … … 614 705 $defaults = self::getDefaultSettings(); 615 706 $wafWhitelistPaths = self::sanitizeWhitelistPaths($settings['waf_whitelist_paths'] ?? $defaults['waf_whitelist_paths']); 707 $weeklySummaryRecipient = sanitize_email((string) ($settings['weekly_summary_email_recipient'] ?? $defaults['weekly_summary_email_recipient'])); 708 709 if ($weeklySummaryRecipient === '') { 710 $weeklySummaryRecipient = sanitize_email((string) $defaults['weekly_summary_email_recipient']); 711 } 616 712 617 713 return [ … … 625 721 'lockout_minutes' => max(5, min(240, (int)($settings['lockout_minutes'] ?? $defaults['lockout_minutes']))), 626 722 'log_retention_days' => max(1, min(365, (int)($settings['log_retention_days'] ?? $defaults['log_retention_days']))), 723 'weekly_summary_email_enabled' => !empty($settings['weekly_summary_email_enabled']) ? 1 : 0, 724 'weekly_summary_email_recipient' => $weeklySummaryRecipient, 627 725 ]; 726 } 727 728 protected static function emptyDigestCounts(): array 729 { 730 return [ 731 'total_events' => 0, 732 'blocked' => 0, 733 'login_failed' => 0, 734 'login_blocked' => 0, 735 'login_lockout' => 0, 736 'login_recovered' => 0, 737 'sqli' => 0, 738 'command_injection' => 0, 739 'path_traversal' => 0, 740 'sensitive_file_probe' => 0, 741 'unique_attackers' => 0, 742 ]; 743 } 744 745 protected static function queryDigestCountsForWindow(string $startGmt, string $endGmt): array 746 { 747 global $wpdb; 748 749 $tableName = self::getTableName(); 750 $blockedCondition = self::getBlockedSqlCondition(); 751 752 $row = $wpdb->get_row( 753 $wpdb->prepare( 754 "SELECT 755 COUNT(*) AS total_events, 756 SUM(CASE WHEN {$blockedCondition} THEN 1 ELSE 0 END) AS blocked, 757 SUM(CASE WHEN event_type = 'login_failed' THEN 1 ELSE 0 END) AS login_failed, 758 SUM(CASE WHEN event_type = 'login_blocked' THEN 1 ELSE 0 END) AS login_blocked, 759 SUM(CASE WHEN event_type = 'login_lockout' THEN 1 ELSE 0 END) AS login_lockout, 760 SUM(CASE WHEN event_type = 'login_recovered' THEN 1 ELSE 0 END) AS login_recovered, 761 SUM(CASE WHEN event_type = 'request_blocked' AND rule_group = 'waf_sqli' THEN 1 ELSE 0 END) AS sqli, 762 SUM(CASE WHEN event_type = 'request_blocked' AND rule_group = 'waf_command_injection' THEN 1 ELSE 0 END) AS command_injection, 763 SUM(CASE WHEN event_type = 'request_blocked' AND rule_id = 'path_traversal' THEN 1 ELSE 0 END) AS path_traversal, 764 SUM(CASE WHEN event_type = 'request_blocked' AND rule_id = 'sensitive_file_probe' THEN 1 ELSE 0 END) AS sensitive_file_probe, 765 COUNT(DISTINCT CASE WHEN {$blockedCondition} AND ip_hash <> '' THEN ip_hash ELSE NULL END) AS unique_attackers 766 FROM {$tableName} 767 WHERE created_at >= %s AND created_at < %s", 768 $startGmt, 769 $endGmt 770 ), 771 ARRAY_A 772 ); 773 774 $defaults = self::emptyDigestCounts(); 775 776 if (!is_array($row)) { 777 return $defaults; 778 } 779 780 foreach ($defaults as $key => $value) { 781 $defaults[$key] = (int) ($row[$key] ?? 0); 782 } 783 784 return $defaults; 785 } 786 787 protected static function queryTopBlockedPaths(string $startGmt, string $endGmt, int $limit = 5): array 788 { 789 global $wpdb; 790 791 $tableName = self::getTableName(); 792 $safeLimit = max(1, min(10, $limit)); 793 $blockedCondition = self::getBlockedSqlCondition(); 794 795 $rows = $wpdb->get_results( 796 $wpdb->prepare( 797 "SELECT request_path, COUNT(*) AS hits 798 FROM {$tableName} 799 WHERE created_at >= %s 800 AND created_at < %s 801 AND {$blockedCondition} 802 AND request_path <> '' 803 GROUP BY request_path 804 ORDER BY hits DESC, request_path ASC 805 LIMIT {$safeLimit}", 806 $startGmt, 807 $endGmt 808 ), 809 ARRAY_A 810 ); 811 812 if (!is_array($rows)) { 813 return []; 814 } 815 816 return array_map(static function (array $row): array { 817 return [ 818 'path' => (string) ($row['request_path'] ?? ''), 819 'hits' => (int) ($row['hits'] ?? 0), 820 ]; 821 }, $rows); 822 } 823 824 protected static function queryTopBlockedRules(string $startGmt, string $endGmt, int $limit = 5): array 825 { 826 global $wpdb; 827 828 $tableName = self::getTableName(); 829 $safeLimit = max(1, min(10, $limit)); 830 831 $rows = $wpdb->get_results( 832 $wpdb->prepare( 833 "SELECT rule_group, rule_id, matched_pattern, COUNT(*) AS hits 834 FROM {$tableName} 835 WHERE created_at >= %s 836 AND created_at < %s 837 AND event_type = 'request_blocked' 838 AND (rule_group <> '' OR rule_id <> '' OR matched_pattern <> '') 839 GROUP BY rule_group, rule_id, matched_pattern 840 ORDER BY hits DESC, rule_group ASC, rule_id ASC 841 LIMIT {$safeLimit}", 842 $startGmt, 843 $endGmt 844 ), 845 ARRAY_A 846 ); 847 848 if (!is_array($rows)) { 849 return []; 850 } 851 852 return array_map(static function (array $row): array { 853 return [ 854 'rule_group' => (string) ($row['rule_group'] ?? ''), 855 'rule_id' => (string) ($row['rule_id'] ?? ''), 856 'matched_pattern' => (string) ($row['matched_pattern'] ?? ''), 857 'hits' => (int) ($row['hits'] ?? 0), 858 ]; 859 }, $rows); 860 } 861 862 protected static function getBlockedSqlCondition(): string 863 { 864 return "(blocked = 1 OR event_type IN ('request_blocked', 'login_blocked', 'login_lockout'))"; 865 } 866 867 protected static function getWpTimezone(): \DateTimeZone 868 { 869 if (function_exists('wp_timezone')) { 870 return wp_timezone(); 871 } 872 873 $timezoneString = (string) get_option('timezone_string', 'UTC'); 874 if ($timezoneString !== '') { 875 try { 876 return new \DateTimeZone($timezoneString); 877 } catch (\Exception $exception) { 878 // Fall through to UTC. 879 } 880 } 881 882 return new \DateTimeZone('UTC'); 883 } 884 885 protected static function formatLocalTimestamp(int $timestamp, string $format): string 886 { 887 if (function_exists('wp_date')) { 888 return wp_date($format, $timestamp, self::getWpTimezone()); 889 } 890 891 return date_i18n($format, $timestamp, false); 628 892 } 629 893 -
vulntitan/trunk/readme.txt
r3481753 r3481785 4 4 Tested up to: 6.9 5 5 Requires PHP: 7.4 6 Stable tag: 2.0. 76 Stable tag: 2.0.8 7 7 License: GPLv2 8 8 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 133 133 == Changelog == 134 134 135 = v2.0.8 - 13 Mar, 2026 = 136 * Added a weekly executive security digest email with 7-day firewall telemetry, login abuse summaries, WAF detections, and top targeted paths/rules. 137 * Added Firewall settings for enabling the weekly digest and overriding the recipient email address. 138 * Upgraded the digest into a professional branded HTML email template with VulnTitan logo, metric cards, timeline, and protection profile summary. 139 135 140 = v2.0.7 - 13 Mar, 2026 = 136 141 * Fixed custom login logout requests on some Nginx-backed WordPress sites so hidden login logout no longer triggers `502 Bad Gateway` responses. -
vulntitan/trunk/vulntitan.php
r3481753 r3481785 4 4 * Plugin URI: https://vulntitan.com/vulntitan/ 5 5 * Description: VulnTitan is a lightweight WordPress security plugin with vulnerability scanning, malware detection, file integrity monitoring, and a built-in firewall with WAF payload rules and login protection. 6 * Version: 2.0. 76 * Version: 2.0.8 7 7 * Author: Jaroslav Svetlik 8 8 * Author URI: https://vulntitan.com … … 30 30 31 31 // Define plugin constants 32 define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.0. 7');32 define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.0.8'); 33 33 define('VULNTITAN_PLUGIN_BASENAME', plugin_basename(__FILE__)); 34 34 define('VULNTITAN_PLUGIN_DIR', untrailingslashit(plugin_dir_path(__FILE__)));
Note: See TracChangeset
for help on using the changeset viewer.