Changeset 3490735
- Timestamp:
- 03/25/2026 10:25:03 AM (9 days ago)
- Location:
- vulntitan
- Files:
-
- 18 edited
- 1 copied
-
tags/2.1.16 (copied) (copied from vulntitan/trunk)
-
tags/2.1.16/CHANGELOG.md (modified) (1 diff)
-
tags/2.1.16/assets/js/firewall.js (modified) (2 diffs)
-
tags/2.1.16/assets/js/firewall.min.js (modified) (2 diffs)
-
tags/2.1.16/includes/Admin/Admin.php (modified) (1 diff)
-
tags/2.1.16/includes/Services/CommentSpamService.php (modified) (9 diffs)
-
tags/2.1.16/includes/Services/FirewallService.php (modified) (4 diffs)
-
tags/2.1.16/includes/Services/WeeklySummaryEmailService.php (modified) (24 diffs)
-
tags/2.1.16/readme.txt (modified) (2 diffs)
-
tags/2.1.16/vulntitan.php (modified) (2 diffs)
-
trunk/CHANGELOG.md (modified) (1 diff)
-
trunk/assets/js/firewall.js (modified) (2 diffs)
-
trunk/assets/js/firewall.min.js (modified) (2 diffs)
-
trunk/includes/Admin/Admin.php (modified) (1 diff)
-
trunk/includes/Services/CommentSpamService.php (modified) (9 diffs)
-
trunk/includes/Services/FirewallService.php (modified) (4 diffs)
-
trunk/includes/Services/WeeklySummaryEmailService.php (modified) (24 diffs)
-
trunk/readme.txt (modified) (2 diffs)
-
trunk/vulntitan.php (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
vulntitan/tags/2.1.16/CHANGELOG.md
r3486040 r3490735 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 8 ## [2.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. 7 16 8 17 ## [2.1.15] - 2026-03-18 -
vulntitan/tags/2.1.16/assets/js/firewall.js
r3486040 r3490735 241 241 case 'comment_spam_held': 242 242 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'; 243 245 case 'comment_rate_limited': 244 246 return i18n.firewall_event_comment_rate_limited || 'Comment rate limited'; … … 257 259 return 'is-danger'; 258 260 case 'comment_spam_held': 261 case 'comment_pending_review': 259 262 case 'login_failed': 260 263 return 'is-warning'; -
vulntitan/tags/2.1.16/assets/js/firewall.min.js
r3486040 r3490735 241 241 case 'comment_spam_held': 242 242 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'; 243 245 case 'comment_rate_limited': 244 246 return i18n.firewall_event_comment_rate_limited || 'Comment rate limited'; … … 257 259 return 'is-danger'; 258 260 case 'comment_spam_held': 261 case 'comment_pending_review': 259 262 case 'login_failed': 260 263 return 'is-warning'; -
vulntitan/tags/2.1.16/includes/Admin/Admin.php
r3485911 r3490735 138 138 'firewall_event_comment_spam_blocked' => esc_html__('Comment spam blocked', 'vulntitan'), 139 139 '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'), 140 141 'firewall_event_comment_rate_limited' => esc_html__('Comment rate limited', 'vulntitan'), 141 142 'firewall_feed_live' => esc_html__('Live', 'vulntitan'), -
vulntitan/tags/2.1.16/includes/Services/CommentSpamService.php
r3485911 r3490735 12 12 protected const RATE_LIMIT_PREFIX = 'vulntitan_cs_rate_'; 13 13 protected const MIN_TOKEN_AGE = 1; 14 protected const SHORT_COMMENT_MAX_WORDS = 3; 15 protected const SHORT_COMMENT_MAX_LENGTH = 40; 14 16 15 17 protected static bool $booted = false; … … 92 94 93 95 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 94 100 return $approved; 95 101 } … … 131 137 $commentContent = isset($commentData['comment_content']) ? (string) wp_unslash($commentData['comment_content']) : ''; 132 138 $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'])) : ''; 133 141 $postId = (int) ($commentData['comment_post_ID'] ?? 0); 134 142 $ipAddress = self::resolveIpAddress($commentData); … … 137 145 $rateCount = self::incrementRateCounter($visitorKey, $rateWindowMinutes); 138 146 $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); 139 155 140 156 if ($rateCount > $maxAttempts) { … … 154 170 [ 155 171 'comment_hash' => self::buildCommentHash($normalizedContent), 156 'link_count' => self::countLinks($commentContent),172 'link_count' => $linkCount, 157 173 ] 158 174 ); … … 239 255 240 256 if (!self::isAuthenticatedCommenter($commentData)) { 241 $linkCount = self::countLinks($commentContent);242 257 $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 } 243 360 244 361 if ($linkCount > $maxLinks) { … … 311 428 ): array { 312 429 $details['action'] = $action; 430 431 if ($action === 'hold') { 432 $details['approval_status'] = 'pending'; 433 } 313 434 314 435 return [ … … 352 473 } 353 474 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 354 511 protected static function isEnabled(): bool 355 512 { … … 479 636 480 637 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); 481 848 } 482 849 -
vulntitan/tags/2.1.16/includes/Services/FirewallService.php
r3485911 r3490735 1698 1698 'total_events' => 0, 1699 1699 'blocked' => 0, 1700 'forms_blocked' => 0, 1700 1701 'login_failed' => 0, 1701 1702 'login_blocked' => 0, … … 1708 1709 'comment_spam_blocked' => 0, 1709 1710 'comment_spam_held' => 0, 1711 'comment_pending_review' => 0, 1710 1712 'comment_rate_limited' => 0, 1711 1713 'unique_attackers' => 0, … … 1725 1727 COUNT(*) AS total_events, 1726 1728 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, 1727 1730 SUM(CASE WHEN event_type = 'login_failed' THEN 1 ELSE 0 END) AS login_failed, 1728 1731 SUM(CASE WHEN event_type = 'login_blocked' THEN 1 ELSE 0 END) AS login_blocked, … … 1735 1738 SUM(CASE WHEN event_type = 'comment_spam_blocked' THEN 1 ELSE 0 END) AS comment_spam_blocked, 1736 1739 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, 1737 1741 SUM(CASE WHEN event_type = 'comment_rate_limited' THEN 1 ELSE 0 END) AS comment_rate_limited, 1738 1742 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 165 165 $periodLabel = trim((string) ($period['start_local_display'] ?? '') . ' - ' . (string) ($period['end_local_display'] ?? '')); 166 166 $blockedTotal = (int) ($totals['blocked'] ?? 0); 167 $formsBlocked = (int) ($totals['forms_blocked'] ?? 0); 167 168 $loginFailed = (int) ($totals['login_failed'] ?? 0); 168 169 $lockouts = (int) ($totals['login_lockout'] ?? 0); … … 171 172 $commentSpamBlocked = (int) ($totals['comment_spam_blocked'] ?? 0); 172 173 $commentSpamHeld = (int) ($totals['comment_spam_held'] ?? 0); 174 $commentPendingReview = (int) ($totals['comment_pending_review'] ?? 0); 173 175 $commentRateLimited = (int) ($totals['comment_rate_limited'] ?? 0); 174 176 $uniqueAttackers = (int) ($totals['unique_attackers'] ?? 0); 177 $commentQueueTotal = $commentSpamHeld + $commentPendingReview; 178 $commentSpamStopped = $commentSpamBlocked + $commentRateLimited; 175 179 $posture = self::getPostureSummary($totals); 176 180 $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'), 178 182 'VulnTitan', 179 183 $siteName, 180 184 self::formatNumber($blockedTotal), 181 185 self::formatNumber($loginFailed), 182 self::formatNumber($sqli) 186 self::formatNumber($formsBlocked), 187 self::formatNumber($commentQueueTotal) 183 188 ); 184 189 $generatedAt = self::formatLocalTimestamp(time(), 'M j, Y H:i'); 185 190 $policySnapshot = self::buildPolicySnapshot(); 186 $highlights = self::buildHighlights($daily );191 $highlights = self::buildHighlights($daily, $totals); 187 192 188 193 $metricCards = [ … … 190 195 'label' => __('Blocked Events', 'vulntitan'), 191 196 'value' => $blockedTotal, 192 'note' => __('Firewall, comment shield, and lockoutinterventions', 'vulntitan'),197 'note' => __('Firewall, login shield, comment shield, and form shield interventions', 'vulntitan'), 193 198 'accent' => '#0f9bd7', 194 199 ], … … 206 211 ], 207 212 [ 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 [ 208 231 'label' => __('SQLi Blocks', 'vulntitan'), 209 232 'value' => $sqli, 210 233 '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', 224 241 ], 225 242 ]; 226 243 227 244 $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 ], 228 255 [ 229 256 'label' => __('Command Injection Blocks', 'vulntitan'), … … 268 295 ]; 269 296 270 $html = '<!doctype html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"> </head><bodystyle="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;">'; 271 298 $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;">'; 276 303 $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;">'; 278 305 $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"><tr>'; 279 306 $html .= '<td style="vertical-align:middle;">'; … … 284 311 } 285 312 $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;">'; 287 314 $html .= '<span style="display:inline-block;font-size:12px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:#38bdf8;">Weekly Digest</span>'; 288 315 $html .= '</td>'; 289 316 $html .= '</tr></table>'; 290 317 $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>'; 295 322 $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>'; 297 324 $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;">'; 300 327 $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>'; 302 329 $html .= '<tr><td style="padding:0 18px 18px 18px;font-size:13px;line-height:1.7;color:#cbd5e1;">' . esc_html($posture['summary']) . '</td></tr>'; 303 330 $html .= '</table>'; … … 308 335 $html .= '</td></tr>'; 309 336 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;">'; 313 340 $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;">'; 315 342 $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 loginshield 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>'; 319 346 $html .= '</table>'; 320 347 $html .= '</td></tr>'; 321 348 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">'; 324 351 $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>'; 327 354 $html .= '</tr>'; 328 355 $html .= '</table>'; 329 356 $html .= '</td></tr>'; 330 357 331 $html .= '<tr><td style="padding:0 16px 16px 16px;">';358 $html .= '<tr><td class="vt-section-pad" style="padding:0 16px 16px 16px;">'; 332 359 $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;">'; 334 361 $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 containmentshown 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>'; 338 365 $html .= '</table>'; 339 366 $html .= '</td></tr>'; 340 367 341 $html .= '<tr><td style="padding:0 16px 0 16px;">';368 $html .= '<tr><td class="vt-section-pad" style="padding:0 16px 0 16px;">'; 342 369 $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;">'; 344 371 $html .= '<div style="font-size:14px;line-height:1.8;color:#cbd5e1;">'; 345 372 $html .= esc_html__('Generated by VulnTitan for your primary site administrator.', 'vulntitan') . ' '; … … 360 387 protected static function renderMetricCard(string $label, int $value, string $note, string $accent): string 361 388 { 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;">' 363 390 . '<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>' 365 392 . '<tr><td style="padding:0 20px 20px 20px;font-size:14px;line-height:1.7;color:#475569;">' . esc_html($note) . '</td></tr>' 366 393 . '</table>'; … … 369 396 protected static function renderMetricGrid(array $cards): string 370 397 { 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">'; 372 399 $chunks = array_chunk($cards, 2); 373 400 374 401 foreach ($chunks as $rowIndex => $rowCards) { 375 $html .= '<tr >';402 $html .= '<tr class="vt-metric-row">'; 376 403 377 404 foreach ($rowCards as $cardIndex => $card) { … … 381 408 $paddingBottom = $rowIndex < count($chunks) - 1 ? '16px' : '0'; 382 409 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;">'; 384 411 $html .= self::renderMetricCard( 385 412 (string) ($card['label'] ?? ''), … … 393 420 if (count($rowCards) === 1) { 394 421 $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>'; 396 423 } 397 424 … … 433 460 434 461 $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;">'; 436 463 $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;">'; 440 467 441 468 foreach ($daily as $index => $day) { 442 469 $counts = is_array($day['counts'] ?? null) ? $day['counts'] : []; 443 470 $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); 444 474 $percent = ($maxBlocked > 0 && $blocked > 0) ? max(12, (int) round(($blocked / $maxBlocked) * 100)) : 0; 445 475 $border = $index < count($daily) - 1 ? 'border-bottom:1px solid #e2e8f0;' : ''; … … 447 477 $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="' . $border . '">'; 448 478 $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;">'; 451 481 $html .= '<div style="height:8px;border-radius:999px;background-color:#e2e8f0;overflow:hidden;margin:5px 0 10px 0;">'; 452 482 $html .= '<span style="display:block;height:8px;width:' . esc_attr((string) $percent) . '%;background-color:#0f9bd7;border-radius:999px;"></span>'; … … 454 484 $html .= '<div style="font-size:12px;line-height:1.8;color:#64748b;">'; 455 485 $html .= esc_html__('Blocked', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber($blocked)) . '</strong> '; 486 $html .= esc_html__('Forms', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber($formsBlocked)) . '</strong> '; 487 $html .= esc_html__('Comment Stops', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber($commentStopped)) . '</strong> '; 488 $html .= esc_html__('Queue', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber($commentQueue)) . '</strong> '; 456 489 $html .= esc_html__('Failed Logins', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber((int) ($counts['login_failed'] ?? 0))) . '</strong> '; 457 490 $html .= esc_html__('SQLi', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber((int) ($counts['sqli'] ?? 0))) . '</strong> '; … … 472 505 { 473 506 $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;">'; 475 508 $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>'; 476 509 $html .= '<div style="font-size:14px;line-height:1.7;color:#475569;">' . esc_html__('The most important takeaways from this reporting period.', 'vulntitan') . '</div>'; 477 510 $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;">'; 479 512 480 513 foreach ($highlights as $index => $highlight) { … … 523 556 ]; 524 557 }, 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') 526 559 ); 527 560 … … 532 565 { 533 566 $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;">'; 535 568 $html .= '<div style="font-size:18px;line-height:1.3;font-weight:800;color:#0f172a;margin:0 0 8px 0;">' . esc_html($title) . '</div>'; 536 569 $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;">'; 538 571 539 572 if (!$rows) { … … 583 616 ], 584 617 [ 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 [ 585 634 'label' => __('Max Failed Attempts', 'vulntitan'), 586 635 'value' => self::formatNumber((int) ($settings['max_attempts'] ?? 0)), … … 594 643 ], 595 644 [ 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 [ 596 661 'label' => __('Comment Shield', 'vulntitan'), 597 662 'value' => !empty($settings['comment_shield_enabled']) ? __('Enabled', 'vulntitan') : __('Disabled', 'vulntitan'), … … 604 669 ], 605 670 [ 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'), 607 686 'value' => sprintf( 608 687 __('%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)) 611 690 ), 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), 612 699 ], 613 700 [ … … 624 711 $border = $index < count($rows) - 1 ? 'border-bottom:1px solid #e2e8f0;' : ''; 625 712 $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>'; 628 715 $html .= '</tr>'; 629 716 } … … 633 720 } 634 721 635 protected static function buildHighlights(array $daily ): array722 protected static function buildHighlights(array $daily, array $totals = []): array 636 723 { 637 724 $peakDay = null; … … 640 727 $peakLogins = -1; 641 728 $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); 642 733 643 734 foreach ($daily as $day) { … … 690 781 ), 691 782 ], 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 ], 692 794 ]; 693 795 694 796 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; 695 997 } 696 998 -
vulntitan/tags/2.1.16/readme.txt
r3486040 r3490735 4 4 Tested up to: 6.9 5 5 Requires PHP: 7.4 6 Stable tag: 2.1.1 56 Stable tag: 2.1.16 7 7 License: GPLv2 8 8 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 175 175 == Changelog == 176 176 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 177 183 = v2.1.15 - 18 Mar, 2026 = 178 184 * 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 4 4 * Plugin URI: https://vulntitan.com/vulntitan/ 5 5 * 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.1 56 * Version: 2.1.16 7 7 * Author: Jaroslav Svetlik 8 8 * Author URI: https://vulntitan.com … … 30 30 31 31 // Define plugin constants 32 define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.1.1 5');32 define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.1.16'); 33 33 define('VULNTITAN_PLUGIN_BASENAME', plugin_basename(__FILE__)); 34 34 define('VULNTITAN_PLUGIN_DIR', untrailingslashit(plugin_dir_path(__FILE__))); -
vulntitan/trunk/CHANGELOG.md
r3486040 r3490735 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 8 ## [2.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. 7 16 8 17 ## [2.1.15] - 2026-03-18 -
vulntitan/trunk/assets/js/firewall.js
r3486040 r3490735 241 241 case 'comment_spam_held': 242 242 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'; 243 245 case 'comment_rate_limited': 244 246 return i18n.firewall_event_comment_rate_limited || 'Comment rate limited'; … … 257 259 return 'is-danger'; 258 260 case 'comment_spam_held': 261 case 'comment_pending_review': 259 262 case 'login_failed': 260 263 return 'is-warning'; -
vulntitan/trunk/assets/js/firewall.min.js
r3486040 r3490735 241 241 case 'comment_spam_held': 242 242 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'; 243 245 case 'comment_rate_limited': 244 246 return i18n.firewall_event_comment_rate_limited || 'Comment rate limited'; … … 257 259 return 'is-danger'; 258 260 case 'comment_spam_held': 261 case 'comment_pending_review': 259 262 case 'login_failed': 260 263 return 'is-warning'; -
vulntitan/trunk/includes/Admin/Admin.php
r3485911 r3490735 138 138 'firewall_event_comment_spam_blocked' => esc_html__('Comment spam blocked', 'vulntitan'), 139 139 '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'), 140 141 'firewall_event_comment_rate_limited' => esc_html__('Comment rate limited', 'vulntitan'), 141 142 'firewall_feed_live' => esc_html__('Live', 'vulntitan'), -
vulntitan/trunk/includes/Services/CommentSpamService.php
r3485911 r3490735 12 12 protected const RATE_LIMIT_PREFIX = 'vulntitan_cs_rate_'; 13 13 protected const MIN_TOKEN_AGE = 1; 14 protected const SHORT_COMMENT_MAX_WORDS = 3; 15 protected const SHORT_COMMENT_MAX_LENGTH = 40; 14 16 15 17 protected static bool $booted = false; … … 92 94 93 95 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 94 100 return $approved; 95 101 } … … 131 137 $commentContent = isset($commentData['comment_content']) ? (string) wp_unslash($commentData['comment_content']) : ''; 132 138 $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'])) : ''; 133 141 $postId = (int) ($commentData['comment_post_ID'] ?? 0); 134 142 $ipAddress = self::resolveIpAddress($commentData); … … 137 145 $rateCount = self::incrementRateCounter($visitorKey, $rateWindowMinutes); 138 146 $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); 139 155 140 156 if ($rateCount > $maxAttempts) { … … 154 170 [ 155 171 'comment_hash' => self::buildCommentHash($normalizedContent), 156 'link_count' => self::countLinks($commentContent),172 'link_count' => $linkCount, 157 173 ] 158 174 ); … … 239 255 240 256 if (!self::isAuthenticatedCommenter($commentData)) { 241 $linkCount = self::countLinks($commentContent);242 257 $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 } 243 360 244 361 if ($linkCount > $maxLinks) { … … 311 428 ): array { 312 429 $details['action'] = $action; 430 431 if ($action === 'hold') { 432 $details['approval_status'] = 'pending'; 433 } 313 434 314 435 return [ … … 352 473 } 353 474 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 354 511 protected static function isEnabled(): bool 355 512 { … … 479 636 480 637 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); 481 848 } 482 849 -
vulntitan/trunk/includes/Services/FirewallService.php
r3485911 r3490735 1698 1698 'total_events' => 0, 1699 1699 'blocked' => 0, 1700 'forms_blocked' => 0, 1700 1701 'login_failed' => 0, 1701 1702 'login_blocked' => 0, … … 1708 1709 'comment_spam_blocked' => 0, 1709 1710 'comment_spam_held' => 0, 1711 'comment_pending_review' => 0, 1710 1712 'comment_rate_limited' => 0, 1711 1713 'unique_attackers' => 0, … … 1725 1727 COUNT(*) AS total_events, 1726 1728 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, 1727 1730 SUM(CASE WHEN event_type = 'login_failed' THEN 1 ELSE 0 END) AS login_failed, 1728 1731 SUM(CASE WHEN event_type = 'login_blocked' THEN 1 ELSE 0 END) AS login_blocked, … … 1735 1738 SUM(CASE WHEN event_type = 'comment_spam_blocked' THEN 1 ELSE 0 END) AS comment_spam_blocked, 1736 1739 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, 1737 1741 SUM(CASE WHEN event_type = 'comment_rate_limited' THEN 1 ELSE 0 END) AS comment_rate_limited, 1738 1742 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 165 165 $periodLabel = trim((string) ($period['start_local_display'] ?? '') . ' - ' . (string) ($period['end_local_display'] ?? '')); 166 166 $blockedTotal = (int) ($totals['blocked'] ?? 0); 167 $formsBlocked = (int) ($totals['forms_blocked'] ?? 0); 167 168 $loginFailed = (int) ($totals['login_failed'] ?? 0); 168 169 $lockouts = (int) ($totals['login_lockout'] ?? 0); … … 171 172 $commentSpamBlocked = (int) ($totals['comment_spam_blocked'] ?? 0); 172 173 $commentSpamHeld = (int) ($totals['comment_spam_held'] ?? 0); 174 $commentPendingReview = (int) ($totals['comment_pending_review'] ?? 0); 173 175 $commentRateLimited = (int) ($totals['comment_rate_limited'] ?? 0); 174 176 $uniqueAttackers = (int) ($totals['unique_attackers'] ?? 0); 177 $commentQueueTotal = $commentSpamHeld + $commentPendingReview; 178 $commentSpamStopped = $commentSpamBlocked + $commentRateLimited; 175 179 $posture = self::getPostureSummary($totals); 176 180 $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'), 178 182 'VulnTitan', 179 183 $siteName, 180 184 self::formatNumber($blockedTotal), 181 185 self::formatNumber($loginFailed), 182 self::formatNumber($sqli) 186 self::formatNumber($formsBlocked), 187 self::formatNumber($commentQueueTotal) 183 188 ); 184 189 $generatedAt = self::formatLocalTimestamp(time(), 'M j, Y H:i'); 185 190 $policySnapshot = self::buildPolicySnapshot(); 186 $highlights = self::buildHighlights($daily );191 $highlights = self::buildHighlights($daily, $totals); 187 192 188 193 $metricCards = [ … … 190 195 'label' => __('Blocked Events', 'vulntitan'), 191 196 'value' => $blockedTotal, 192 'note' => __('Firewall, comment shield, and lockoutinterventions', 'vulntitan'),197 'note' => __('Firewall, login shield, comment shield, and form shield interventions', 'vulntitan'), 193 198 'accent' => '#0f9bd7', 194 199 ], … … 206 211 ], 207 212 [ 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 [ 208 231 'label' => __('SQLi Blocks', 'vulntitan'), 209 232 'value' => $sqli, 210 233 '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', 224 241 ], 225 242 ]; 226 243 227 244 $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 ], 228 255 [ 229 256 'label' => __('Command Injection Blocks', 'vulntitan'), … … 268 295 ]; 269 296 270 $html = '<!doctype html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"> </head><bodystyle="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;">'; 271 298 $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;">'; 276 303 $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;">'; 278 305 $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"><tr>'; 279 306 $html .= '<td style="vertical-align:middle;">'; … … 284 311 } 285 312 $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;">'; 287 314 $html .= '<span style="display:inline-block;font-size:12px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:#38bdf8;">Weekly Digest</span>'; 288 315 $html .= '</td>'; 289 316 $html .= '</tr></table>'; 290 317 $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>'; 295 322 $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>'; 297 324 $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;">'; 300 327 $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>'; 302 329 $html .= '<tr><td style="padding:0 18px 18px 18px;font-size:13px;line-height:1.7;color:#cbd5e1;">' . esc_html($posture['summary']) . '</td></tr>'; 303 330 $html .= '</table>'; … … 308 335 $html .= '</td></tr>'; 309 336 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;">'; 313 340 $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;">'; 315 342 $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 loginshield 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>'; 319 346 $html .= '</table>'; 320 347 $html .= '</td></tr>'; 321 348 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">'; 324 351 $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>'; 327 354 $html .= '</tr>'; 328 355 $html .= '</table>'; 329 356 $html .= '</td></tr>'; 330 357 331 $html .= '<tr><td style="padding:0 16px 16px 16px;">';358 $html .= '<tr><td class="vt-section-pad" style="padding:0 16px 16px 16px;">'; 332 359 $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;">'; 334 361 $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 containmentshown 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>'; 338 365 $html .= '</table>'; 339 366 $html .= '</td></tr>'; 340 367 341 $html .= '<tr><td style="padding:0 16px 0 16px;">';368 $html .= '<tr><td class="vt-section-pad" style="padding:0 16px 0 16px;">'; 342 369 $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;">'; 344 371 $html .= '<div style="font-size:14px;line-height:1.8;color:#cbd5e1;">'; 345 372 $html .= esc_html__('Generated by VulnTitan for your primary site administrator.', 'vulntitan') . ' '; … … 360 387 protected static function renderMetricCard(string $label, int $value, string $note, string $accent): string 361 388 { 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;">' 363 390 . '<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>' 365 392 . '<tr><td style="padding:0 20px 20px 20px;font-size:14px;line-height:1.7;color:#475569;">' . esc_html($note) . '</td></tr>' 366 393 . '</table>'; … … 369 396 protected static function renderMetricGrid(array $cards): string 370 397 { 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">'; 372 399 $chunks = array_chunk($cards, 2); 373 400 374 401 foreach ($chunks as $rowIndex => $rowCards) { 375 $html .= '<tr >';402 $html .= '<tr class="vt-metric-row">'; 376 403 377 404 foreach ($rowCards as $cardIndex => $card) { … … 381 408 $paddingBottom = $rowIndex < count($chunks) - 1 ? '16px' : '0'; 382 409 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;">'; 384 411 $html .= self::renderMetricCard( 385 412 (string) ($card['label'] ?? ''), … … 393 420 if (count($rowCards) === 1) { 394 421 $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>'; 396 423 } 397 424 … … 433 460 434 461 $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;">'; 436 463 $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;">'; 440 467 441 468 foreach ($daily as $index => $day) { 442 469 $counts = is_array($day['counts'] ?? null) ? $day['counts'] : []; 443 470 $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); 444 474 $percent = ($maxBlocked > 0 && $blocked > 0) ? max(12, (int) round(($blocked / $maxBlocked) * 100)) : 0; 445 475 $border = $index < count($daily) - 1 ? 'border-bottom:1px solid #e2e8f0;' : ''; … … 447 477 $html .= '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="' . $border . '">'; 448 478 $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;">'; 451 481 $html .= '<div style="height:8px;border-radius:999px;background-color:#e2e8f0;overflow:hidden;margin:5px 0 10px 0;">'; 452 482 $html .= '<span style="display:block;height:8px;width:' . esc_attr((string) $percent) . '%;background-color:#0f9bd7;border-radius:999px;"></span>'; … … 454 484 $html .= '<div style="font-size:12px;line-height:1.8;color:#64748b;">'; 455 485 $html .= esc_html__('Blocked', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber($blocked)) . '</strong> '; 486 $html .= esc_html__('Forms', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber($formsBlocked)) . '</strong> '; 487 $html .= esc_html__('Comment Stops', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber($commentStopped)) . '</strong> '; 488 $html .= esc_html__('Queue', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber($commentQueue)) . '</strong> '; 456 489 $html .= esc_html__('Failed Logins', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber((int) ($counts['login_failed'] ?? 0))) . '</strong> '; 457 490 $html .= esc_html__('SQLi', 'vulntitan') . ': <strong style="color:#0f172a;">' . esc_html(self::formatNumber((int) ($counts['sqli'] ?? 0))) . '</strong> '; … … 472 505 { 473 506 $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;">'; 475 508 $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>'; 476 509 $html .= '<div style="font-size:14px;line-height:1.7;color:#475569;">' . esc_html__('The most important takeaways from this reporting period.', 'vulntitan') . '</div>'; 477 510 $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;">'; 479 512 480 513 foreach ($highlights as $index => $highlight) { … … 523 556 ]; 524 557 }, 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') 526 559 ); 527 560 … … 532 565 { 533 566 $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;">'; 535 568 $html .= '<div style="font-size:18px;line-height:1.3;font-weight:800;color:#0f172a;margin:0 0 8px 0;">' . esc_html($title) . '</div>'; 536 569 $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;">'; 538 571 539 572 if (!$rows) { … … 583 616 ], 584 617 [ 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 [ 585 634 'label' => __('Max Failed Attempts', 'vulntitan'), 586 635 'value' => self::formatNumber((int) ($settings['max_attempts'] ?? 0)), … … 594 643 ], 595 644 [ 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 [ 596 661 'label' => __('Comment Shield', 'vulntitan'), 597 662 'value' => !empty($settings['comment_shield_enabled']) ? __('Enabled', 'vulntitan') : __('Disabled', 'vulntitan'), … … 604 669 ], 605 670 [ 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'), 607 686 'value' => sprintf( 608 687 __('%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)) 611 690 ), 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), 612 699 ], 613 700 [ … … 624 711 $border = $index < count($rows) - 1 ? 'border-bottom:1px solid #e2e8f0;' : ''; 625 712 $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>'; 628 715 $html .= '</tr>'; 629 716 } … … 633 720 } 634 721 635 protected static function buildHighlights(array $daily ): array722 protected static function buildHighlights(array $daily, array $totals = []): array 636 723 { 637 724 $peakDay = null; … … 640 727 $peakLogins = -1; 641 728 $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); 642 733 643 734 foreach ($daily as $day) { … … 690 781 ), 691 782 ], 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 ], 692 794 ]; 693 795 694 796 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; 695 997 } 696 998 -
vulntitan/trunk/readme.txt
r3486040 r3490735 4 4 Tested up to: 6.9 5 5 Requires PHP: 7.4 6 Stable tag: 2.1.1 56 Stable tag: 2.1.16 7 7 License: GPLv2 8 8 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 175 175 == Changelog == 176 176 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 177 183 = v2.1.15 - 18 Mar, 2026 = 178 184 * 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 4 4 * Plugin URI: https://vulntitan.com/vulntitan/ 5 5 * 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.1 56 * Version: 2.1.16 7 7 * Author: Jaroslav Svetlik 8 8 * Author URI: https://vulntitan.com … … 30 30 31 31 // Define plugin constants 32 define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.1.1 5');32 define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.1.16'); 33 33 define('VULNTITAN_PLUGIN_BASENAME', plugin_basename(__FILE__)); 34 34 define('VULNTITAN_PLUGIN_DIR', untrailingslashit(plugin_dir_path(__FILE__)));
Note: See TracChangeset
for help on using the changeset viewer.