Plugin Directory

Changeset 3490735


Ignore:
Timestamp:
03/25/2026 10:25:03 AM (9 days ago)
Author:
jerryscg
Message:

Release 2.1.16 with tighter comment moderation and expanded weekly digest

Location:
vulntitan
Files:
18 edited
1 copied

Legend:

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

    r3486040 r3490735  
    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.1.16] - 2026-03-25
     9### Added
     10- Tightened Comment Shield spam detection with casino, betting, gambling, promotional-link, repeated-domain, and thin-link heuristics for guest comments.
     11- Logged suspicious comment holds and WordPress moderation-queue entries into the firewall activity stream.
     12- Expanded the weekly executive security digest with form spam, comment queue, and broader protection-profile coverage.
     13
     14### Changed
     15- Reworked the HTML weekly digest layout so cramped two-column sections collapse into a readable mobile presentation.
    716
    817## [2.1.15] - 2026-03-18
  • vulntitan/tags/2.1.16/assets/js/firewall.js

    r3486040 r3490735  
    241241            case 'comment_spam_held':
    242242                return i18n.firewall_event_comment_spam_held || 'Comment held for review';
     243            case 'comment_pending_review':
     244                return i18n.firewall_event_comment_pending_review || 'Comment entered pending review';
    243245            case 'comment_rate_limited':
    244246                return i18n.firewall_event_comment_rate_limited || 'Comment rate limited';
     
    257259                return 'is-danger';
    258260            case 'comment_spam_held':
     261            case 'comment_pending_review':
    259262            case 'login_failed':
    260263                return 'is-warning';
  • vulntitan/tags/2.1.16/assets/js/firewall.min.js

    r3486040 r3490735  
    241241            case 'comment_spam_held':
    242242                return i18n.firewall_event_comment_spam_held || 'Comment held for review';
     243            case 'comment_pending_review':
     244                return i18n.firewall_event_comment_pending_review || 'Comment entered pending review';
    243245            case 'comment_rate_limited':
    244246                return i18n.firewall_event_comment_rate_limited || 'Comment rate limited';
     
    257259                return 'is-danger';
    258260            case 'comment_spam_held':
     261            case 'comment_pending_review':
    259262            case 'login_failed':
    260263                return 'is-warning';
  • vulntitan/tags/2.1.16/includes/Admin/Admin.php

    r3485911 r3490735  
    138138                    'firewall_event_comment_spam_blocked' => esc_html__('Comment spam blocked', 'vulntitan'),
    139139                    'firewall_event_comment_spam_held' => esc_html__('Comment held for review', 'vulntitan'),
     140                    'firewall_event_comment_pending_review' => esc_html__('Comment entered pending review', 'vulntitan'),
    140141                    'firewall_event_comment_rate_limited' => esc_html__('Comment rate limited', 'vulntitan'),
    141142                    'firewall_feed_live' => esc_html__('Live', 'vulntitan'),
  • vulntitan/tags/2.1.16/includes/Services/CommentSpamService.php

    r3485911 r3490735  
    1212    protected const RATE_LIMIT_PREFIX = 'vulntitan_cs_rate_';
    1313    protected const MIN_TOKEN_AGE = 1;
     14    protected const SHORT_COMMENT_MAX_WORDS = 3;
     15    protected const SHORT_COMMENT_MAX_LENGTH = 40;
    1416
    1517    protected static bool $booted = false;
     
    9294
    9395        if (($decision['action'] ?? 'allow') === 'allow') {
     96            if (self::isEnabled() && self::supportsCommentType($commentData) && !self::shouldBypass($commentData) && ($approved === 0 || $approved === '0')) {
     97                self::logPendingModeration($commentData);
     98            }
     99
    94100            return $approved;
    95101        }
     
    131137        $commentContent = isset($commentData['comment_content']) ? (string) wp_unslash($commentData['comment_content']) : '';
    132138        $normalizedContent = self::normalizeCommentContent($commentContent);
     139        $authorName = isset($commentData['comment_author']) ? self::normalizeCommentContent((string) wp_unslash($commentData['comment_author'])) : '';
     140        $authorUrl = isset($commentData['comment_author_url']) ? trim((string) wp_unslash($commentData['comment_author_url'])) : '';
    133141        $postId = (int) ($commentData['comment_post_ID'] ?? 0);
    134142        $ipAddress = self::resolveIpAddress($commentData);
     
    137145        $rateCount = self::incrementRateCounter($visitorKey, $rateWindowMinutes);
    138146        $maxAttempts = max(1, (int) ($settings['submission_rate_limit_attempts'] ?? $settings['comment_rate_limit_attempts'] ?? 3));
     147        $linkCount = self::countLinks($commentContent);
     148        $authorDomain = self::normalizeDomain($authorUrl);
     149        $externalDomains = self::extractExternalDomains($commentContent, $authorDomain);
     150        [$topExternalDomain, $topExternalHits] = self::findTopDomain($externalDomains);
     151        $spamKeywordMatches = self::matchSpamKeywords(implode("\n", array_filter([$normalizedContent, $authorName, self::normalizeCommentContent($authorUrl)])));
     152        $promotionalPhraseMatches = self::matchPromotionalPhrases($normalizedContent);
     153        $wordCount = self::countWords($normalizedContent);
     154        $contentLength = self::measureLength($normalizedContent);
    139155
    140156        if ($rateCount > $maxAttempts) {
     
    154170                [
    155171                    'comment_hash' => self::buildCommentHash($normalizedContent),
    156                     'link_count' => self::countLinks($commentContent),
     172                    'link_count' => $linkCount,
    157173                ]
    158174            );
     
    239255
    240256        if (!self::isAuthenticatedCommenter($commentData)) {
    241             $linkCount = self::countLinks($commentContent);
    242257            $maxLinks = max(0, (int) ($settings['submission_max_links'] ?? $settings['comment_max_links'] ?? 2));
     258
     259            if ($spamKeywordMatches !== [] && $externalDomains !== []) {
     260                return self::buildDecisionFromSuspiciousAction(
     261                    (string) ($settings['comment_suspicious_action'] ?? 'hold'),
     262                    'spam_keywords_with_external_url',
     263                    __('Comment contains common spam keywords together with an external URL or website.', 'vulntitan'),
     264                    [
     265                        'post_id' => $postId,
     266                        'rate_count' => $rateCount,
     267                        'window_minutes' => $rateWindowMinutes,
     268                        'link_count' => $linkCount,
     269                        'author_url_domain' => $authorDomain,
     270                        'matched_keywords' => $spamKeywordMatches,
     271                    ],
     272                    [
     273                        'comment_hash' => self::buildCommentHash($normalizedContent),
     274                        'link_count' => $linkCount,
     275                        'external_domains' => array_values(array_unique($externalDomains)),
     276                    ]
     277                );
     278            }
     279
     280            if (count($spamKeywordMatches) >= 2) {
     281                return self::buildDecisionFromSuspiciousAction(
     282                    (string) ($settings['comment_suspicious_action'] ?? 'hold'),
     283                    'spam_keyword_cluster',
     284                    __('Comment contains multiple high-risk spam keyword categories.', 'vulntitan'),
     285                    [
     286                        'post_id' => $postId,
     287                        'rate_count' => $rateCount,
     288                        'window_minutes' => $rateWindowMinutes,
     289                        'matched_keywords' => $spamKeywordMatches,
     290                    ],
     291                    [
     292                        'comment_hash' => self::buildCommentHash($normalizedContent),
     293                        'matched_keywords' => $spamKeywordMatches,
     294                    ]
     295                );
     296            }
     297
     298            if ($topExternalDomain !== '' && $topExternalHits >= 2) {
     299                return self::buildDecisionFromSuspiciousAction(
     300                    (string) ($settings['comment_suspicious_action'] ?? 'hold'),
     301                    'repeated_external_domain',
     302                    __('Comment repeats the same external domain across the message and author website fields.', 'vulntitan'),
     303                    [
     304                        'post_id' => $postId,
     305                        'rate_count' => $rateCount,
     306                        'window_minutes' => $rateWindowMinutes,
     307                        'top_external_domain' => $topExternalDomain,
     308                        'top_external_domain_hits' => $topExternalHits,
     309                    ],
     310                    [
     311                        'comment_hash' => self::buildCommentHash($normalizedContent),
     312                        'link_count' => $linkCount,
     313                        'external_domains' => array_values(array_unique($externalDomains)),
     314                        'top_external_domain' => $topExternalDomain,
     315                        'top_external_domain_hits' => $topExternalHits,
     316                    ]
     317                );
     318            }
     319
     320            if ($promotionalPhraseMatches !== [] && $externalDomains !== []) {
     321                return self::buildDecisionFromSuspiciousAction(
     322                    (string) ($settings['comment_suspicious_action'] ?? 'hold'),
     323                    'promotional_link_comment',
     324                    __('Comment combines promotional phrases with an external link or website.', 'vulntitan'),
     325                    [
     326                        'post_id' => $postId,
     327                        'rate_count' => $rateCount,
     328                        'window_minutes' => $rateWindowMinutes,
     329                        'matched_promotions' => $promotionalPhraseMatches,
     330                        'link_count' => $linkCount,
     331                    ],
     332                    [
     333                        'comment_hash' => self::buildCommentHash($normalizedContent),
     334                        'external_domains' => array_values(array_unique($externalDomains)),
     335                        'matched_promotions' => $promotionalPhraseMatches,
     336                    ]
     337                );
     338            }
     339
     340            if ($externalDomains !== [] && $wordCount > 0 && $wordCount <= self::SHORT_COMMENT_MAX_WORDS && $contentLength <= self::SHORT_COMMENT_MAX_LENGTH) {
     341                return self::buildDecisionFromSuspiciousAction(
     342                    (string) ($settings['comment_suspicious_action'] ?? 'hold'),
     343                    'thin_external_link_comment',
     344                    __('Very short guest comment submitted with an external link or website.', 'vulntitan'),
     345                    [
     346                        'post_id' => $postId,
     347                        'rate_count' => $rateCount,
     348                        'window_minutes' => $rateWindowMinutes,
     349                        'word_count' => $wordCount,
     350                        'content_length' => $contentLength,
     351                        'author_url_domain' => $authorDomain,
     352                    ],
     353                    [
     354                        'comment_hash' => self::buildCommentHash($normalizedContent),
     355                        'link_count' => $linkCount,
     356                        'external_domains' => array_values(array_unique($externalDomains)),
     357                    ]
     358                );
     359            }
    243360
    244361            if ($linkCount > $maxLinks) {
     
    311428    ): array {
    312429        $details['action'] = $action;
     430
     431        if ($action === 'hold') {
     432            $details['approval_status'] = 'pending';
     433        }
    313434
    314435        return [
     
    352473    }
    353474
     475    protected static function logPendingModeration(array $commentData): void
     476    {
     477        $commentContent = isset($commentData['comment_content']) ? (string) wp_unslash($commentData['comment_content']) : '';
     478        $normalizedContent = self::normalizeCommentContent($commentContent);
     479        $authorUrl = isset($commentData['comment_author_url']) ? trim((string) wp_unslash($commentData['comment_author_url'])) : '';
     480        $authorDomain = self::normalizeDomain($authorUrl);
     481        $externalDomains = self::extractExternalDomains($commentContent, $authorDomain);
     482
     483        FirewallService::logEvent('comment_pending_review', [
     484            'event_source' => 'comment_shield',
     485            'event_action' => 'log',
     486            'blocked' => 0,
     487            'response_code' => 0,
     488            'severity' => 1,
     489            'ip_address' => self::resolveIpAddress($commentData),
     490            'username' => self::resolveUsername($commentData),
     491            'request_method' => strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'POST')),
     492            'request_uri' => (string) ($_SERVER['REQUEST_URI'] ?? ''),
     493            'request_path' => (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH),
     494            'rule_group' => 'comment_moderation',
     495            'rule_id' => 'wordpress_pending_review',
     496            'reason' => __('Comment entered the moderation queue.', 'vulntitan'),
     497            'details' => [
     498                'action' => 'hold',
     499                'approval_status' => 'pending',
     500                'post_id' => (int) ($commentData['comment_post_ID'] ?? 0),
     501                'link_count' => self::countLinks($commentContent),
     502                'author_url_domain' => $authorDomain,
     503            ],
     504            'context' => [
     505                'comment_hash' => self::buildCommentHash($normalizedContent),
     506                'external_domains' => array_values(array_unique($externalDomains)),
     507            ],
     508        ]);
     509    }
     510
    354511    protected static function isEnabled(): bool
    355512    {
     
    479636
    480637        return is_array($matches[0] ?? null) ? count($matches[0]) : 0;
     638    }
     639
     640    /**
     641     * @return array<int,string>
     642     */
     643    protected static function extractExternalDomains(string $content, string $authorDomain = ''): array
     644    {
     645        $domains = self::extractLinkDomains($content);
     646
     647        if ($authorDomain !== '') {
     648            $domains[] = $authorDomain;
     649        }
     650
     651        return array_values(array_filter($domains, [__CLASS__, 'isExternalDomain']));
     652    }
     653
     654    /**
     655     * @return array<int,string>
     656     */
     657    protected static function extractLinkDomains(string $content): array
     658    {
     659        if ($content === '') {
     660            return [];
     661        }
     662
     663        preg_match_all('~(?:https?://|www\.)[^\s<>"\']+~iu', $content, $matches);
     664        $rawUrls = is_array($matches[0] ?? null) ? $matches[0] : [];
     665        $domains = [];
     666
     667        foreach ($rawUrls as $rawUrl) {
     668            if (!is_string($rawUrl)) {
     669                continue;
     670            }
     671
     672            $domain = self::normalizeDomain($rawUrl);
     673            if ($domain !== '') {
     674                $domains[] = $domain;
     675            }
     676        }
     677
     678        return $domains;
     679    }
     680
     681    protected static function normalizeDomain(string $value): string
     682    {
     683        $value = trim($value);
     684        if ($value === '') {
     685            return '';
     686        }
     687
     688        if (strpos($value, '://') === false) {
     689            $value = 'https://' . ltrim($value, '/');
     690        }
     691
     692        $host = wp_parse_url($value, PHP_URL_HOST);
     693        if (!is_string($host) || $host === '') {
     694            return '';
     695        }
     696
     697        $host = strtolower(trim($host));
     698
     699        return strpos($host, 'www.') === 0 ? substr($host, 4) : $host;
     700    }
     701
     702    protected static function isExternalDomain(string $domain): bool
     703    {
     704        $normalizedDomain = self::normalizeDomain($domain);
     705        if ($normalizedDomain === '') {
     706            return false;
     707        }
     708
     709        $siteDomain = self::normalizeDomain((string) home_url('/'));
     710        if ($siteDomain === '') {
     711            return true;
     712        }
     713
     714        return $normalizedDomain !== $siteDomain
     715            && substr($normalizedDomain, -strlen('.' . $siteDomain)) !== '.' . $siteDomain;
     716    }
     717
     718    /**
     719     * @param array<int,string> $domains
     720     * @return array{0:string,1:int}
     721     */
     722    protected static function findTopDomain(array $domains): array
     723    {
     724        if ($domains === []) {
     725            return ['', 0];
     726        }
     727
     728        $counts = array_count_values(array_filter($domains, 'is_string'));
     729        if ($counts === []) {
     730            return ['', 0];
     731        }
     732
     733        arsort($counts);
     734        $topDomain = (string) array_key_first($counts);
     735
     736        return [$topDomain, (int) ($counts[$topDomain] ?? 0)];
     737    }
     738
     739    /**
     740     * @return array<int,string>
     741     */
     742    protected static function matchSpamKeywords(string $content): array
     743    {
     744        if ($content === '') {
     745            return [];
     746        }
     747
     748        $matches = [];
     749
     750        foreach (self::getSpamKeywordPatterns() as $label => $pattern) {
     751            if (!is_string($pattern) || @preg_match($pattern, '') === false) {
     752                continue;
     753            }
     754
     755            if (preg_match($pattern, $content)) {
     756                $matches[] = (string) $label;
     757            }
     758        }
     759
     760        return $matches;
     761    }
     762
     763    /**
     764     * @return array<int,string>
     765     */
     766    protected static function getSpamKeywordPatterns(): array
     767    {
     768        $patterns = [
     769            'casino' => '/\bcasino(?:s)?\b/iu',
     770            'betting' => '/\b(?:bet(?:s|ting)?|sportsbook|bookmaker(?:s)?)\b/iu',
     771            'gambling' => '/\b(?:gambl(?:e|ing)|wager(?:s|ing)?)\b/iu',
     772            'slots' => '/\b(?:slot(?:s)?|jackpot(?:s)?|roulette|blackjack|baccarat|poker|free\s*spin(?:s)?)\b/iu',
     773            'pharma' => '/\b(?:viagra|cialis|levitra|pharmacy|pill(?:s)?)\b/iu',
     774            'adult' => '/\b(?:adult|porn|xxx|escort(?:s)?)\b/iu',
     775            'loans' => '/\b(?:loan(?:s)?|payday\s*loan(?:s)?|cash\s*advance|debt\s*relief)\b/iu',
     776            'crypto' => '/\b(?:crypto|bitcoin|forex|trading\s*signal(?:s)?)\b/iu',
     777            'seo' => '/\b(?:seo(?:\s+services?)?|backlink(?:s)?|guest\s+post(?:ing)?|domain\s+authority)\b/iu',
     778        ];
     779
     780        $filtered = apply_filters('vulntitan_comment_spam_keyword_patterns', $patterns);
     781
     782        return is_array($filtered) ? $filtered : $patterns;
     783    }
     784
     785    /**
     786     * @return array<int,string>
     787     */
     788    protected static function matchPromotionalPhrases(string $content): array
     789    {
     790        if ($content === '') {
     791            return [];
     792        }
     793
     794        $matches = [];
     795
     796        foreach (self::getPromotionalPhrasePatterns() as $label => $pattern) {
     797            if (!is_string($pattern) || @preg_match($pattern, '') === false) {
     798                continue;
     799            }
     800
     801            if (preg_match($pattern, $content)) {
     802                $matches[] = (string) $label;
     803            }
     804        }
     805
     806        return $matches;
     807    }
     808
     809    /**
     810     * @return array<int|string,string>
     811     */
     812    protected static function getPromotionalPhrasePatterns(): array
     813    {
     814        $patterns = [
     815            'check_it_out' => '/\bcheck\s+(?:it|this|them)\s+out\b/iu',
     816            'go_to_spot' => '/\bgo-to\s+spot\b/iu',
     817            'quick_bets' => '/\bquick\s+bets?\b/iu',
     818            'best_odds' => '/\bbest\s+odds\b/iu',
     819            'join_now' => '/\bjoin\s+now\b/iu',
     820            'visit_site' => '/\bvisit\s+(?:my|our|this)\s+(?:site|website)\b/iu',
     821        ];
     822
     823        $filtered = apply_filters('vulntitan_comment_promotional_phrase_patterns', $patterns);
     824
     825        return is_array($filtered) ? $filtered : $patterns;
     826    }
     827
     828    protected static function countWords(string $content): int
     829    {
     830        if ($content === '') {
     831            return 0;
     832        }
     833
     834        preg_match_all('/[\p{L}\p{N}]+(?:[\'’-][\p{L}\p{N}]+)*/u', $content, $matches);
     835
     836        return is_array($matches[0] ?? null) ? count($matches[0]) : 0;
     837    }
     838
     839    protected static function measureLength(string $content): int
     840    {
     841        if ($content === '') {
     842            return 0;
     843        }
     844
     845        return function_exists('mb_strlen')
     846            ? (int) mb_strlen($content, 'UTF-8')
     847            : strlen($content);
    481848    }
    482849
  • vulntitan/tags/2.1.16/includes/Services/FirewallService.php

    r3485911 r3490735  
    16981698            'total_events' => 0,
    16991699            'blocked' => 0,
     1700            'forms_blocked' => 0,
    17001701            'login_failed' => 0,
    17011702            'login_blocked' => 0,
     
    17081709            'comment_spam_blocked' => 0,
    17091710            'comment_spam_held' => 0,
     1711            'comment_pending_review' => 0,
    17101712            'comment_rate_limited' => 0,
    17111713            'unique_attackers' => 0,
     
    17251727                    COUNT(*) AS total_events,
    17261728                    SUM(CASE WHEN {$blockedCondition} THEN 1 ELSE 0 END) AS blocked,
     1729                    SUM(CASE WHEN event_type = 'request_blocked' AND event_source IN ('contact_form_7', 'fluent_forms') THEN 1 ELSE 0 END) AS forms_blocked,
    17271730                    SUM(CASE WHEN event_type = 'login_failed' THEN 1 ELSE 0 END) AS login_failed,
    17281731                    SUM(CASE WHEN event_type = 'login_blocked' THEN 1 ELSE 0 END) AS login_blocked,
     
    17351738                    SUM(CASE WHEN event_type = 'comment_spam_blocked' THEN 1 ELSE 0 END) AS comment_spam_blocked,
    17361739                    SUM(CASE WHEN event_type = 'comment_spam_held' THEN 1 ELSE 0 END) AS comment_spam_held,
     1740                    SUM(CASE WHEN event_type = 'comment_pending_review' THEN 1 ELSE 0 END) AS comment_pending_review,
    17371741                    SUM(CASE WHEN event_type = 'comment_rate_limited' THEN 1 ELSE 0 END) AS comment_rate_limited,
    17381742                    COUNT(DISTINCT CASE WHEN {$blockedCondition} AND ip_hash <> '' THEN ip_hash ELSE NULL END) AS unique_attackers
  • vulntitan/tags/2.1.16/includes/Services/WeeklySummaryEmailService.php

    r3481890 r3490735  
    165165        $periodLabel = trim((string) ($period['start_local_display'] ?? '') . ' - ' . (string) ($period['end_local_display'] ?? ''));
    166166        $blockedTotal = (int) ($totals['blocked'] ?? 0);
     167        $formsBlocked = (int) ($totals['forms_blocked'] ?? 0);
    167168        $loginFailed = (int) ($totals['login_failed'] ?? 0);
    168169        $lockouts = (int) ($totals['login_lockout'] ?? 0);
     
    171172        $commentSpamBlocked = (int) ($totals['comment_spam_blocked'] ?? 0);
    172173        $commentSpamHeld = (int) ($totals['comment_spam_held'] ?? 0);
     174        $commentPendingReview = (int) ($totals['comment_pending_review'] ?? 0);
    173175        $commentRateLimited = (int) ($totals['comment_rate_limited'] ?? 0);
    174176        $uniqueAttackers = (int) ($totals['unique_attackers'] ?? 0);
     177        $commentQueueTotal = $commentSpamHeld + $commentPendingReview;
     178        $commentSpamStopped = $commentSpamBlocked + $commentRateLimited;
    175179        $posture = self::getPostureSummary($totals);
    176180        $preheader = sprintf(
    177             __('%1$s weekly security digest for %2$s. %3$s blocked events, %4$s failed logins, %5$s SQL injection attempts.', 'vulntitan'),
     181            __('%1$s weekly security digest for %2$s. %3$s blocked events, %4$s failed logins, %5$s form spam blocks, %6$s queued comments.', 'vulntitan'),
    178182            'VulnTitan',
    179183            $siteName,
    180184            self::formatNumber($blockedTotal),
    181185            self::formatNumber($loginFailed),
    182             self::formatNumber($sqli)
     186            self::formatNumber($formsBlocked),
     187            self::formatNumber($commentQueueTotal)
    183188        );
    184189        $generatedAt = self::formatLocalTimestamp(time(), 'M j, Y H:i');
    185190        $policySnapshot = self::buildPolicySnapshot();
    186         $highlights = self::buildHighlights($daily);
     191        $highlights = self::buildHighlights($daily, $totals);
    187192
    188193        $metricCards = [
     
    190195                'label' => __('Blocked Events', 'vulntitan'),
    191196                'value' => $blockedTotal,
    192                 'note' => __('Firewall, comment shield, and lockout interventions', 'vulntitan'),
     197                'note' => __('Firewall, login shield, comment shield, and form shield interventions', 'vulntitan'),
    193198                'accent' => '#0f9bd7',
    194199            ],
     
    206211            ],
    207212            [
     213                'label' => __('Form Spam Blocks', 'vulntitan'),
     214                'value' => $formsBlocked,
     215                'note' => __('Blocked Contact Form 7 and Fluent Forms submissions', 'vulntitan'),
     216                'accent' => '#22c55e',
     217            ],
     218            [
     219                'label' => __('Comment Spam Blocks', 'vulntitan'),
     220                'value' => $commentSpamStopped,
     221                'note' => __('Spam comments denied before publication', 'vulntitan'),
     222                'accent' => '#8b5cf6',
     223            ],
     224            [
     225                'label' => __('Comment Queue Holds', 'vulntitan'),
     226                'value' => $commentQueueTotal,
     227                'note' => __('Comment Shield holds plus WordPress moderation queue entries', 'vulntitan'),
     228                'accent' => '#f97316',
     229            ],
     230            [
    208231                'label' => __('SQLi Blocks', 'vulntitan'),
    209232                'value' => $sqli,
    210233                'note' => __('Malicious query payloads denied', 'vulntitan'),
    211                 'accent' => '#22c55e',
    212             ],
    213             [
    214                 'label' => __('Comment Spam Blocks', 'vulntitan'),
    215                 'value' => $commentSpamBlocked + $commentRateLimited,
    216                 'note' => __('Spam comments denied before publication', 'vulntitan'),
    217                 'accent' => '#8b5cf6',
    218             ],
    219             [
    220                 'label' => __('Comment Spam Held', 'vulntitan'),
    221                 'value' => $commentSpamHeld,
    222                 'note' => __('Suspicious comments sent to moderation', 'vulntitan'),
    223                 'accent' => '#f97316',
     234                'accent' => '#14b8a6',
     235            ],
     236            [
     237                'label' => __('Unique Attack Sources', 'vulntitan'),
     238                'value' => $uniqueAttackers,
     239                'note' => __('Distinct blocked IP fingerprints observed', 'vulntitan'),
     240                'accent' => '#2563eb',
    224241            ],
    225242        ];
    226243
    227244        $threatRows = [
     245            [
     246                'label' => __('Form Spam Blocks', 'vulntitan'),
     247                'value' => $formsBlocked,
     248                'tone' => '#22c55e',
     249            ],
     250            [
     251                'label' => __('Comment Pending Review', 'vulntitan'),
     252                'value' => $commentPendingReview,
     253                'tone' => '#f59e0b',
     254            ],
    228255            [
    229256                'label' => __('Command Injection Blocks', 'vulntitan'),
     
    268295        ];
    269296
    270         $html = '<!doctype html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head><body style="margin:0;padding:0;background-color:#edf2f7;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;color:#0f172a;">';
     297        $html = '<!doctype html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">' . self::renderEmailStyles() . '</head><body class="vt-body" style="margin:0;padding:0;background-color:#edf2f7;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;color:#0f172a;">';
    271298        $html .= '<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">' . esc_html($preheader) . '</div>';
    272         $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#edf2f7;margin:0;padding:24px 0;width:100%;"><tr><td align="center">';
    273         $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="max-width:720px;width:100%;margin:0 auto;">';
    274 
    275         $html .= '<tr><td style="padding:0 16px 16px 16px;">';
     299        $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" class="vt-shell" style="background-color:#edf2f7;margin:0;padding:24px 0;width:100%;"><tr><td align="center">';
     300        $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" class="vt-container" style="max-width:720px;width:100%;margin:0 auto;">';
     301
     302        $html .= '<tr><td class="vt-section-pad" style="padding:0 16px 16px 16px;">';
    276303        $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#0b1726;border-radius:24px;overflow:hidden;">';
    277         $html .= '<tr><td style="padding:18px 24px;background-color:#09111d;border-bottom:1px solid #14324f;">';
     304        $html .= '<tr><td class="vt-card-pad" style="padding:18px 24px;background-color:#09111d;border-bottom:1px solid #14324f;">';
    278305        $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"><tr>';
    279306        $html .= '<td style="vertical-align:middle;">';
     
    284311        }
    285312        $html .= '</td>';
    286         $html .= '<td align="right" style="vertical-align:middle;padding-left:16px;">';
     313        $html .= '<td align="right" class="vt-brand-right" style="vertical-align:middle;padding-left:16px;">';
    287314        $html .= '<span style="display:inline-block;font-size:12px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:#38bdf8;">Weekly Digest</span>';
    288315        $html .= '</td>';
    289316        $html .= '</tr></table>';
    290317        $html .= '</td></tr>';
    291         $html .= '<tr><td style="padding:30px 24px 28px 24px;background-color:#0b1726;">';
    292         $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"><tr>';
    293         $html .= '<td style="vertical-align:top;padding-right:16px;">';
    294         $html .= '<div style="font-size:28px;line-height:1.2;font-weight:800;color:#f8fafc;margin:0 0 10px 0;">' . esc_html($siteName) . '</div>';
     318        $html .= '<tr><td class="vt-card-pad" style="padding:30px 24px 28px 24px;background-color:#0b1726;">';
     319        $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" class="vt-stack-table"><tr>';
     320        $html .= '<td class="vt-hero-left" style="vertical-align:top;padding-right:16px;">';
     321        $html .= '<div class="vt-hero-title" style="font-size:28px;line-height:1.2;font-weight:800;color:#f8fafc;margin:0 0 10px 0;">' . esc_html($siteName) . '</div>';
    295322        $html .= '<div style="font-size:15px;line-height:1.7;color:#cbd5e1;margin:0 0 18px 0;">' . esc_html__('Your firewall and login telemetry summary for the previous 7 complete days.', 'vulntitan') . '</div>';
    296         $html .= '<div style="display:inline-block;padding:9px 14px;border-radius:999px;background-color:#11263c;color:#7dd3fc;font-size:12px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;">' . esc_html($periodLabel) . '</div>';
     323        $html .= '<div class="vt-inline-chip" style="display:inline-block;padding:9px 14px;border-radius:999px;background-color:#11263c;color:#7dd3fc;font-size:12px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;">' . esc_html($periodLabel) . '</div>';
    297324        $html .= '</td>';
    298         $html .= '<td align="right" style="vertical-align:top;width:220px;">';
    299         $html .= '<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="width:220px;background-color:#0f2237;border:1px solid #1d4061;border-radius:20px;">';
     325        $html .= '<td align="right" class="vt-hero-right" style="vertical-align:top;width:220px;">';
     326        $html .= '<table role="presentation" cellspacing="0" cellpadding="0" border="0" class="vt-blocked-card" style="width:220px;background-color:#0f2237;border:1px solid #1d4061;border-radius:20px;">';
    300327        $html .= '<tr><td style="padding:18px 18px 8px 18px;font-size:12px;letter-spacing:0.12em;text-transform:uppercase;color:#93c5fd;font-weight:700;">' . esc_html__('Blocked This Week', 'vulntitan') . '</td></tr>';
    301         $html .= '<tr><td style="padding:0 18px 8px 18px;font-size:38px;line-height:1;font-weight:800;color:#ffffff;">' . esc_html(self::formatNumber($blockedTotal)) . '</td></tr>';
     328        $html .= '<tr><td class="vt-blocked-value" style="padding:0 18px 8px 18px;font-size:38px;line-height:1;font-weight:800;color:#ffffff;">' . esc_html(self::formatNumber($blockedTotal)) . '</td></tr>';
    302329        $html .= '<tr><td style="padding:0 18px 18px 18px;font-size:13px;line-height:1.7;color:#cbd5e1;">' . esc_html($posture['summary']) . '</td></tr>';
    303330        $html .= '</table>';
     
    308335        $html .= '</td></tr>';
    309336
    310         $html .= '<tr><td style="padding:0 16px 16px 16px;">' . self::renderMetricGrid($metricCards) . '</td></tr>';
    311 
    312         $html .= '<tr><td style="padding:0 16px 16px 16px;">';
     337        $html .= '<tr><td class="vt-section-pad" style="padding:0 16px 16px 16px;">' . self::renderMetricGrid($metricCards) . '</td></tr>';
     338
     339        $html .= '<tr><td class="vt-section-pad" style="padding:0 16px 16px 16px;">';
    313340        $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#ffffff;border:1px solid #dbe6f0;border-radius:24px;">';
    314         $html .= '<tr><td style="padding:24px 24px 14px 24px;">';
     341        $html .= '<tr><td class="vt-card-pad" style="padding:24px 24px 14px 24px;">';
    315342        $html .= '<div style="font-size:20px;line-height:1.3;font-weight:800;color:#0f172a;margin:0 0 8px 0;">' . esc_html__('Threat Breakdown', 'vulntitan') . '</div>';
    316         $html .= '<div style="font-size:14px;line-height:1.7;color:#475569;">' . esc_html__('A focused view of what the firewall and login shield handled during the reporting window.', 'vulntitan') . '</div>';
    317         $html .= '</td></tr>';
    318         $html .= '<tr><td style="padding:0 24px 24px 24px;">' . self::renderThreatBreakdown($threatRows) . '</td></tr>';
     343        $html .= '<div style="font-size:14px;line-height:1.7;color:#475569;">' . esc_html__('A focused view of what the firewall, login shield, comment shield, and form shield handled during the reporting window.', 'vulntitan') . '</div>';
     344        $html .= '</td></tr>';
     345        $html .= '<tr><td class="vt-card-pad" style="padding:0 24px 24px 24px;">' . self::renderThreatBreakdown($threatRows) . '</td></tr>';
    319346        $html .= '</table>';
    320347        $html .= '</td></tr>';
    321348
    322         $html .= '<tr><td style="padding:0 16px 16px 16px;">';
    323         $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">';
     349        $html .= '<tr><td class="vt-section-pad" style="padding:0 16px 16px 16px;">';
     350        $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" class="vt-stack-table">';
    324351        $html .= '<tr>';
    325         $html .= '<td style="width:58%;padding:0 8px 0 0;vertical-align:top;">' . self::renderDailyHistory($daily) . '</td>';
    326         $html .= '<td style="width:42%;padding:0 0 0 8px;vertical-align:top;">' . self::renderHighlightsPanel($highlights, $topPaths, $topRules) . '</td>';
     352        $html .= '<td class="vt-two-col-left" style="width:58%;padding:0 8px 0 0;vertical-align:top;">' . self::renderDailyHistory($daily) . '</td>';
     353        $html .= '<td class="vt-two-col-right" style="width:42%;padding:0 0 0 8px;vertical-align:top;">' . self::renderHighlightsPanel($highlights, $topPaths, $topRules) . '</td>';
    327354        $html .= '</tr>';
    328355        $html .= '</table>';
    329356        $html .= '</td></tr>';
    330357
    331         $html .= '<tr><td style="padding:0 16px 16px 16px;">';
     358        $html .= '<tr><td class="vt-section-pad" style="padding:0 16px 16px 16px;">';
    332359        $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#ffffff;border:1px solid #dbe6f0;border-radius:24px;">';
    333         $html .= '<tr><td style="padding:24px 24px 14px 24px;">';
     360        $html .= '<tr><td class="vt-card-pad" style="padding:24px 24px 14px 24px;">';
    334361        $html .= '<div style="font-size:20px;line-height:1.3;font-weight:800;color:#0f172a;margin:0 0 8px 0;">' . esc_html__('Protection Profile', 'vulntitan') . '</div>';
    335         $html .= '<div style="font-size:14px;line-height:1.7;color:#475569;">' . esc_html__('Current policy posture that shaped the detection and containment shown above.', 'vulntitan') . '</div>';
    336         $html .= '</td></tr>';
    337         $html .= '<tr><td style="padding:0 24px 24px 24px;">' . $policySnapshot . '</td></tr>';
     362        $html .= '<div style="font-size:14px;line-height:1.7;color:#475569;">' . esc_html__('Authentication, WAF, anti-spam, and telemetry controls that shaped the detections shown above.', 'vulntitan') . '</div>';
     363        $html .= '</td></tr>';
     364        $html .= '<tr><td class="vt-card-pad" style="padding:0 24px 24px 24px;">' . $policySnapshot . '</td></tr>';
    338365        $html .= '</table>';
    339366        $html .= '</td></tr>';
    340367
    341         $html .= '<tr><td style="padding:0 16px 0 16px;">';
     368        $html .= '<tr><td class="vt-section-pad" style="padding:0 16px 0 16px;">';
    342369        $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#0f172a;border-radius:24px;">';
    343         $html .= '<tr><td style="padding:22px 24px;">';
     370        $html .= '<tr><td class="vt-card-pad" style="padding:22px 24px;">';
    344371        $html .= '<div style="font-size:14px;line-height:1.8;color:#cbd5e1;">';
    345372        $html .= esc_html__('Generated by VulnTitan for your primary site administrator.', 'vulntitan') . ' ';
     
    360387    protected static function renderMetricCard(string $label, int $value, string $note, string $accent): string
    361388    {
    362         return '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#ffffff;border:1px solid #dbe6f0;border-radius:20px;">'
     389        return '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" class="vt-metric-card" style="background-color:#ffffff;border:1px solid #dbe6f0;border-radius:20px;">'
    363390            . '<tr><td style="padding:20px 20px 8px 20px;font-size:12px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:' . esc_attr($accent) . ';">' . esc_html($label) . '</td></tr>'
    364             . '<tr><td style="padding:0 20px 8px 20px;font-size:34px;line-height:1;font-weight:800;color:#0f172a;">' . esc_html(self::formatNumber($value)) . '</td></tr>'
     391            . '<tr><td class="vt-metric-value" style="padding:0 20px 8px 20px;font-size:34px;line-height:1;font-weight:800;color:#0f172a;">' . esc_html(self::formatNumber($value)) . '</td></tr>'
    365392            . '<tr><td style="padding:0 20px 20px 20px;font-size:14px;line-height:1.7;color:#475569;">' . esc_html($note) . '</td></tr>'
    366393            . '</table>';
     
    369396    protected static function renderMetricGrid(array $cards): string
    370397    {
    371         $html = '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">';
     398        $html = '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" class="vt-metric-grid">';
    372399        $chunks = array_chunk($cards, 2);
    373400
    374401        foreach ($chunks as $rowIndex => $rowCards) {
    375             $html .= '<tr>';
     402            $html .= '<tr class="vt-metric-row">';
    376403
    377404            foreach ($rowCards as $cardIndex => $card) {
     
    381408                $paddingBottom = $rowIndex < count($chunks) - 1 ? '16px' : '0';
    382409
    383                 $html .= '<td style="width:50%;padding:0 ' . $paddingRight . ' ' . $paddingBottom . ' ' . $paddingLeft . ';vertical-align:top;">';
     410                $html .= '<td class="vt-metric-cell" style="width:50%;padding:0 ' . $paddingRight . ' ' . $paddingBottom . ' ' . $paddingLeft . ';vertical-align:top;">';
    384411                $html .= self::renderMetricCard(
    385412                    (string) ($card['label'] ?? ''),
     
    393420            if (count($rowCards) === 1) {
    394421                $paddingBottom = $rowIndex < count($chunks) - 1 ? '16px' : '0';
    395                 $html .= '<td style="width:50%;padding:0 0 ' . $paddingBottom . ' 8px;vertical-align:top;"></td>';
     422                $html .= '<td class="vt-metric-cell" style="width:50%;padding:0 0 ' . $paddingBottom . ' 8px;vertical-align:top;"></td>';
    396423            }
    397424
     
    433460
    434461        $html = '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#ffffff;border:1px solid #dbe6f0;border-radius:24px;">';
    435         $html .= '<tr><td style="padding:24px 24px 14px 24px;">';
     462        $html .= '<tr><td class="vt-card-pad" style="padding:24px 24px 14px 24px;">';
    436463        $html .= '<div style="font-size:20px;line-height:1.3;font-weight:800;color:#0f172a;margin:0 0 8px 0;">' . esc_html__('7-Day Activity Timeline', 'vulntitan') . '</div>';
    437         $html .= '<div style="font-size:14px;line-height:1.7;color:#475569;">' . esc_html__('Blocked traffic, login failures, and WAF detections day by day.', 'vulntitan') . '</div>';
    438         $html .= '</td></tr>';
    439         $html .= '<tr><td style="padding:0 24px 24px 24px;">';
     464        $html .= '<div style="font-size:14px;line-height:1.7;color:#475569;">' . esc_html__('Blocked traffic, login pressure, form abuse, and comment moderation signals day by day.', 'vulntitan') . '</div>';
     465        $html .= '</td></tr>';
     466        $html .= '<tr><td class="vt-card-pad" style="padding:0 24px 24px 24px;">';
    440467
    441468        foreach ($daily as $index => $day) {
    442469            $counts = is_array($day['counts'] ?? null) ? $day['counts'] : [];
    443470            $blocked = (int) ($counts['blocked'] ?? 0);
     471            $formsBlocked = (int) ($counts['forms_blocked'] ?? 0);
     472            $commentStopped = (int) ($counts['comment_spam_blocked'] ?? 0) + (int) ($counts['comment_rate_limited'] ?? 0);
     473            $commentQueue = (int) ($counts['comment_spam_held'] ?? 0) + (int) ($counts['comment_pending_review'] ?? 0);
    444474            $percent = ($maxBlocked > 0 && $blocked > 0) ? max(12, (int) round(($blocked / $maxBlocked) * 100)) : 0;
    445475            $border = $index < count($daily) - 1 ? 'border-bottom:1px solid #e2e8f0;' : '';
     
    447477            $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="' . $border . '">';
    448478            $html .= '<tr>';
    449             $html .= '<td style="padding:12px 0;width:110px;font-size:13px;font-weight:700;color:#0f172a;vertical-align:top;">' . esc_html((string) ($day['label'] ?? '')) . '</td>';
    450             $html .= '<td style="padding:12px 14px 12px 0;vertical-align:top;">';
     479            $html .= '<td class="vt-daily-label" style="padding:12px 0;width:110px;font-size:13px;font-weight:700;color:#0f172a;vertical-align:top;">' . esc_html((string) ($day['label'] ?? '')) . '</td>';
     480            $html .= '<td class="vt-daily-data" style="padding:12px 14px 12px 0;vertical-align:top;">';
    451481            $html .= '<div style="height:8px;border-radius:999px;background-color:#e2e8f0;overflow:hidden;margin:5px 0 10px 0;">';
    452482            $html .= '<span style="display:block;height:8px;width:' . esc_attr((string) $percent) . '%;background-color:#0f9bd7;border-radius:999px;"></span>';
     
    454484            $html .= '<div style="font-size:12px;line-height:1.8;color:#64748b;">';
    455485            $html .= esc_html__('Blocked', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber($blocked)) . '</strong> &nbsp; ';
     486            $html .= esc_html__('Forms', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber($formsBlocked)) . '</strong> &nbsp; ';
     487            $html .= esc_html__('Comment Stops', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber($commentStopped)) . '</strong> &nbsp; ';
     488            $html .= esc_html__('Queue', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber($commentQueue)) . '</strong> &nbsp; ';
    456489            $html .= esc_html__('Failed Logins', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber((int) ($counts['login_failed'] ?? 0))) . '</strong> &nbsp; ';
    457490            $html .= esc_html__('SQLi', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber((int) ($counts['sqli'] ?? 0))) . '</strong> &nbsp; ';
     
    472505    {
    473506        $html = '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#ffffff;border:1px solid #dbe6f0;border-radius:24px;margin-bottom:16px;">';
    474         $html .= '<tr><td style="padding:24px 24px 14px 24px;">';
     507        $html .= '<tr><td class="vt-card-pad" style="padding:24px 24px 14px 24px;">';
    475508        $html .= '<div style="font-size:20px;line-height:1.3;font-weight:800;color:#0f172a;margin:0 0 8px 0;">' . esc_html__('Highlights', 'vulntitan') . '</div>';
    476509        $html .= '<div style="font-size:14px;line-height:1.7;color:#475569;">' . esc_html__('The most important takeaways from this reporting period.', 'vulntitan') . '</div>';
    477510        $html .= '</td></tr>';
    478         $html .= '<tr><td style="padding:0 24px 24px 24px;">';
     511        $html .= '<tr><td class="vt-card-pad" style="padding:0 24px 24px 24px;">';
    479512
    480513        foreach ($highlights as $index => $highlight) {
     
    523556                ];
    524557            },
    525             __('No firewall or comment-shield rules were triggered in this period.', 'vulntitan')
     558            __('No firewall, login, or form-shield rules were triggered in this period.', 'vulntitan')
    526559        );
    527560
     
    532565    {
    533566        $html = '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#ffffff;border:1px solid #dbe6f0;border-radius:24px;">';
    534         $html .= '<tr><td style="padding:24px 24px 14px 24px;">';
     567        $html .= '<tr><td class="vt-card-pad" style="padding:24px 24px 14px 24px;">';
    535568        $html .= '<div style="font-size:18px;line-height:1.3;font-weight:800;color:#0f172a;margin:0 0 8px 0;">' . esc_html($title) . '</div>';
    536569        $html .= '</td></tr>';
    537         $html .= '<tr><td style="padding:0 24px 24px 24px;">';
     570        $html .= '<tr><td class="vt-card-pad" style="padding:0 24px 24px 24px;">';
    538571
    539572        if (!$rows) {
     
    583616            ],
    584617            [
     618                'label' => __('Two-Factor Authentication', 'vulntitan'),
     619                'value' => self::buildTwoFactorSummary($settings),
     620            ],
     621            [
     622                'label' => __('CAPTCHA Coverage', 'vulntitan'),
     623                'value' => self::buildCaptchaSummary($settings),
     624            ],
     625            [
     626                'label' => __('XML-RPC Policy', 'vulntitan'),
     627                'value' => self::buildXmlrpcSummary($settings),
     628            ],
     629            [
     630                'label' => __('Weak Password Blocking', 'vulntitan'),
     631                'value' => !empty($settings['weak_password_blocking_enabled']) ? __('Enabled', 'vulntitan') : __('Disabled', 'vulntitan'),
     632            ],
     633            [
    585634                'label' => __('Max Failed Attempts', 'vulntitan'),
    586635                'value' => self::formatNumber((int) ($settings['max_attempts'] ?? 0)),
     
    594643            ],
    595644            [
     645                'label' => __('SQLi Rule Set', 'vulntitan'),
     646                'value' => !empty($settings['waf_sqli_enabled']) ? __('Enabled', 'vulntitan') : __('Disabled', 'vulntitan'),
     647            ],
     648            [
     649                'label' => __('Command Injection Rule Set', 'vulntitan'),
     650                'value' => !empty($settings['waf_command_injection_enabled']) ? __('Enabled', 'vulntitan') : __('Disabled', 'vulntitan'),
     651            ],
     652            [
     653                'label' => __('Learning Mode', 'vulntitan'),
     654                'value' => self::buildLearningModeSummary($settings),
     655            ],
     656            [
     657                'label' => __('Proxy Trust Handling', 'vulntitan'),
     658                'value' => self::buildProxyTrustSummary($settings),
     659            ],
     660            [
    596661                'label' => __('Comment Shield', 'vulntitan'),
    597662                'value' => !empty($settings['comment_shield_enabled']) ? __('Enabled', 'vulntitan') : __('Disabled', 'vulntitan'),
     
    604669            ],
    605670            [
    606                 'label' => __('Comment Rate Window', 'vulntitan'),
     671                'label' => __('Minimum Submit Time', 'vulntitan'),
     672                'value' => sprintf(
     673                    __('%s seconds', 'vulntitan'),
     674                    self::formatNumber((int) ($settings['submission_min_submit_seconds'] ?? $settings['comment_min_submit_seconds'] ?? 0))
     675                ),
     676            ],
     677            [
     678                'label' => __('Guest Link Limit', 'vulntitan'),
     679                'value' => sprintf(
     680                    __('%s links', 'vulntitan'),
     681                    self::formatNumber((int) ($settings['submission_max_links'] ?? $settings['comment_max_links'] ?? 0))
     682                ),
     683            ],
     684            [
     685                'label' => __('Submission Rate Window', 'vulntitan'),
    607686                'value' => sprintf(
    608687                    __('%1$s attempts / %2$s minutes', 'vulntitan'),
    609                     self::formatNumber((int) ($settings['comment_rate_limit_attempts'] ?? 0)),
    610                     self::formatNumber((int) ($settings['comment_rate_limit_window_minutes'] ?? 0))
     688                    self::formatNumber((int) ($settings['submission_rate_limit_attempts'] ?? $settings['comment_rate_limit_attempts'] ?? 0)),
     689                    self::formatNumber((int) ($settings['submission_rate_limit_window_minutes'] ?? $settings['comment_rate_limit_window_minutes'] ?? 0))
    611690                ),
     691            ],
     692            [
     693                'label' => __('Form Shield', 'vulntitan'),
     694                'value' => !empty($settings['form_shield_enabled']) ? __('Enabled', 'vulntitan') : __('Disabled', 'vulntitan'),
     695            ],
     696            [
     697                'label' => __('Protected Form Providers', 'vulntitan'),
     698                'value' => self::buildFormProvidersSummary($settings),
    612699            ],
    613700            [
     
    624711            $border = $index < count($rows) - 1 ? 'border-bottom:1px solid #e2e8f0;' : '';
    625712            $html .= '<tr>';
    626             $html .= '<td style="padding:12px 0;' . $border . 'font-size:14px;line-height:1.7;color:#475569;width:42%;">' . esc_html((string) $row['label']) . '</td>';
    627             $html .= '<td style="padding:12px 0;' . $border . 'font-size:14px;line-height:1.7;font-weight:700;color:#0f172a;">' . esc_html((string) $row['value']) . '</td>';
     713            $html .= '<td class="vt-policy-label" style="padding:12px 0;' . $border . 'font-size:14px;line-height:1.7;color:#475569;width:42%;">' . esc_html((string) $row['label']) . '</td>';
     714            $html .= '<td class="vt-policy-value" style="padding:12px 0;' . $border . 'font-size:14px;line-height:1.7;font-weight:700;color:#0f172a;">' . esc_html((string) $row['value']) . '</td>';
    628715            $html .= '</tr>';
    629716        }
     
    633720    }
    634721
    635     protected static function buildHighlights(array $daily): array
     722    protected static function buildHighlights(array $daily, array $totals = []): array
    636723    {
    637724        $peakDay = null;
     
    640727        $peakLogins = -1;
    641728        $activeDays = 0;
     729        $formsBlocked = (int) ($totals['forms_blocked'] ?? 0);
     730        $commentSpamHeld = (int) ($totals['comment_spam_held'] ?? 0);
     731        $commentPendingReview = (int) ($totals['comment_pending_review'] ?? 0);
     732        $commentStops = (int) ($totals['comment_spam_blocked'] ?? 0) + (int) ($totals['comment_rate_limited'] ?? 0);
    642733
    643734        foreach ($daily as $day) {
     
    690781                ),
    691782            ],
     783            [
     784                'title' => __('Abuse Filters', 'vulntitan'),
     785                'body' => ($formsBlocked + $commentStops + $commentSpamHeld + $commentPendingReview) > 0
     786                    ? sprintf(
     787                        __('Form Shield blocked %1$s submissions, comment filtering stopped %2$s comments, and %3$s comments were held or queued for review.', 'vulntitan'),
     788                        self::formatNumber($formsBlocked),
     789                        self::formatNumber($commentStops),
     790                        self::formatNumber($commentSpamHeld + $commentPendingReview)
     791                    )
     792                    : __('No form spam or suspicious comment activity required intervention during this reporting period.', 'vulntitan'),
     793            ],
    692794        ];
    693795
    694796        return $highlights;
     797    }
     798
     799    protected static function renderEmailStyles(): string
     800    {
     801        return '<style type="text/css">'
     802            . 'body{margin:0;padding:0;background-color:#edf2f7;}'
     803            . 'table{border-collapse:separate;}'
     804            . '@media only screen and (max-width:620px){'
     805            . '.vt-shell{padding:16px 0 !important;}'
     806            . '.vt-container{width:100% !important;}'
     807            . '.vt-section-pad{padding-left:12px !important;padding-right:12px !important;}'
     808            . '.vt-card-pad{padding-left:18px !important;padding-right:18px !important;}'
     809            . '.vt-brand-right{display:block !important;width:100% !important;text-align:left !important;padding:12px 0 0 0 !important;}'
     810            . '.vt-hero-left,.vt-hero-right,.vt-two-col-left,.vt-two-col-right,.vt-metric-cell,.vt-policy-label,.vt-policy-value,.vt-daily-label,.vt-daily-data{display:block !important;width:100% !important;}'
     811            . '.vt-hero-left{padding-right:0 !important;padding-bottom:16px !important;}'
     812            . '.vt-hero-right{padding-left:0 !important;text-align:left !important;}'
     813            . '.vt-two-col-left{padding:0 0 16px 0 !important;}'
     814            . '.vt-two-col-right{padding:0 !important;}'
     815            . '.vt-metric-row{display:block !important;}'
     816            . '.vt-metric-cell{padding:0 0 12px 0 !important;}'
     817            . '.vt-metric-cell:last-child{padding-bottom:0 !important;}'
     818            . '.vt-blocked-card{width:100% !important;}'
     819            . '.vt-hero-title{font-size:24px !important;}'
     820            . '.vt-blocked-value,.vt-metric-value{font-size:30px !important;}'
     821            . '.vt-inline-chip{display:block !important;margin:0 0 8px 0 !important;text-align:center !important;}'
     822            . '.vt-daily-label{padding-bottom:8px !important;}'
     823            . '.vt-daily-data{padding-right:0 !important;}'
     824            . '.vt-policy-label{padding-bottom:2px !important;border-bottom:none !important;}'
     825            . '.vt-policy-value{padding-top:0 !important;padding-bottom:12px !important;}'
     826            . '}'
     827            . '</style>';
     828    }
     829
     830    protected static function buildTwoFactorSummary(array $settings): string
     831    {
     832        if (empty($settings['two_factor_enabled'])) {
     833            return __('Disabled', 'vulntitan');
     834        }
     835
     836        $roles = is_array($settings['two_factor_roles'] ?? null) ? $settings['two_factor_roles'] : [];
     837        $roleLabel = $roles ? self::implodeHumanList(array_map([__CLASS__, 'humanizeIdentifier'], $roles)) : __('selected roles', 'vulntitan');
     838        $trustedDays = max(0, (int) ($settings['trusted_device_days'] ?? 0));
     839
     840        return sprintf(
     841            __('Enabled for %1$s with %2$s-day trusted devices', 'vulntitan'),
     842            $roleLabel,
     843            self::formatNumber($trustedDays)
     844        );
     845    }
     846
     847    protected static function buildCaptchaSummary(array $settings): string
     848    {
     849        $provider = (string) ($settings['captcha_provider'] ?? 'none');
     850        if ($provider === '' || $provider === 'none') {
     851            return __('Disabled', 'vulntitan');
     852        }
     853
     854        $surfaces = [];
     855        if (!empty($settings['captcha_login_enabled'])) {
     856            $surfaces[] = __('login', 'vulntitan');
     857        }
     858        if (!empty($settings['captcha_register_enabled'])) {
     859            $surfaces[] = __('registration', 'vulntitan');
     860        }
     861        if (!empty($settings['captcha_lostpassword_enabled'])) {
     862            $surfaces[] = __('password reset', 'vulntitan');
     863        }
     864        if (!empty($settings['captcha_comment_enabled'])) {
     865            $surfaces[] = __('comments', 'vulntitan');
     866        }
     867
     868        $providerLabel = self::humanizeCaptchaProvider($provider);
     869        if (!$surfaces) {
     870            return sprintf(
     871                __('%s configured but not assigned to a protected flow', 'vulntitan'),
     872                $providerLabel
     873            );
     874        }
     875
     876        return sprintf(
     877            __('%1$s on %2$s', 'vulntitan'),
     878            $providerLabel,
     879            self::implodeHumanList($surfaces)
     880        );
     881    }
     882
     883    protected static function buildXmlrpcSummary(array $settings): string
     884    {
     885        $policy = (string) ($settings['xmlrpc_policy'] ?? 'allow');
     886
     887        if ($policy === 'rate_limit') {
     888            return sprintf(
     889                __('Rate limited at %1$s attempts / %2$s minutes', 'vulntitan'),
     890                self::formatNumber((int) ($settings['xmlrpc_rate_limit_attempts'] ?? 0)),
     891                self::formatNumber((int) ($settings['xmlrpc_rate_limit_window_minutes'] ?? 0))
     892            );
     893        }
     894
     895        if ($policy === 'block') {
     896            return __('Blocked except allowlist', 'vulntitan');
     897        }
     898
     899        return __('Allowed', 'vulntitan');
     900    }
     901
     902    protected static function buildLearningModeSummary(array $settings): string
     903    {
     904        if (empty($settings['learning_mode_enabled'])) {
     905            return __('Disabled', 'vulntitan');
     906        }
     907
     908        return sprintf(
     909            __('Enabled at %1$s hits within %2$s days', 'vulntitan'),
     910            self::formatNumber((int) ($settings['learning_suggestion_threshold'] ?? 0)),
     911            self::formatNumber((int) ($settings['learning_suggestion_window_days'] ?? 0))
     912        );
     913    }
     914
     915    protected static function buildProxyTrustSummary(array $settings): string
     916    {
     917        $parts = [];
     918        $trustedProxies = is_array($settings['trusted_proxies'] ?? null) ? $settings['trusted_proxies'] : [];
     919
     920        if (!empty($settings['trust_cloudflare'])) {
     921            $parts[] = __('Cloudflare headers trusted', 'vulntitan');
     922        }
     923
     924        if ($trustedProxies) {
     925            $parts[] = sprintf(
     926                __('%s trusted proxy IPs', 'vulntitan'),
     927                self::formatNumber(count($trustedProxies))
     928            );
     929        }
     930
     931        if (!$parts) {
     932            return __('Direct REMOTE_ADDR only', 'vulntitan');
     933        }
     934
     935        return implode(' • ', $parts);
     936    }
     937
     938    protected static function buildFormProvidersSummary(array $settings): string
     939    {
     940        if (empty($settings['form_shield_enabled'])) {
     941            return __('Disabled', 'vulntitan');
     942        }
     943
     944        $providers = [];
     945        if (!empty($settings['form_provider_cf7_enabled'])) {
     946            $providers[] = __('Contact Form 7', 'vulntitan');
     947        }
     948        if (!empty($settings['form_provider_fluentforms_enabled'])) {
     949            $providers[] = __('Fluent Forms', 'vulntitan');
     950        }
     951
     952        if (!$providers) {
     953            return __('Form Shield enabled with no active provider selected', 'vulntitan');
     954        }
     955
     956        return self::implodeHumanList($providers);
     957    }
     958
     959    protected static function humanizeCaptchaProvider(string $provider): string
     960    {
     961        switch ($provider) {
     962            case 'turnstile':
     963                return __('Cloudflare Turnstile', 'vulntitan');
     964            case 'hcaptcha':
     965                return __('hCaptcha', 'vulntitan');
     966            default:
     967                return ucfirst($provider);
     968        }
     969    }
     970
     971    protected static function humanizeIdentifier(string $value): string
     972    {
     973        return ucwords(str_replace(['_', '-'], ' ', trim($value)));
     974    }
     975
     976    protected static function implodeHumanList(array $items): string
     977    {
     978        $items = array_values(array_filter(array_map(static function ($item): string {
     979            return is_scalar($item) ? trim((string) $item) : '';
     980        }, $items)));
     981
     982        if (!$items) {
     983            return '';
     984        }
     985
     986        if (count($items) === 1) {
     987            return $items[0];
     988        }
     989
     990        if (count($items) === 2) {
     991            return $items[0] . ' ' . __('and', 'vulntitan') . ' ' . $items[1];
     992        }
     993
     994        $last = array_pop($items);
     995
     996        return implode(', ', $items) . ', ' . __('and', 'vulntitan') . ' ' . $last;
    695997    }
    696998
  • vulntitan/tags/2.1.16/readme.txt

    r3486040 r3490735  
    44Tested up to: 6.9
    55Requires PHP: 7.4
    6 Stable tag: 2.1.15
     6Stable tag: 2.1.16
    77License: GPLv2
    88License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    175175== Changelog ==
    176176
     177= v2.1.16 - 25 Mar, 2026 =
     178* Tightened Comment Shield spam detection with casino, betting, gambling, promotional-link, repeated-domain, and thin-link comment heuristics for guest comments.
     179* Added firewall logging when suspicious comments are held and when WordPress routes comments into the pending moderation queue.
     180* Expanded the weekly executive security digest with form spam, comment queue, and broader protection-profile coverage.
     181* Improved the HTML digest layout on mobile by stacking compressed two-column sections into a readable single-column flow.
     182
    177183= v2.1.15 - 18 Mar, 2026 =
    178184* Added “Not installed” provider messaging in Spam Protection and disabled unavailable form provider toggles until Contact Form 7 or Fluent Forms is activated.
  • vulntitan/tags/2.1.16/vulntitan.php

    r3486040 r3490735  
    44 * Plugin URI: https://vulntitan.com/vulntitan/
    55 * Description: VulnTitan is a WordPress security plugin with vulnerability scanning, malware detection, file integrity monitoring, comment and form anti-spam protection, and a built-in firewall with WAF payload rules and login protection.
    6  * Version: 2.1.15
     6 * Version: 2.1.16
    77 * Author: Jaroslav Svetlik
    88 * Author URI: https://vulntitan.com
     
    3030
    3131// Define plugin constants
    32 define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.1.15');
     32define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.1.16');
    3333define('VULNTITAN_PLUGIN_BASENAME', plugin_basename(__FILE__));
    3434define('VULNTITAN_PLUGIN_DIR', untrailingslashit(plugin_dir_path(__FILE__)));
  • vulntitan/trunk/CHANGELOG.md

    r3486040 r3490735  
    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.1.16] - 2026-03-25
     9### Added
     10- Tightened Comment Shield spam detection with casino, betting, gambling, promotional-link, repeated-domain, and thin-link heuristics for guest comments.
     11- Logged suspicious comment holds and WordPress moderation-queue entries into the firewall activity stream.
     12- Expanded the weekly executive security digest with form spam, comment queue, and broader protection-profile coverage.
     13
     14### Changed
     15- Reworked the HTML weekly digest layout so cramped two-column sections collapse into a readable mobile presentation.
    716
    817## [2.1.15] - 2026-03-18
  • vulntitan/trunk/assets/js/firewall.js

    r3486040 r3490735  
    241241            case 'comment_spam_held':
    242242                return i18n.firewall_event_comment_spam_held || 'Comment held for review';
     243            case 'comment_pending_review':
     244                return i18n.firewall_event_comment_pending_review || 'Comment entered pending review';
    243245            case 'comment_rate_limited':
    244246                return i18n.firewall_event_comment_rate_limited || 'Comment rate limited';
     
    257259                return 'is-danger';
    258260            case 'comment_spam_held':
     261            case 'comment_pending_review':
    259262            case 'login_failed':
    260263                return 'is-warning';
  • vulntitan/trunk/assets/js/firewall.min.js

    r3486040 r3490735  
    241241            case 'comment_spam_held':
    242242                return i18n.firewall_event_comment_spam_held || 'Comment held for review';
     243            case 'comment_pending_review':
     244                return i18n.firewall_event_comment_pending_review || 'Comment entered pending review';
    243245            case 'comment_rate_limited':
    244246                return i18n.firewall_event_comment_rate_limited || 'Comment rate limited';
     
    257259                return 'is-danger';
    258260            case 'comment_spam_held':
     261            case 'comment_pending_review':
    259262            case 'login_failed':
    260263                return 'is-warning';
  • vulntitan/trunk/includes/Admin/Admin.php

    r3485911 r3490735  
    138138                    'firewall_event_comment_spam_blocked' => esc_html__('Comment spam blocked', 'vulntitan'),
    139139                    'firewall_event_comment_spam_held' => esc_html__('Comment held for review', 'vulntitan'),
     140                    'firewall_event_comment_pending_review' => esc_html__('Comment entered pending review', 'vulntitan'),
    140141                    'firewall_event_comment_rate_limited' => esc_html__('Comment rate limited', 'vulntitan'),
    141142                    'firewall_feed_live' => esc_html__('Live', 'vulntitan'),
  • vulntitan/trunk/includes/Services/CommentSpamService.php

    r3485911 r3490735  
    1212    protected const RATE_LIMIT_PREFIX = 'vulntitan_cs_rate_';
    1313    protected const MIN_TOKEN_AGE = 1;
     14    protected const SHORT_COMMENT_MAX_WORDS = 3;
     15    protected const SHORT_COMMENT_MAX_LENGTH = 40;
    1416
    1517    protected static bool $booted = false;
     
    9294
    9395        if (($decision['action'] ?? 'allow') === 'allow') {
     96            if (self::isEnabled() && self::supportsCommentType($commentData) && !self::shouldBypass($commentData) && ($approved === 0 || $approved === '0')) {
     97                self::logPendingModeration($commentData);
     98            }
     99
    94100            return $approved;
    95101        }
     
    131137        $commentContent = isset($commentData['comment_content']) ? (string) wp_unslash($commentData['comment_content']) : '';
    132138        $normalizedContent = self::normalizeCommentContent($commentContent);
     139        $authorName = isset($commentData['comment_author']) ? self::normalizeCommentContent((string) wp_unslash($commentData['comment_author'])) : '';
     140        $authorUrl = isset($commentData['comment_author_url']) ? trim((string) wp_unslash($commentData['comment_author_url'])) : '';
    133141        $postId = (int) ($commentData['comment_post_ID'] ?? 0);
    134142        $ipAddress = self::resolveIpAddress($commentData);
     
    137145        $rateCount = self::incrementRateCounter($visitorKey, $rateWindowMinutes);
    138146        $maxAttempts = max(1, (int) ($settings['submission_rate_limit_attempts'] ?? $settings['comment_rate_limit_attempts'] ?? 3));
     147        $linkCount = self::countLinks($commentContent);
     148        $authorDomain = self::normalizeDomain($authorUrl);
     149        $externalDomains = self::extractExternalDomains($commentContent, $authorDomain);
     150        [$topExternalDomain, $topExternalHits] = self::findTopDomain($externalDomains);
     151        $spamKeywordMatches = self::matchSpamKeywords(implode("\n", array_filter([$normalizedContent, $authorName, self::normalizeCommentContent($authorUrl)])));
     152        $promotionalPhraseMatches = self::matchPromotionalPhrases($normalizedContent);
     153        $wordCount = self::countWords($normalizedContent);
     154        $contentLength = self::measureLength($normalizedContent);
    139155
    140156        if ($rateCount > $maxAttempts) {
     
    154170                [
    155171                    'comment_hash' => self::buildCommentHash($normalizedContent),
    156                     'link_count' => self::countLinks($commentContent),
     172                    'link_count' => $linkCount,
    157173                ]
    158174            );
     
    239255
    240256        if (!self::isAuthenticatedCommenter($commentData)) {
    241             $linkCount = self::countLinks($commentContent);
    242257            $maxLinks = max(0, (int) ($settings['submission_max_links'] ?? $settings['comment_max_links'] ?? 2));
     258
     259            if ($spamKeywordMatches !== [] && $externalDomains !== []) {
     260                return self::buildDecisionFromSuspiciousAction(
     261                    (string) ($settings['comment_suspicious_action'] ?? 'hold'),
     262                    'spam_keywords_with_external_url',
     263                    __('Comment contains common spam keywords together with an external URL or website.', 'vulntitan'),
     264                    [
     265                        'post_id' => $postId,
     266                        'rate_count' => $rateCount,
     267                        'window_minutes' => $rateWindowMinutes,
     268                        'link_count' => $linkCount,
     269                        'author_url_domain' => $authorDomain,
     270                        'matched_keywords' => $spamKeywordMatches,
     271                    ],
     272                    [
     273                        'comment_hash' => self::buildCommentHash($normalizedContent),
     274                        'link_count' => $linkCount,
     275                        'external_domains' => array_values(array_unique($externalDomains)),
     276                    ]
     277                );
     278            }
     279
     280            if (count($spamKeywordMatches) >= 2) {
     281                return self::buildDecisionFromSuspiciousAction(
     282                    (string) ($settings['comment_suspicious_action'] ?? 'hold'),
     283                    'spam_keyword_cluster',
     284                    __('Comment contains multiple high-risk spam keyword categories.', 'vulntitan'),
     285                    [
     286                        'post_id' => $postId,
     287                        'rate_count' => $rateCount,
     288                        'window_minutes' => $rateWindowMinutes,
     289                        'matched_keywords' => $spamKeywordMatches,
     290                    ],
     291                    [
     292                        'comment_hash' => self::buildCommentHash($normalizedContent),
     293                        'matched_keywords' => $spamKeywordMatches,
     294                    ]
     295                );
     296            }
     297
     298            if ($topExternalDomain !== '' && $topExternalHits >= 2) {
     299                return self::buildDecisionFromSuspiciousAction(
     300                    (string) ($settings['comment_suspicious_action'] ?? 'hold'),
     301                    'repeated_external_domain',
     302                    __('Comment repeats the same external domain across the message and author website fields.', 'vulntitan'),
     303                    [
     304                        'post_id' => $postId,
     305                        'rate_count' => $rateCount,
     306                        'window_minutes' => $rateWindowMinutes,
     307                        'top_external_domain' => $topExternalDomain,
     308                        'top_external_domain_hits' => $topExternalHits,
     309                    ],
     310                    [
     311                        'comment_hash' => self::buildCommentHash($normalizedContent),
     312                        'link_count' => $linkCount,
     313                        'external_domains' => array_values(array_unique($externalDomains)),
     314                        'top_external_domain' => $topExternalDomain,
     315                        'top_external_domain_hits' => $topExternalHits,
     316                    ]
     317                );
     318            }
     319
     320            if ($promotionalPhraseMatches !== [] && $externalDomains !== []) {
     321                return self::buildDecisionFromSuspiciousAction(
     322                    (string) ($settings['comment_suspicious_action'] ?? 'hold'),
     323                    'promotional_link_comment',
     324                    __('Comment combines promotional phrases with an external link or website.', 'vulntitan'),
     325                    [
     326                        'post_id' => $postId,
     327                        'rate_count' => $rateCount,
     328                        'window_minutes' => $rateWindowMinutes,
     329                        'matched_promotions' => $promotionalPhraseMatches,
     330                        'link_count' => $linkCount,
     331                    ],
     332                    [
     333                        'comment_hash' => self::buildCommentHash($normalizedContent),
     334                        'external_domains' => array_values(array_unique($externalDomains)),
     335                        'matched_promotions' => $promotionalPhraseMatches,
     336                    ]
     337                );
     338            }
     339
     340            if ($externalDomains !== [] && $wordCount > 0 && $wordCount <= self::SHORT_COMMENT_MAX_WORDS && $contentLength <= self::SHORT_COMMENT_MAX_LENGTH) {
     341                return self::buildDecisionFromSuspiciousAction(
     342                    (string) ($settings['comment_suspicious_action'] ?? 'hold'),
     343                    'thin_external_link_comment',
     344                    __('Very short guest comment submitted with an external link or website.', 'vulntitan'),
     345                    [
     346                        'post_id' => $postId,
     347                        'rate_count' => $rateCount,
     348                        'window_minutes' => $rateWindowMinutes,
     349                        'word_count' => $wordCount,
     350                        'content_length' => $contentLength,
     351                        'author_url_domain' => $authorDomain,
     352                    ],
     353                    [
     354                        'comment_hash' => self::buildCommentHash($normalizedContent),
     355                        'link_count' => $linkCount,
     356                        'external_domains' => array_values(array_unique($externalDomains)),
     357                    ]
     358                );
     359            }
    243360
    244361            if ($linkCount > $maxLinks) {
     
    311428    ): array {
    312429        $details['action'] = $action;
     430
     431        if ($action === 'hold') {
     432            $details['approval_status'] = 'pending';
     433        }
    313434
    314435        return [
     
    352473    }
    353474
     475    protected static function logPendingModeration(array $commentData): void
     476    {
     477        $commentContent = isset($commentData['comment_content']) ? (string) wp_unslash($commentData['comment_content']) : '';
     478        $normalizedContent = self::normalizeCommentContent($commentContent);
     479        $authorUrl = isset($commentData['comment_author_url']) ? trim((string) wp_unslash($commentData['comment_author_url'])) : '';
     480        $authorDomain = self::normalizeDomain($authorUrl);
     481        $externalDomains = self::extractExternalDomains($commentContent, $authorDomain);
     482
     483        FirewallService::logEvent('comment_pending_review', [
     484            'event_source' => 'comment_shield',
     485            'event_action' => 'log',
     486            'blocked' => 0,
     487            'response_code' => 0,
     488            'severity' => 1,
     489            'ip_address' => self::resolveIpAddress($commentData),
     490            'username' => self::resolveUsername($commentData),
     491            'request_method' => strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'POST')),
     492            'request_uri' => (string) ($_SERVER['REQUEST_URI'] ?? ''),
     493            'request_path' => (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH),
     494            'rule_group' => 'comment_moderation',
     495            'rule_id' => 'wordpress_pending_review',
     496            'reason' => __('Comment entered the moderation queue.', 'vulntitan'),
     497            'details' => [
     498                'action' => 'hold',
     499                'approval_status' => 'pending',
     500                'post_id' => (int) ($commentData['comment_post_ID'] ?? 0),
     501                'link_count' => self::countLinks($commentContent),
     502                'author_url_domain' => $authorDomain,
     503            ],
     504            'context' => [
     505                'comment_hash' => self::buildCommentHash($normalizedContent),
     506                'external_domains' => array_values(array_unique($externalDomains)),
     507            ],
     508        ]);
     509    }
     510
    354511    protected static function isEnabled(): bool
    355512    {
     
    479636
    480637        return is_array($matches[0] ?? null) ? count($matches[0]) : 0;
     638    }
     639
     640    /**
     641     * @return array<int,string>
     642     */
     643    protected static function extractExternalDomains(string $content, string $authorDomain = ''): array
     644    {
     645        $domains = self::extractLinkDomains($content);
     646
     647        if ($authorDomain !== '') {
     648            $domains[] = $authorDomain;
     649        }
     650
     651        return array_values(array_filter($domains, [__CLASS__, 'isExternalDomain']));
     652    }
     653
     654    /**
     655     * @return array<int,string>
     656     */
     657    protected static function extractLinkDomains(string $content): array
     658    {
     659        if ($content === '') {
     660            return [];
     661        }
     662
     663        preg_match_all('~(?:https?://|www\.)[^\s<>"\']+~iu', $content, $matches);
     664        $rawUrls = is_array($matches[0] ?? null) ? $matches[0] : [];
     665        $domains = [];
     666
     667        foreach ($rawUrls as $rawUrl) {
     668            if (!is_string($rawUrl)) {
     669                continue;
     670            }
     671
     672            $domain = self::normalizeDomain($rawUrl);
     673            if ($domain !== '') {
     674                $domains[] = $domain;
     675            }
     676        }
     677
     678        return $domains;
     679    }
     680
     681    protected static function normalizeDomain(string $value): string
     682    {
     683        $value = trim($value);
     684        if ($value === '') {
     685            return '';
     686        }
     687
     688        if (strpos($value, '://') === false) {
     689            $value = 'https://' . ltrim($value, '/');
     690        }
     691
     692        $host = wp_parse_url($value, PHP_URL_HOST);
     693        if (!is_string($host) || $host === '') {
     694            return '';
     695        }
     696
     697        $host = strtolower(trim($host));
     698
     699        return strpos($host, 'www.') === 0 ? substr($host, 4) : $host;
     700    }
     701
     702    protected static function isExternalDomain(string $domain): bool
     703    {
     704        $normalizedDomain = self::normalizeDomain($domain);
     705        if ($normalizedDomain === '') {
     706            return false;
     707        }
     708
     709        $siteDomain = self::normalizeDomain((string) home_url('/'));
     710        if ($siteDomain === '') {
     711            return true;
     712        }
     713
     714        return $normalizedDomain !== $siteDomain
     715            && substr($normalizedDomain, -strlen('.' . $siteDomain)) !== '.' . $siteDomain;
     716    }
     717
     718    /**
     719     * @param array<int,string> $domains
     720     * @return array{0:string,1:int}
     721     */
     722    protected static function findTopDomain(array $domains): array
     723    {
     724        if ($domains === []) {
     725            return ['', 0];
     726        }
     727
     728        $counts = array_count_values(array_filter($domains, 'is_string'));
     729        if ($counts === []) {
     730            return ['', 0];
     731        }
     732
     733        arsort($counts);
     734        $topDomain = (string) array_key_first($counts);
     735
     736        return [$topDomain, (int) ($counts[$topDomain] ?? 0)];
     737    }
     738
     739    /**
     740     * @return array<int,string>
     741     */
     742    protected static function matchSpamKeywords(string $content): array
     743    {
     744        if ($content === '') {
     745            return [];
     746        }
     747
     748        $matches = [];
     749
     750        foreach (self::getSpamKeywordPatterns() as $label => $pattern) {
     751            if (!is_string($pattern) || @preg_match($pattern, '') === false) {
     752                continue;
     753            }
     754
     755            if (preg_match($pattern, $content)) {
     756                $matches[] = (string) $label;
     757            }
     758        }
     759
     760        return $matches;
     761    }
     762
     763    /**
     764     * @return array<int,string>
     765     */
     766    protected static function getSpamKeywordPatterns(): array
     767    {
     768        $patterns = [
     769            'casino' => '/\bcasino(?:s)?\b/iu',
     770            'betting' => '/\b(?:bet(?:s|ting)?|sportsbook|bookmaker(?:s)?)\b/iu',
     771            'gambling' => '/\b(?:gambl(?:e|ing)|wager(?:s|ing)?)\b/iu',
     772            'slots' => '/\b(?:slot(?:s)?|jackpot(?:s)?|roulette|blackjack|baccarat|poker|free\s*spin(?:s)?)\b/iu',
     773            'pharma' => '/\b(?:viagra|cialis|levitra|pharmacy|pill(?:s)?)\b/iu',
     774            'adult' => '/\b(?:adult|porn|xxx|escort(?:s)?)\b/iu',
     775            'loans' => '/\b(?:loan(?:s)?|payday\s*loan(?:s)?|cash\s*advance|debt\s*relief)\b/iu',
     776            'crypto' => '/\b(?:crypto|bitcoin|forex|trading\s*signal(?:s)?)\b/iu',
     777            'seo' => '/\b(?:seo(?:\s+services?)?|backlink(?:s)?|guest\s+post(?:ing)?|domain\s+authority)\b/iu',
     778        ];
     779
     780        $filtered = apply_filters('vulntitan_comment_spam_keyword_patterns', $patterns);
     781
     782        return is_array($filtered) ? $filtered : $patterns;
     783    }
     784
     785    /**
     786     * @return array<int,string>
     787     */
     788    protected static function matchPromotionalPhrases(string $content): array
     789    {
     790        if ($content === '') {
     791            return [];
     792        }
     793
     794        $matches = [];
     795
     796        foreach (self::getPromotionalPhrasePatterns() as $label => $pattern) {
     797            if (!is_string($pattern) || @preg_match($pattern, '') === false) {
     798                continue;
     799            }
     800
     801            if (preg_match($pattern, $content)) {
     802                $matches[] = (string) $label;
     803            }
     804        }
     805
     806        return $matches;
     807    }
     808
     809    /**
     810     * @return array<int|string,string>
     811     */
     812    protected static function getPromotionalPhrasePatterns(): array
     813    {
     814        $patterns = [
     815            'check_it_out' => '/\bcheck\s+(?:it|this|them)\s+out\b/iu',
     816            'go_to_spot' => '/\bgo-to\s+spot\b/iu',
     817            'quick_bets' => '/\bquick\s+bets?\b/iu',
     818            'best_odds' => '/\bbest\s+odds\b/iu',
     819            'join_now' => '/\bjoin\s+now\b/iu',
     820            'visit_site' => '/\bvisit\s+(?:my|our|this)\s+(?:site|website)\b/iu',
     821        ];
     822
     823        $filtered = apply_filters('vulntitan_comment_promotional_phrase_patterns', $patterns);
     824
     825        return is_array($filtered) ? $filtered : $patterns;
     826    }
     827
     828    protected static function countWords(string $content): int
     829    {
     830        if ($content === '') {
     831            return 0;
     832        }
     833
     834        preg_match_all('/[\p{L}\p{N}]+(?:[\'’-][\p{L}\p{N}]+)*/u', $content, $matches);
     835
     836        return is_array($matches[0] ?? null) ? count($matches[0]) : 0;
     837    }
     838
     839    protected static function measureLength(string $content): int
     840    {
     841        if ($content === '') {
     842            return 0;
     843        }
     844
     845        return function_exists('mb_strlen')
     846            ? (int) mb_strlen($content, 'UTF-8')
     847            : strlen($content);
    481848    }
    482849
  • vulntitan/trunk/includes/Services/FirewallService.php

    r3485911 r3490735  
    16981698            'total_events' => 0,
    16991699            'blocked' => 0,
     1700            'forms_blocked' => 0,
    17001701            'login_failed' => 0,
    17011702            'login_blocked' => 0,
     
    17081709            'comment_spam_blocked' => 0,
    17091710            'comment_spam_held' => 0,
     1711            'comment_pending_review' => 0,
    17101712            'comment_rate_limited' => 0,
    17111713            'unique_attackers' => 0,
     
    17251727                    COUNT(*) AS total_events,
    17261728                    SUM(CASE WHEN {$blockedCondition} THEN 1 ELSE 0 END) AS blocked,
     1729                    SUM(CASE WHEN event_type = 'request_blocked' AND event_source IN ('contact_form_7', 'fluent_forms') THEN 1 ELSE 0 END) AS forms_blocked,
    17271730                    SUM(CASE WHEN event_type = 'login_failed' THEN 1 ELSE 0 END) AS login_failed,
    17281731                    SUM(CASE WHEN event_type = 'login_blocked' THEN 1 ELSE 0 END) AS login_blocked,
     
    17351738                    SUM(CASE WHEN event_type = 'comment_spam_blocked' THEN 1 ELSE 0 END) AS comment_spam_blocked,
    17361739                    SUM(CASE WHEN event_type = 'comment_spam_held' THEN 1 ELSE 0 END) AS comment_spam_held,
     1740                    SUM(CASE WHEN event_type = 'comment_pending_review' THEN 1 ELSE 0 END) AS comment_pending_review,
    17371741                    SUM(CASE WHEN event_type = 'comment_rate_limited' THEN 1 ELSE 0 END) AS comment_rate_limited,
    17381742                    COUNT(DISTINCT CASE WHEN {$blockedCondition} AND ip_hash <> '' THEN ip_hash ELSE NULL END) AS unique_attackers
  • vulntitan/trunk/includes/Services/WeeklySummaryEmailService.php

    r3481890 r3490735  
    165165        $periodLabel = trim((string) ($period['start_local_display'] ?? '') . ' - ' . (string) ($period['end_local_display'] ?? ''));
    166166        $blockedTotal = (int) ($totals['blocked'] ?? 0);
     167        $formsBlocked = (int) ($totals['forms_blocked'] ?? 0);
    167168        $loginFailed = (int) ($totals['login_failed'] ?? 0);
    168169        $lockouts = (int) ($totals['login_lockout'] ?? 0);
     
    171172        $commentSpamBlocked = (int) ($totals['comment_spam_blocked'] ?? 0);
    172173        $commentSpamHeld = (int) ($totals['comment_spam_held'] ?? 0);
     174        $commentPendingReview = (int) ($totals['comment_pending_review'] ?? 0);
    173175        $commentRateLimited = (int) ($totals['comment_rate_limited'] ?? 0);
    174176        $uniqueAttackers = (int) ($totals['unique_attackers'] ?? 0);
     177        $commentQueueTotal = $commentSpamHeld + $commentPendingReview;
     178        $commentSpamStopped = $commentSpamBlocked + $commentRateLimited;
    175179        $posture = self::getPostureSummary($totals);
    176180        $preheader = sprintf(
    177             __('%1$s weekly security digest for %2$s. %3$s blocked events, %4$s failed logins, %5$s SQL injection attempts.', 'vulntitan'),
     181            __('%1$s weekly security digest for %2$s. %3$s blocked events, %4$s failed logins, %5$s form spam blocks, %6$s queued comments.', 'vulntitan'),
    178182            'VulnTitan',
    179183            $siteName,
    180184            self::formatNumber($blockedTotal),
    181185            self::formatNumber($loginFailed),
    182             self::formatNumber($sqli)
     186            self::formatNumber($formsBlocked),
     187            self::formatNumber($commentQueueTotal)
    183188        );
    184189        $generatedAt = self::formatLocalTimestamp(time(), 'M j, Y H:i');
    185190        $policySnapshot = self::buildPolicySnapshot();
    186         $highlights = self::buildHighlights($daily);
     191        $highlights = self::buildHighlights($daily, $totals);
    187192
    188193        $metricCards = [
     
    190195                'label' => __('Blocked Events', 'vulntitan'),
    191196                'value' => $blockedTotal,
    192                 'note' => __('Firewall, comment shield, and lockout interventions', 'vulntitan'),
     197                'note' => __('Firewall, login shield, comment shield, and form shield interventions', 'vulntitan'),
    193198                'accent' => '#0f9bd7',
    194199            ],
     
    206211            ],
    207212            [
     213                'label' => __('Form Spam Blocks', 'vulntitan'),
     214                'value' => $formsBlocked,
     215                'note' => __('Blocked Contact Form 7 and Fluent Forms submissions', 'vulntitan'),
     216                'accent' => '#22c55e',
     217            ],
     218            [
     219                'label' => __('Comment Spam Blocks', 'vulntitan'),
     220                'value' => $commentSpamStopped,
     221                'note' => __('Spam comments denied before publication', 'vulntitan'),
     222                'accent' => '#8b5cf6',
     223            ],
     224            [
     225                'label' => __('Comment Queue Holds', 'vulntitan'),
     226                'value' => $commentQueueTotal,
     227                'note' => __('Comment Shield holds plus WordPress moderation queue entries', 'vulntitan'),
     228                'accent' => '#f97316',
     229            ],
     230            [
    208231                'label' => __('SQLi Blocks', 'vulntitan'),
    209232                'value' => $sqli,
    210233                'note' => __('Malicious query payloads denied', 'vulntitan'),
    211                 'accent' => '#22c55e',
    212             ],
    213             [
    214                 'label' => __('Comment Spam Blocks', 'vulntitan'),
    215                 'value' => $commentSpamBlocked + $commentRateLimited,
    216                 'note' => __('Spam comments denied before publication', 'vulntitan'),
    217                 'accent' => '#8b5cf6',
    218             ],
    219             [
    220                 'label' => __('Comment Spam Held', 'vulntitan'),
    221                 'value' => $commentSpamHeld,
    222                 'note' => __('Suspicious comments sent to moderation', 'vulntitan'),
    223                 'accent' => '#f97316',
     234                'accent' => '#14b8a6',
     235            ],
     236            [
     237                'label' => __('Unique Attack Sources', 'vulntitan'),
     238                'value' => $uniqueAttackers,
     239                'note' => __('Distinct blocked IP fingerprints observed', 'vulntitan'),
     240                'accent' => '#2563eb',
    224241            ],
    225242        ];
    226243
    227244        $threatRows = [
     245            [
     246                'label' => __('Form Spam Blocks', 'vulntitan'),
     247                'value' => $formsBlocked,
     248                'tone' => '#22c55e',
     249            ],
     250            [
     251                'label' => __('Comment Pending Review', 'vulntitan'),
     252                'value' => $commentPendingReview,
     253                'tone' => '#f59e0b',
     254            ],
    228255            [
    229256                'label' => __('Command Injection Blocks', 'vulntitan'),
     
    268295        ];
    269296
    270         $html = '<!doctype html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head><body style="margin:0;padding:0;background-color:#edf2f7;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;color:#0f172a;">';
     297        $html = '<!doctype html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">' . self::renderEmailStyles() . '</head><body class="vt-body" style="margin:0;padding:0;background-color:#edf2f7;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;color:#0f172a;">';
    271298        $html .= '<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">' . esc_html($preheader) . '</div>';
    272         $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#edf2f7;margin:0;padding:24px 0;width:100%;"><tr><td align="center">';
    273         $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="max-width:720px;width:100%;margin:0 auto;">';
    274 
    275         $html .= '<tr><td style="padding:0 16px 16px 16px;">';
     299        $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" class="vt-shell" style="background-color:#edf2f7;margin:0;padding:24px 0;width:100%;"><tr><td align="center">';
     300        $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" class="vt-container" style="max-width:720px;width:100%;margin:0 auto;">';
     301
     302        $html .= '<tr><td class="vt-section-pad" style="padding:0 16px 16px 16px;">';
    276303        $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#0b1726;border-radius:24px;overflow:hidden;">';
    277         $html .= '<tr><td style="padding:18px 24px;background-color:#09111d;border-bottom:1px solid #14324f;">';
     304        $html .= '<tr><td class="vt-card-pad" style="padding:18px 24px;background-color:#09111d;border-bottom:1px solid #14324f;">';
    278305        $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"><tr>';
    279306        $html .= '<td style="vertical-align:middle;">';
     
    284311        }
    285312        $html .= '</td>';
    286         $html .= '<td align="right" style="vertical-align:middle;padding-left:16px;">';
     313        $html .= '<td align="right" class="vt-brand-right" style="vertical-align:middle;padding-left:16px;">';
    287314        $html .= '<span style="display:inline-block;font-size:12px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:#38bdf8;">Weekly Digest</span>';
    288315        $html .= '</td>';
    289316        $html .= '</tr></table>';
    290317        $html .= '</td></tr>';
    291         $html .= '<tr><td style="padding:30px 24px 28px 24px;background-color:#0b1726;">';
    292         $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"><tr>';
    293         $html .= '<td style="vertical-align:top;padding-right:16px;">';
    294         $html .= '<div style="font-size:28px;line-height:1.2;font-weight:800;color:#f8fafc;margin:0 0 10px 0;">' . esc_html($siteName) . '</div>';
     318        $html .= '<tr><td class="vt-card-pad" style="padding:30px 24px 28px 24px;background-color:#0b1726;">';
     319        $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" class="vt-stack-table"><tr>';
     320        $html .= '<td class="vt-hero-left" style="vertical-align:top;padding-right:16px;">';
     321        $html .= '<div class="vt-hero-title" style="font-size:28px;line-height:1.2;font-weight:800;color:#f8fafc;margin:0 0 10px 0;">' . esc_html($siteName) . '</div>';
    295322        $html .= '<div style="font-size:15px;line-height:1.7;color:#cbd5e1;margin:0 0 18px 0;">' . esc_html__('Your firewall and login telemetry summary for the previous 7 complete days.', 'vulntitan') . '</div>';
    296         $html .= '<div style="display:inline-block;padding:9px 14px;border-radius:999px;background-color:#11263c;color:#7dd3fc;font-size:12px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;">' . esc_html($periodLabel) . '</div>';
     323        $html .= '<div class="vt-inline-chip" style="display:inline-block;padding:9px 14px;border-radius:999px;background-color:#11263c;color:#7dd3fc;font-size:12px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;">' . esc_html($periodLabel) . '</div>';
    297324        $html .= '</td>';
    298         $html .= '<td align="right" style="vertical-align:top;width:220px;">';
    299         $html .= '<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="width:220px;background-color:#0f2237;border:1px solid #1d4061;border-radius:20px;">';
     325        $html .= '<td align="right" class="vt-hero-right" style="vertical-align:top;width:220px;">';
     326        $html .= '<table role="presentation" cellspacing="0" cellpadding="0" border="0" class="vt-blocked-card" style="width:220px;background-color:#0f2237;border:1px solid #1d4061;border-radius:20px;">';
    300327        $html .= '<tr><td style="padding:18px 18px 8px 18px;font-size:12px;letter-spacing:0.12em;text-transform:uppercase;color:#93c5fd;font-weight:700;">' . esc_html__('Blocked This Week', 'vulntitan') . '</td></tr>';
    301         $html .= '<tr><td style="padding:0 18px 8px 18px;font-size:38px;line-height:1;font-weight:800;color:#ffffff;">' . esc_html(self::formatNumber($blockedTotal)) . '</td></tr>';
     328        $html .= '<tr><td class="vt-blocked-value" style="padding:0 18px 8px 18px;font-size:38px;line-height:1;font-weight:800;color:#ffffff;">' . esc_html(self::formatNumber($blockedTotal)) . '</td></tr>';
    302329        $html .= '<tr><td style="padding:0 18px 18px 18px;font-size:13px;line-height:1.7;color:#cbd5e1;">' . esc_html($posture['summary']) . '</td></tr>';
    303330        $html .= '</table>';
     
    308335        $html .= '</td></tr>';
    309336
    310         $html .= '<tr><td style="padding:0 16px 16px 16px;">' . self::renderMetricGrid($metricCards) . '</td></tr>';
    311 
    312         $html .= '<tr><td style="padding:0 16px 16px 16px;">';
     337        $html .= '<tr><td class="vt-section-pad" style="padding:0 16px 16px 16px;">' . self::renderMetricGrid($metricCards) . '</td></tr>';
     338
     339        $html .= '<tr><td class="vt-section-pad" style="padding:0 16px 16px 16px;">';
    313340        $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#ffffff;border:1px solid #dbe6f0;border-radius:24px;">';
    314         $html .= '<tr><td style="padding:24px 24px 14px 24px;">';
     341        $html .= '<tr><td class="vt-card-pad" style="padding:24px 24px 14px 24px;">';
    315342        $html .= '<div style="font-size:20px;line-height:1.3;font-weight:800;color:#0f172a;margin:0 0 8px 0;">' . esc_html__('Threat Breakdown', 'vulntitan') . '</div>';
    316         $html .= '<div style="font-size:14px;line-height:1.7;color:#475569;">' . esc_html__('A focused view of what the firewall and login shield handled during the reporting window.', 'vulntitan') . '</div>';
    317         $html .= '</td></tr>';
    318         $html .= '<tr><td style="padding:0 24px 24px 24px;">' . self::renderThreatBreakdown($threatRows) . '</td></tr>';
     343        $html .= '<div style="font-size:14px;line-height:1.7;color:#475569;">' . esc_html__('A focused view of what the firewall, login shield, comment shield, and form shield handled during the reporting window.', 'vulntitan') . '</div>';
     344        $html .= '</td></tr>';
     345        $html .= '<tr><td class="vt-card-pad" style="padding:0 24px 24px 24px;">' . self::renderThreatBreakdown($threatRows) . '</td></tr>';
    319346        $html .= '</table>';
    320347        $html .= '</td></tr>';
    321348
    322         $html .= '<tr><td style="padding:0 16px 16px 16px;">';
    323         $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">';
     349        $html .= '<tr><td class="vt-section-pad" style="padding:0 16px 16px 16px;">';
     350        $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" class="vt-stack-table">';
    324351        $html .= '<tr>';
    325         $html .= '<td style="width:58%;padding:0 8px 0 0;vertical-align:top;">' . self::renderDailyHistory($daily) . '</td>';
    326         $html .= '<td style="width:42%;padding:0 0 0 8px;vertical-align:top;">' . self::renderHighlightsPanel($highlights, $topPaths, $topRules) . '</td>';
     352        $html .= '<td class="vt-two-col-left" style="width:58%;padding:0 8px 0 0;vertical-align:top;">' . self::renderDailyHistory($daily) . '</td>';
     353        $html .= '<td class="vt-two-col-right" style="width:42%;padding:0 0 0 8px;vertical-align:top;">' . self::renderHighlightsPanel($highlights, $topPaths, $topRules) . '</td>';
    327354        $html .= '</tr>';
    328355        $html .= '</table>';
    329356        $html .= '</td></tr>';
    330357
    331         $html .= '<tr><td style="padding:0 16px 16px 16px;">';
     358        $html .= '<tr><td class="vt-section-pad" style="padding:0 16px 16px 16px;">';
    332359        $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#ffffff;border:1px solid #dbe6f0;border-radius:24px;">';
    333         $html .= '<tr><td style="padding:24px 24px 14px 24px;">';
     360        $html .= '<tr><td class="vt-card-pad" style="padding:24px 24px 14px 24px;">';
    334361        $html .= '<div style="font-size:20px;line-height:1.3;font-weight:800;color:#0f172a;margin:0 0 8px 0;">' . esc_html__('Protection Profile', 'vulntitan') . '</div>';
    335         $html .= '<div style="font-size:14px;line-height:1.7;color:#475569;">' . esc_html__('Current policy posture that shaped the detection and containment shown above.', 'vulntitan') . '</div>';
    336         $html .= '</td></tr>';
    337         $html .= '<tr><td style="padding:0 24px 24px 24px;">' . $policySnapshot . '</td></tr>';
     362        $html .= '<div style="font-size:14px;line-height:1.7;color:#475569;">' . esc_html__('Authentication, WAF, anti-spam, and telemetry controls that shaped the detections shown above.', 'vulntitan') . '</div>';
     363        $html .= '</td></tr>';
     364        $html .= '<tr><td class="vt-card-pad" style="padding:0 24px 24px 24px;">' . $policySnapshot . '</td></tr>';
    338365        $html .= '</table>';
    339366        $html .= '</td></tr>';
    340367
    341         $html .= '<tr><td style="padding:0 16px 0 16px;">';
     368        $html .= '<tr><td class="vt-section-pad" style="padding:0 16px 0 16px;">';
    342369        $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#0f172a;border-radius:24px;">';
    343         $html .= '<tr><td style="padding:22px 24px;">';
     370        $html .= '<tr><td class="vt-card-pad" style="padding:22px 24px;">';
    344371        $html .= '<div style="font-size:14px;line-height:1.8;color:#cbd5e1;">';
    345372        $html .= esc_html__('Generated by VulnTitan for your primary site administrator.', 'vulntitan') . ' ';
     
    360387    protected static function renderMetricCard(string $label, int $value, string $note, string $accent): string
    361388    {
    362         return '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#ffffff;border:1px solid #dbe6f0;border-radius:20px;">'
     389        return '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" class="vt-metric-card" style="background-color:#ffffff;border:1px solid #dbe6f0;border-radius:20px;">'
    363390            . '<tr><td style="padding:20px 20px 8px 20px;font-size:12px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:' . esc_attr($accent) . ';">' . esc_html($label) . '</td></tr>'
    364             . '<tr><td style="padding:0 20px 8px 20px;font-size:34px;line-height:1;font-weight:800;color:#0f172a;">' . esc_html(self::formatNumber($value)) . '</td></tr>'
     391            . '<tr><td class="vt-metric-value" style="padding:0 20px 8px 20px;font-size:34px;line-height:1;font-weight:800;color:#0f172a;">' . esc_html(self::formatNumber($value)) . '</td></tr>'
    365392            . '<tr><td style="padding:0 20px 20px 20px;font-size:14px;line-height:1.7;color:#475569;">' . esc_html($note) . '</td></tr>'
    366393            . '</table>';
     
    369396    protected static function renderMetricGrid(array $cards): string
    370397    {
    371         $html = '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">';
     398        $html = '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" class="vt-metric-grid">';
    372399        $chunks = array_chunk($cards, 2);
    373400
    374401        foreach ($chunks as $rowIndex => $rowCards) {
    375             $html .= '<tr>';
     402            $html .= '<tr class="vt-metric-row">';
    376403
    377404            foreach ($rowCards as $cardIndex => $card) {
     
    381408                $paddingBottom = $rowIndex < count($chunks) - 1 ? '16px' : '0';
    382409
    383                 $html .= '<td style="width:50%;padding:0 ' . $paddingRight . ' ' . $paddingBottom . ' ' . $paddingLeft . ';vertical-align:top;">';
     410                $html .= '<td class="vt-metric-cell" style="width:50%;padding:0 ' . $paddingRight . ' ' . $paddingBottom . ' ' . $paddingLeft . ';vertical-align:top;">';
    384411                $html .= self::renderMetricCard(
    385412                    (string) ($card['label'] ?? ''),
     
    393420            if (count($rowCards) === 1) {
    394421                $paddingBottom = $rowIndex < count($chunks) - 1 ? '16px' : '0';
    395                 $html .= '<td style="width:50%;padding:0 0 ' . $paddingBottom . ' 8px;vertical-align:top;"></td>';
     422                $html .= '<td class="vt-metric-cell" style="width:50%;padding:0 0 ' . $paddingBottom . ' 8px;vertical-align:top;"></td>';
    396423            }
    397424
     
    433460
    434461        $html = '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#ffffff;border:1px solid #dbe6f0;border-radius:24px;">';
    435         $html .= '<tr><td style="padding:24px 24px 14px 24px;">';
     462        $html .= '<tr><td class="vt-card-pad" style="padding:24px 24px 14px 24px;">';
    436463        $html .= '<div style="font-size:20px;line-height:1.3;font-weight:800;color:#0f172a;margin:0 0 8px 0;">' . esc_html__('7-Day Activity Timeline', 'vulntitan') . '</div>';
    437         $html .= '<div style="font-size:14px;line-height:1.7;color:#475569;">' . esc_html__('Blocked traffic, login failures, and WAF detections day by day.', 'vulntitan') . '</div>';
    438         $html .= '</td></tr>';
    439         $html .= '<tr><td style="padding:0 24px 24px 24px;">';
     464        $html .= '<div style="font-size:14px;line-height:1.7;color:#475569;">' . esc_html__('Blocked traffic, login pressure, form abuse, and comment moderation signals day by day.', 'vulntitan') . '</div>';
     465        $html .= '</td></tr>';
     466        $html .= '<tr><td class="vt-card-pad" style="padding:0 24px 24px 24px;">';
    440467
    441468        foreach ($daily as $index => $day) {
    442469            $counts = is_array($day['counts'] ?? null) ? $day['counts'] : [];
    443470            $blocked = (int) ($counts['blocked'] ?? 0);
     471            $formsBlocked = (int) ($counts['forms_blocked'] ?? 0);
     472            $commentStopped = (int) ($counts['comment_spam_blocked'] ?? 0) + (int) ($counts['comment_rate_limited'] ?? 0);
     473            $commentQueue = (int) ($counts['comment_spam_held'] ?? 0) + (int) ($counts['comment_pending_review'] ?? 0);
    444474            $percent = ($maxBlocked > 0 && $blocked > 0) ? max(12, (int) round(($blocked / $maxBlocked) * 100)) : 0;
    445475            $border = $index < count($daily) - 1 ? 'border-bottom:1px solid #e2e8f0;' : '';
     
    447477            $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="' . $border . '">';
    448478            $html .= '<tr>';
    449             $html .= '<td style="padding:12px 0;width:110px;font-size:13px;font-weight:700;color:#0f172a;vertical-align:top;">' . esc_html((string) ($day['label'] ?? '')) . '</td>';
    450             $html .= '<td style="padding:12px 14px 12px 0;vertical-align:top;">';
     479            $html .= '<td class="vt-daily-label" style="padding:12px 0;width:110px;font-size:13px;font-weight:700;color:#0f172a;vertical-align:top;">' . esc_html((string) ($day['label'] ?? '')) . '</td>';
     480            $html .= '<td class="vt-daily-data" style="padding:12px 14px 12px 0;vertical-align:top;">';
    451481            $html .= '<div style="height:8px;border-radius:999px;background-color:#e2e8f0;overflow:hidden;margin:5px 0 10px 0;">';
    452482            $html .= '<span style="display:block;height:8px;width:' . esc_attr((string) $percent) . '%;background-color:#0f9bd7;border-radius:999px;"></span>';
     
    454484            $html .= '<div style="font-size:12px;line-height:1.8;color:#64748b;">';
    455485            $html .= esc_html__('Blocked', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber($blocked)) . '</strong> &nbsp; ';
     486            $html .= esc_html__('Forms', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber($formsBlocked)) . '</strong> &nbsp; ';
     487            $html .= esc_html__('Comment Stops', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber($commentStopped)) . '</strong> &nbsp; ';
     488            $html .= esc_html__('Queue', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber($commentQueue)) . '</strong> &nbsp; ';
    456489            $html .= esc_html__('Failed Logins', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber((int) ($counts['login_failed'] ?? 0))) . '</strong> &nbsp; ';
    457490            $html .= esc_html__('SQLi', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber((int) ($counts['sqli'] ?? 0))) . '</strong> &nbsp; ';
     
    472505    {
    473506        $html = '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#ffffff;border:1px solid #dbe6f0;border-radius:24px;margin-bottom:16px;">';
    474         $html .= '<tr><td style="padding:24px 24px 14px 24px;">';
     507        $html .= '<tr><td class="vt-card-pad" style="padding:24px 24px 14px 24px;">';
    475508        $html .= '<div style="font-size:20px;line-height:1.3;font-weight:800;color:#0f172a;margin:0 0 8px 0;">' . esc_html__('Highlights', 'vulntitan') . '</div>';
    476509        $html .= '<div style="font-size:14px;line-height:1.7;color:#475569;">' . esc_html__('The most important takeaways from this reporting period.', 'vulntitan') . '</div>';
    477510        $html .= '</td></tr>';
    478         $html .= '<tr><td style="padding:0 24px 24px 24px;">';
     511        $html .= '<tr><td class="vt-card-pad" style="padding:0 24px 24px 24px;">';
    479512
    480513        foreach ($highlights as $index => $highlight) {
     
    523556                ];
    524557            },
    525             __('No firewall or comment-shield rules were triggered in this period.', 'vulntitan')
     558            __('No firewall, login, or form-shield rules were triggered in this period.', 'vulntitan')
    526559        );
    527560
     
    532565    {
    533566        $html = '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#ffffff;border:1px solid #dbe6f0;border-radius:24px;">';
    534         $html .= '<tr><td style="padding:24px 24px 14px 24px;">';
     567        $html .= '<tr><td class="vt-card-pad" style="padding:24px 24px 14px 24px;">';
    535568        $html .= '<div style="font-size:18px;line-height:1.3;font-weight:800;color:#0f172a;margin:0 0 8px 0;">' . esc_html($title) . '</div>';
    536569        $html .= '</td></tr>';
    537         $html .= '<tr><td style="padding:0 24px 24px 24px;">';
     570        $html .= '<tr><td class="vt-card-pad" style="padding:0 24px 24px 24px;">';
    538571
    539572        if (!$rows) {
     
    583616            ],
    584617            [
     618                'label' => __('Two-Factor Authentication', 'vulntitan'),
     619                'value' => self::buildTwoFactorSummary($settings),
     620            ],
     621            [
     622                'label' => __('CAPTCHA Coverage', 'vulntitan'),
     623                'value' => self::buildCaptchaSummary($settings),
     624            ],
     625            [
     626                'label' => __('XML-RPC Policy', 'vulntitan'),
     627                'value' => self::buildXmlrpcSummary($settings),
     628            ],
     629            [
     630                'label' => __('Weak Password Blocking', 'vulntitan'),
     631                'value' => !empty($settings['weak_password_blocking_enabled']) ? __('Enabled', 'vulntitan') : __('Disabled', 'vulntitan'),
     632            ],
     633            [
    585634                'label' => __('Max Failed Attempts', 'vulntitan'),
    586635                'value' => self::formatNumber((int) ($settings['max_attempts'] ?? 0)),
     
    594643            ],
    595644            [
     645                'label' => __('SQLi Rule Set', 'vulntitan'),
     646                'value' => !empty($settings['waf_sqli_enabled']) ? __('Enabled', 'vulntitan') : __('Disabled', 'vulntitan'),
     647            ],
     648            [
     649                'label' => __('Command Injection Rule Set', 'vulntitan'),
     650                'value' => !empty($settings['waf_command_injection_enabled']) ? __('Enabled', 'vulntitan') : __('Disabled', 'vulntitan'),
     651            ],
     652            [
     653                'label' => __('Learning Mode', 'vulntitan'),
     654                'value' => self::buildLearningModeSummary($settings),
     655            ],
     656            [
     657                'label' => __('Proxy Trust Handling', 'vulntitan'),
     658                'value' => self::buildProxyTrustSummary($settings),
     659            ],
     660            [
    596661                'label' => __('Comment Shield', 'vulntitan'),
    597662                'value' => !empty($settings['comment_shield_enabled']) ? __('Enabled', 'vulntitan') : __('Disabled', 'vulntitan'),
     
    604669            ],
    605670            [
    606                 'label' => __('Comment Rate Window', 'vulntitan'),
     671                'label' => __('Minimum Submit Time', 'vulntitan'),
     672                'value' => sprintf(
     673                    __('%s seconds', 'vulntitan'),
     674                    self::formatNumber((int) ($settings['submission_min_submit_seconds'] ?? $settings['comment_min_submit_seconds'] ?? 0))
     675                ),
     676            ],
     677            [
     678                'label' => __('Guest Link Limit', 'vulntitan'),
     679                'value' => sprintf(
     680                    __('%s links', 'vulntitan'),
     681                    self::formatNumber((int) ($settings['submission_max_links'] ?? $settings['comment_max_links'] ?? 0))
     682                ),
     683            ],
     684            [
     685                'label' => __('Submission Rate Window', 'vulntitan'),
    607686                'value' => sprintf(
    608687                    __('%1$s attempts / %2$s minutes', 'vulntitan'),
    609                     self::formatNumber((int) ($settings['comment_rate_limit_attempts'] ?? 0)),
    610                     self::formatNumber((int) ($settings['comment_rate_limit_window_minutes'] ?? 0))
     688                    self::formatNumber((int) ($settings['submission_rate_limit_attempts'] ?? $settings['comment_rate_limit_attempts'] ?? 0)),
     689                    self::formatNumber((int) ($settings['submission_rate_limit_window_minutes'] ?? $settings['comment_rate_limit_window_minutes'] ?? 0))
    611690                ),
     691            ],
     692            [
     693                'label' => __('Form Shield', 'vulntitan'),
     694                'value' => !empty($settings['form_shield_enabled']) ? __('Enabled', 'vulntitan') : __('Disabled', 'vulntitan'),
     695            ],
     696            [
     697                'label' => __('Protected Form Providers', 'vulntitan'),
     698                'value' => self::buildFormProvidersSummary($settings),
    612699            ],
    613700            [
     
    624711            $border = $index < count($rows) - 1 ? 'border-bottom:1px solid #e2e8f0;' : '';
    625712            $html .= '<tr>';
    626             $html .= '<td style="padding:12px 0;' . $border . 'font-size:14px;line-height:1.7;color:#475569;width:42%;">' . esc_html((string) $row['label']) . '</td>';
    627             $html .= '<td style="padding:12px 0;' . $border . 'font-size:14px;line-height:1.7;font-weight:700;color:#0f172a;">' . esc_html((string) $row['value']) . '</td>';
     713            $html .= '<td class="vt-policy-label" style="padding:12px 0;' . $border . 'font-size:14px;line-height:1.7;color:#475569;width:42%;">' . esc_html((string) $row['label']) . '</td>';
     714            $html .= '<td class="vt-policy-value" style="padding:12px 0;' . $border . 'font-size:14px;line-height:1.7;font-weight:700;color:#0f172a;">' . esc_html((string) $row['value']) . '</td>';
    628715            $html .= '</tr>';
    629716        }
     
    633720    }
    634721
    635     protected static function buildHighlights(array $daily): array
     722    protected static function buildHighlights(array $daily, array $totals = []): array
    636723    {
    637724        $peakDay = null;
     
    640727        $peakLogins = -1;
    641728        $activeDays = 0;
     729        $formsBlocked = (int) ($totals['forms_blocked'] ?? 0);
     730        $commentSpamHeld = (int) ($totals['comment_spam_held'] ?? 0);
     731        $commentPendingReview = (int) ($totals['comment_pending_review'] ?? 0);
     732        $commentStops = (int) ($totals['comment_spam_blocked'] ?? 0) + (int) ($totals['comment_rate_limited'] ?? 0);
    642733
    643734        foreach ($daily as $day) {
     
    690781                ),
    691782            ],
     783            [
     784                'title' => __('Abuse Filters', 'vulntitan'),
     785                'body' => ($formsBlocked + $commentStops + $commentSpamHeld + $commentPendingReview) > 0
     786                    ? sprintf(
     787                        __('Form Shield blocked %1$s submissions, comment filtering stopped %2$s comments, and %3$s comments were held or queued for review.', 'vulntitan'),
     788                        self::formatNumber($formsBlocked),
     789                        self::formatNumber($commentStops),
     790                        self::formatNumber($commentSpamHeld + $commentPendingReview)
     791                    )
     792                    : __('No form spam or suspicious comment activity required intervention during this reporting period.', 'vulntitan'),
     793            ],
    692794        ];
    693795
    694796        return $highlights;
     797    }
     798
     799    protected static function renderEmailStyles(): string
     800    {
     801        return '<style type="text/css">'
     802            . 'body{margin:0;padding:0;background-color:#edf2f7;}'
     803            . 'table{border-collapse:separate;}'
     804            . '@media only screen and (max-width:620px){'
     805            . '.vt-shell{padding:16px 0 !important;}'
     806            . '.vt-container{width:100% !important;}'
     807            . '.vt-section-pad{padding-left:12px !important;padding-right:12px !important;}'
     808            . '.vt-card-pad{padding-left:18px !important;padding-right:18px !important;}'
     809            . '.vt-brand-right{display:block !important;width:100% !important;text-align:left !important;padding:12px 0 0 0 !important;}'
     810            . '.vt-hero-left,.vt-hero-right,.vt-two-col-left,.vt-two-col-right,.vt-metric-cell,.vt-policy-label,.vt-policy-value,.vt-daily-label,.vt-daily-data{display:block !important;width:100% !important;}'
     811            . '.vt-hero-left{padding-right:0 !important;padding-bottom:16px !important;}'
     812            . '.vt-hero-right{padding-left:0 !important;text-align:left !important;}'
     813            . '.vt-two-col-left{padding:0 0 16px 0 !important;}'
     814            . '.vt-two-col-right{padding:0 !important;}'
     815            . '.vt-metric-row{display:block !important;}'
     816            . '.vt-metric-cell{padding:0 0 12px 0 !important;}'
     817            . '.vt-metric-cell:last-child{padding-bottom:0 !important;}'
     818            . '.vt-blocked-card{width:100% !important;}'
     819            . '.vt-hero-title{font-size:24px !important;}'
     820            . '.vt-blocked-value,.vt-metric-value{font-size:30px !important;}'
     821            . '.vt-inline-chip{display:block !important;margin:0 0 8px 0 !important;text-align:center !important;}'
     822            . '.vt-daily-label{padding-bottom:8px !important;}'
     823            . '.vt-daily-data{padding-right:0 !important;}'
     824            . '.vt-policy-label{padding-bottom:2px !important;border-bottom:none !important;}'
     825            . '.vt-policy-value{padding-top:0 !important;padding-bottom:12px !important;}'
     826            . '}'
     827            . '</style>';
     828    }
     829
     830    protected static function buildTwoFactorSummary(array $settings): string
     831    {
     832        if (empty($settings['two_factor_enabled'])) {
     833            return __('Disabled', 'vulntitan');
     834        }
     835
     836        $roles = is_array($settings['two_factor_roles'] ?? null) ? $settings['two_factor_roles'] : [];
     837        $roleLabel = $roles ? self::implodeHumanList(array_map([__CLASS__, 'humanizeIdentifier'], $roles)) : __('selected roles', 'vulntitan');
     838        $trustedDays = max(0, (int) ($settings['trusted_device_days'] ?? 0));
     839
     840        return sprintf(
     841            __('Enabled for %1$s with %2$s-day trusted devices', 'vulntitan'),
     842            $roleLabel,
     843            self::formatNumber($trustedDays)
     844        );
     845    }
     846
     847    protected static function buildCaptchaSummary(array $settings): string
     848    {
     849        $provider = (string) ($settings['captcha_provider'] ?? 'none');
     850        if ($provider === '' || $provider === 'none') {
     851            return __('Disabled', 'vulntitan');
     852        }
     853
     854        $surfaces = [];
     855        if (!empty($settings['captcha_login_enabled'])) {
     856            $surfaces[] = __('login', 'vulntitan');
     857        }
     858        if (!empty($settings['captcha_register_enabled'])) {
     859            $surfaces[] = __('registration', 'vulntitan');
     860        }
     861        if (!empty($settings['captcha_lostpassword_enabled'])) {
     862            $surfaces[] = __('password reset', 'vulntitan');
     863        }
     864        if (!empty($settings['captcha_comment_enabled'])) {
     865            $surfaces[] = __('comments', 'vulntitan');
     866        }
     867
     868        $providerLabel = self::humanizeCaptchaProvider($provider);
     869        if (!$surfaces) {
     870            return sprintf(
     871                __('%s configured but not assigned to a protected flow', 'vulntitan'),
     872                $providerLabel
     873            );
     874        }
     875
     876        return sprintf(
     877            __('%1$s on %2$s', 'vulntitan'),
     878            $providerLabel,
     879            self::implodeHumanList($surfaces)
     880        );
     881    }
     882
     883    protected static function buildXmlrpcSummary(array $settings): string
     884    {
     885        $policy = (string) ($settings['xmlrpc_policy'] ?? 'allow');
     886
     887        if ($policy === 'rate_limit') {
     888            return sprintf(
     889                __('Rate limited at %1$s attempts / %2$s minutes', 'vulntitan'),
     890                self::formatNumber((int) ($settings['xmlrpc_rate_limit_attempts'] ?? 0)),
     891                self::formatNumber((int) ($settings['xmlrpc_rate_limit_window_minutes'] ?? 0))
     892            );
     893        }
     894
     895        if ($policy === 'block') {
     896            return __('Blocked except allowlist', 'vulntitan');
     897        }
     898
     899        return __('Allowed', 'vulntitan');
     900    }
     901
     902    protected static function buildLearningModeSummary(array $settings): string
     903    {
     904        if (empty($settings['learning_mode_enabled'])) {
     905            return __('Disabled', 'vulntitan');
     906        }
     907
     908        return sprintf(
     909            __('Enabled at %1$s hits within %2$s days', 'vulntitan'),
     910            self::formatNumber((int) ($settings['learning_suggestion_threshold'] ?? 0)),
     911            self::formatNumber((int) ($settings['learning_suggestion_window_days'] ?? 0))
     912        );
     913    }
     914
     915    protected static function buildProxyTrustSummary(array $settings): string
     916    {
     917        $parts = [];
     918        $trustedProxies = is_array($settings['trusted_proxies'] ?? null) ? $settings['trusted_proxies'] : [];
     919
     920        if (!empty($settings['trust_cloudflare'])) {
     921            $parts[] = __('Cloudflare headers trusted', 'vulntitan');
     922        }
     923
     924        if ($trustedProxies) {
     925            $parts[] = sprintf(
     926                __('%s trusted proxy IPs', 'vulntitan'),
     927                self::formatNumber(count($trustedProxies))
     928            );
     929        }
     930
     931        if (!$parts) {
     932            return __('Direct REMOTE_ADDR only', 'vulntitan');
     933        }
     934
     935        return implode(' • ', $parts);
     936    }
     937
     938    protected static function buildFormProvidersSummary(array $settings): string
     939    {
     940        if (empty($settings['form_shield_enabled'])) {
     941            return __('Disabled', 'vulntitan');
     942        }
     943
     944        $providers = [];
     945        if (!empty($settings['form_provider_cf7_enabled'])) {
     946            $providers[] = __('Contact Form 7', 'vulntitan');
     947        }
     948        if (!empty($settings['form_provider_fluentforms_enabled'])) {
     949            $providers[] = __('Fluent Forms', 'vulntitan');
     950        }
     951
     952        if (!$providers) {
     953            return __('Form Shield enabled with no active provider selected', 'vulntitan');
     954        }
     955
     956        return self::implodeHumanList($providers);
     957    }
     958
     959    protected static function humanizeCaptchaProvider(string $provider): string
     960    {
     961        switch ($provider) {
     962            case 'turnstile':
     963                return __('Cloudflare Turnstile', 'vulntitan');
     964            case 'hcaptcha':
     965                return __('hCaptcha', 'vulntitan');
     966            default:
     967                return ucfirst($provider);
     968        }
     969    }
     970
     971    protected static function humanizeIdentifier(string $value): string
     972    {
     973        return ucwords(str_replace(['_', '-'], ' ', trim($value)));
     974    }
     975
     976    protected static function implodeHumanList(array $items): string
     977    {
     978        $items = array_values(array_filter(array_map(static function ($item): string {
     979            return is_scalar($item) ? trim((string) $item) : '';
     980        }, $items)));
     981
     982        if (!$items) {
     983            return '';
     984        }
     985
     986        if (count($items) === 1) {
     987            return $items[0];
     988        }
     989
     990        if (count($items) === 2) {
     991            return $items[0] . ' ' . __('and', 'vulntitan') . ' ' . $items[1];
     992        }
     993
     994        $last = array_pop($items);
     995
     996        return implode(', ', $items) . ', ' . __('and', 'vulntitan') . ' ' . $last;
    695997    }
    696998
  • vulntitan/trunk/readme.txt

    r3486040 r3490735  
    44Tested up to: 6.9
    55Requires PHP: 7.4
    6 Stable tag: 2.1.15
     6Stable tag: 2.1.16
    77License: GPLv2
    88License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    175175== Changelog ==
    176176
     177= v2.1.16 - 25 Mar, 2026 =
     178* Tightened Comment Shield spam detection with casino, betting, gambling, promotional-link, repeated-domain, and thin-link comment heuristics for guest comments.
     179* Added firewall logging when suspicious comments are held and when WordPress routes comments into the pending moderation queue.
     180* Expanded the weekly executive security digest with form spam, comment queue, and broader protection-profile coverage.
     181* Improved the HTML digest layout on mobile by stacking compressed two-column sections into a readable single-column flow.
     182
    177183= v2.1.15 - 18 Mar, 2026 =
    178184* Added “Not installed” provider messaging in Spam Protection and disabled unavailable form provider toggles until Contact Form 7 or Fluent Forms is activated.
  • vulntitan/trunk/vulntitan.php

    r3486040 r3490735  
    44 * Plugin URI: https://vulntitan.com/vulntitan/
    55 * Description: VulnTitan is a WordPress security plugin with vulnerability scanning, malware detection, file integrity monitoring, comment and form anti-spam protection, and a built-in firewall with WAF payload rules and login protection.
    6  * Version: 2.1.15
     6 * Version: 2.1.16
    77 * Author: Jaroslav Svetlik
    88 * Author URI: https://vulntitan.com
     
    3030
    3131// Define plugin constants
    32 define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.1.15');
     32define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.1.16');
    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.