Plugin Directory

Changeset 3481785


Ignore:
Timestamp:
03/13/2026 09:19:28 AM (3 weeks ago)
Author:
jerryscg
Message:

Release version 2.0.8

Location:
vulntitan
Files:
2 added
18 edited
1 copied

Legend:

Unmodified
Added
Removed
  • vulntitan/tags/2.0.8/CHANGELOG.md

    r3481753 r3481785  
    55The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
    66and 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.
    715
    816## [2.0.7] - 2026-03-13
  • vulntitan/tags/2.0.8/assets/js/firewall.js

    r3481425 r3481785  
    1919    const $firewallLockoutMinutes = $('#vulntitan-firewall-lockout-minutes');
    2020    const $firewallLogRetention = $('#vulntitan-firewall-log-retention');
     21    const $firewallWeeklySummaryEmailEnabled = $('#vulntitan-firewall-weekly-summary-email-enabled');
     22    const $firewallWeeklySummaryEmailRecipient = $('#vulntitan-firewall-weekly-summary-email-recipient');
    2123    const $firewallSaveSettings = $('#vulntitan-firewall-save-settings');
    2224    const $firewallRefresh = $('#vulntitan-firewall-refresh');
     
    249251        $firewallLockoutMinutes.val(Number(data.lockout_minutes || 15));
    250252        $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 || ''));
    251255    }
    252256
     
    307311        $firewallLockoutMinutes.prop('disabled', isBusy);
    308312        $firewallLogRetention.prop('disabled', isBusy);
     313        $firewallWeeklySummaryEmailEnabled.prop('disabled', isBusy);
     314        $firewallWeeklySummaryEmailRecipient.prop('disabled', isBusy);
    309315    }
    310316
     
    357363            max_attempts: Number($firewallMaxAttempts.val() || 5),
    358364            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() || '')
    360368        }, function (response) {
    361369            if (!response || !response.success) {
  • vulntitan/tags/2.0.8/assets/js/firewall.min.js

    r3481425 r3481785  
    1919    const $firewallLockoutMinutes = $('#vulntitan-firewall-lockout-minutes');
    2020    const $firewallLogRetention = $('#vulntitan-firewall-log-retention');
     21    const $firewallWeeklySummaryEmailEnabled = $('#vulntitan-firewall-weekly-summary-email-enabled');
     22    const $firewallWeeklySummaryEmailRecipient = $('#vulntitan-firewall-weekly-summary-email-recipient');
    2123    const $firewallSaveSettings = $('#vulntitan-firewall-save-settings');
    2224    const $firewallRefresh = $('#vulntitan-firewall-refresh');
     
    249251        $firewallLockoutMinutes.val(Number(data.lockout_minutes || 15));
    250252        $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 || ''));
    251255    }
    252256
     
    307311        $firewallLockoutMinutes.prop('disabled', isBusy);
    308312        $firewallLogRetention.prop('disabled', isBusy);
     313        $firewallWeeklySummaryEmailEnabled.prop('disabled', isBusy);
     314        $firewallWeeklySummaryEmailRecipient.prop('disabled', isBusy);
    309315    }
    310316
     
    357363            max_attempts: Number($firewallMaxAttempts.val() || 5),
    358364            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() || '')
    360368        }, function (response) {
    361369            if (!response || !response.success) {
  • vulntitan/tags/2.0.8/includes/Admin/Ajax.php

    r3481425 r3481785  
    7373            'lockout_minutes' => isset($_POST['lockout_minutes']) ? (int) wp_unslash($_POST['lockout_minutes']) : 15,
    7474            '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']) : '',
    7577        ];
    7678
  • vulntitan/tags/2.0.8/includes/Admin/Pages/Firewall.php

    r3481425 r3481785  
    201201                                                    </div>
    202202                                                </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>
    203222                                            </section>
    204223                                        </div>
  • vulntitan/tags/2.0.8/includes/Plugin.php

    r3481425 r3481785  
    66use VulnTitan\Services\FirewallService;
    77use VulnTitan\Services\LoginAccessService;
     8use VulnTitan\Services\WeeklySummaryEmailService;
    89
    910class Plugin {
     
    1516    protected const MALWARE_BACKUP_MAX_FILES = 500;
    1617    protected const FIREWALL_LOG_CLEANUP_HOOK = 'vulntitan_cleanup_firewall_logs';
     18    protected const WEEKLY_SUMMARY_EMAIL_HOOK = 'vulntitan_send_weekly_summary_email';
    1719
    1820    public $admin;
     
    3032    public static function activate(): void
    3133    {
     34        add_filter('cron_schedules', [self::class, 'registerCronSchedules']);
    3235        self::schedule_integrity_queue_cleanup();
    3336        self::schedule_malware_backup_cleanup();
    3437        self::schedule_firewall_log_cleanup();
     38        self::schedule_weekly_summary_email();
    3539        FirewallService::createTable();
    3640        FirewallService::installMuLoader();
     
    4246        wp_clear_scheduled_hook(self::MALWARE_BACKUP_CLEANUP_HOOK);
    4347        wp_clear_scheduled_hook(self::FIREWALL_LOG_CLEANUP_HOOK);
     48        wp_clear_scheduled_hook(self::WEEKLY_SUMMARY_EMAIL_HOOK);
    4449        FirewallService::removeMuLoader();
    4550    }
     
    4752    protected function register_scheduled_events(): void
    4853    {
     54        add_filter('cron_schedules', [self::class, 'registerCronSchedules']);
    4955        add_action(self::INTEGRITY_QUEUE_CLEANUP_HOOK, [$this, 'cleanup_integrity_queue_files']);
    5056        add_action(self::MALWARE_BACKUP_CLEANUP_HOOK, [$this, 'cleanup_malware_backup_files']);
    5157        add_action(self::FIREWALL_LOG_CLEANUP_HOOK, [$this, 'cleanup_firewall_logs']);
     58        add_action(self::WEEKLY_SUMMARY_EMAIL_HOOK, [WeeklySummaryEmailService::class, 'sendScheduledDigest']);
    5259        self::schedule_integrity_queue_cleanup();
    5360        self::schedule_malware_backup_cleanup();
    5461        self::schedule_firewall_log_cleanup();
     62        self::schedule_weekly_summary_email();
    5563    }
    5664
     
    8088
    8189        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;
    82115    }
    83116
  • vulntitan/tags/2.0.8/includes/Services/FirewallService.php

    r3481425 r3481785  
    2828            'lockout_minutes' => 15,
    2929            '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', '')),
    3032        ];
    3133    }
     
    9092            'url' => $slug !== '' ? self::getCustomLoginUrl() : '',
    9193        ];
     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;
    92113    }
    93114
     
    441462    }
    442463
     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
    443534    public static function detectClientIp(): string
    444535    {
     
    614705        $defaults = self::getDefaultSettings();
    615706        $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        }
    616712
    617713        return [
     
    625721            'lockout_minutes' => max(5, min(240, (int)($settings['lockout_minutes'] ?? $defaults['lockout_minutes']))),
    626722            '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,
    627725        ];
     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);
    628892    }
    629893
  • vulntitan/tags/2.0.8/readme.txt

    r3481753 r3481785  
    44Tested up to: 6.9
    55Requires PHP: 7.4
    6 Stable tag: 2.0.7
     6Stable tag: 2.0.8
    77License: GPLv2
    88License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    133133== Changelog ==
    134134
     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
    135140= v2.0.7 - 13 Mar, 2026 =
    136141* 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  
    44 * Plugin URI: https://vulntitan.com/vulntitan/
    55 * 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.7
     6 * Version: 2.0.8
    77 * Author: Jaroslav Svetlik
    88 * Author URI: https://vulntitan.com
     
    3030
    3131// Define plugin constants
    32 define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.0.7');
     32define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.0.8');
    3333define('VULNTITAN_PLUGIN_BASENAME', plugin_basename(__FILE__));
    3434define('VULNTITAN_PLUGIN_DIR', untrailingslashit(plugin_dir_path(__FILE__)));
  • vulntitan/trunk/CHANGELOG.md

    r3481753 r3481785  
    55The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
    66and 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.
    715
    816## [2.0.7] - 2026-03-13
  • vulntitan/trunk/assets/js/firewall.js

    r3481425 r3481785  
    1919    const $firewallLockoutMinutes = $('#vulntitan-firewall-lockout-minutes');
    2020    const $firewallLogRetention = $('#vulntitan-firewall-log-retention');
     21    const $firewallWeeklySummaryEmailEnabled = $('#vulntitan-firewall-weekly-summary-email-enabled');
     22    const $firewallWeeklySummaryEmailRecipient = $('#vulntitan-firewall-weekly-summary-email-recipient');
    2123    const $firewallSaveSettings = $('#vulntitan-firewall-save-settings');
    2224    const $firewallRefresh = $('#vulntitan-firewall-refresh');
     
    249251        $firewallLockoutMinutes.val(Number(data.lockout_minutes || 15));
    250252        $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 || ''));
    251255    }
    252256
     
    307311        $firewallLockoutMinutes.prop('disabled', isBusy);
    308312        $firewallLogRetention.prop('disabled', isBusy);
     313        $firewallWeeklySummaryEmailEnabled.prop('disabled', isBusy);
     314        $firewallWeeklySummaryEmailRecipient.prop('disabled', isBusy);
    309315    }
    310316
     
    357363            max_attempts: Number($firewallMaxAttempts.val() || 5),
    358364            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() || '')
    360368        }, function (response) {
    361369            if (!response || !response.success) {
  • vulntitan/trunk/assets/js/firewall.min.js

    r3481425 r3481785  
    1919    const $firewallLockoutMinutes = $('#vulntitan-firewall-lockout-minutes');
    2020    const $firewallLogRetention = $('#vulntitan-firewall-log-retention');
     21    const $firewallWeeklySummaryEmailEnabled = $('#vulntitan-firewall-weekly-summary-email-enabled');
     22    const $firewallWeeklySummaryEmailRecipient = $('#vulntitan-firewall-weekly-summary-email-recipient');
    2123    const $firewallSaveSettings = $('#vulntitan-firewall-save-settings');
    2224    const $firewallRefresh = $('#vulntitan-firewall-refresh');
     
    249251        $firewallLockoutMinutes.val(Number(data.lockout_minutes || 15));
    250252        $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 || ''));
    251255    }
    252256
     
    307311        $firewallLockoutMinutes.prop('disabled', isBusy);
    308312        $firewallLogRetention.prop('disabled', isBusy);
     313        $firewallWeeklySummaryEmailEnabled.prop('disabled', isBusy);
     314        $firewallWeeklySummaryEmailRecipient.prop('disabled', isBusy);
    309315    }
    310316
     
    357363            max_attempts: Number($firewallMaxAttempts.val() || 5),
    358364            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() || '')
    360368        }, function (response) {
    361369            if (!response || !response.success) {
  • vulntitan/trunk/includes/Admin/Ajax.php

    r3481425 r3481785  
    7373            'lockout_minutes' => isset($_POST['lockout_minutes']) ? (int) wp_unslash($_POST['lockout_minutes']) : 15,
    7474            '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']) : '',
    7577        ];
    7678
  • vulntitan/trunk/includes/Admin/Pages/Firewall.php

    r3481425 r3481785  
    201201                                                    </div>
    202202                                                </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>
    203222                                            </section>
    204223                                        </div>
  • vulntitan/trunk/includes/Plugin.php

    r3481425 r3481785  
    66use VulnTitan\Services\FirewallService;
    77use VulnTitan\Services\LoginAccessService;
     8use VulnTitan\Services\WeeklySummaryEmailService;
    89
    910class Plugin {
     
    1516    protected const MALWARE_BACKUP_MAX_FILES = 500;
    1617    protected const FIREWALL_LOG_CLEANUP_HOOK = 'vulntitan_cleanup_firewall_logs';
     18    protected const WEEKLY_SUMMARY_EMAIL_HOOK = 'vulntitan_send_weekly_summary_email';
    1719
    1820    public $admin;
     
    3032    public static function activate(): void
    3133    {
     34        add_filter('cron_schedules', [self::class, 'registerCronSchedules']);
    3235        self::schedule_integrity_queue_cleanup();
    3336        self::schedule_malware_backup_cleanup();
    3437        self::schedule_firewall_log_cleanup();
     38        self::schedule_weekly_summary_email();
    3539        FirewallService::createTable();
    3640        FirewallService::installMuLoader();
     
    4246        wp_clear_scheduled_hook(self::MALWARE_BACKUP_CLEANUP_HOOK);
    4347        wp_clear_scheduled_hook(self::FIREWALL_LOG_CLEANUP_HOOK);
     48        wp_clear_scheduled_hook(self::WEEKLY_SUMMARY_EMAIL_HOOK);
    4449        FirewallService::removeMuLoader();
    4550    }
     
    4752    protected function register_scheduled_events(): void
    4853    {
     54        add_filter('cron_schedules', [self::class, 'registerCronSchedules']);
    4955        add_action(self::INTEGRITY_QUEUE_CLEANUP_HOOK, [$this, 'cleanup_integrity_queue_files']);
    5056        add_action(self::MALWARE_BACKUP_CLEANUP_HOOK, [$this, 'cleanup_malware_backup_files']);
    5157        add_action(self::FIREWALL_LOG_CLEANUP_HOOK, [$this, 'cleanup_firewall_logs']);
     58        add_action(self::WEEKLY_SUMMARY_EMAIL_HOOK, [WeeklySummaryEmailService::class, 'sendScheduledDigest']);
    5259        self::schedule_integrity_queue_cleanup();
    5360        self::schedule_malware_backup_cleanup();
    5461        self::schedule_firewall_log_cleanup();
     62        self::schedule_weekly_summary_email();
    5563    }
    5664
     
    8088
    8189        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;
    82115    }
    83116
  • vulntitan/trunk/includes/Services/FirewallService.php

    r3481425 r3481785  
    2828            'lockout_minutes' => 15,
    2929            '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', '')),
    3032        ];
    3133    }
     
    9092            'url' => $slug !== '' ? self::getCustomLoginUrl() : '',
    9193        ];
     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;
    92113    }
    93114
     
    441462    }
    442463
     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
    443534    public static function detectClientIp(): string
    444535    {
     
    614705        $defaults = self::getDefaultSettings();
    615706        $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        }
    616712
    617713        return [
     
    625721            'lockout_minutes' => max(5, min(240, (int)($settings['lockout_minutes'] ?? $defaults['lockout_minutes']))),
    626722            '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,
    627725        ];
     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);
    628892    }
    629893
  • vulntitan/trunk/readme.txt

    r3481753 r3481785  
    44Tested up to: 6.9
    55Requires PHP: 7.4
    6 Stable tag: 2.0.7
     6Stable tag: 2.0.8
    77License: GPLv2
    88License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    133133== Changelog ==
    134134
     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
    135140= v2.0.7 - 13 Mar, 2026 =
    136141* 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  
    44 * Plugin URI: https://vulntitan.com/vulntitan/
    55 * 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.7
     6 * Version: 2.0.8
    77 * Author: Jaroslav Svetlik
    88 * Author URI: https://vulntitan.com
     
    3030
    3131// Define plugin constants
    32 define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.0.7');
     32define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.0.8');
    3333define('VULNTITAN_PLUGIN_BASENAME', plugin_basename(__FILE__));
    3434define('VULNTITAN_PLUGIN_DIR', untrailingslashit(plugin_dir_path(__FILE__)));
Note: See TracChangeset for help on using the changeset viewer.