Plugin Directory

Changeset 3483739


Ignore:
Timestamp:
03/16/2026 11:00:53 AM (3 weeks ago)
Author:
jerryscg
Message:

Release 2.1.10 - learning suggestions and PHP 8.4 fix

Location:
vulntitan/trunk
Files:
9 edited

Legend:

Unmodified
Added
Removed
  • vulntitan/trunk/CHANGELOG.md

    r3483644 r3483739  
    55The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
    66and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
     7
     8## [2.1.10] - 2026-03-16
     9### Added
     10- Added Learning Mode suggestions for WAF whitelisting with configurable thresholds and review-only approvals.
     11- Added a Learning Suggestions panel with approve/dismiss actions.
     12
     13### Fixed
     14- Fixed PHP 8.4 deprecation warning by making trusted proxy settings explicitly nullable.
    715
    816## [2.1.9] - 2026-03-16
  • vulntitan/trunk/assets/js/firewall.js

    r3483644 r3483739  
    4141    const $firewallWafCommandEnabled = $('#vulntitan-firewall-waf-command-enabled');
    4242    const $firewallWafWhitelistPaths = $('#vulntitan-firewall-waf-whitelist-paths');
     43    const $firewallLearningEnabled = $('#vulntitan-firewall-learning-enabled');
     44    const $firewallLearningThreshold = $('#vulntitan-firewall-learning-threshold');
     45    const $firewallLearningWindow = $('#vulntitan-firewall-learning-window');
     46    const $firewallLearningRefresh = $('#vulntitan-firewall-learning-refresh');
     47    const $firewallLearningList = $('#vulntitan-firewall-learning-list');
     48    const $firewallLearningEmpty = $('#vulntitan-firewall-learning-empty');
    4349    const $firewallTrustCloudflare = $('#vulntitan-firewall-trust-cloudflare');
    4450    const $firewallTrustedProxies = $('#vulntitan-firewall-trusted-proxies');
     
    7480        allLogs: [],
    7581        approvals: [],
     82        learningSuggestions: [],
     83        learningLoaded: false,
     84        learningLoading: false,
    7685        selectedLogId: '',
    7786        visibleLogLimit: LOG_PAGE_SIZE,
     
    432441                .prop('hidden', !isActive);
    433442        });
     443
     444        if (normalizedTab === 'waf') {
     445            maybeLoadLearningSuggestions(false);
     446        }
    434447    }
    435448
     
    494507            Array.isArray(data.waf_whitelist_paths) ? data.waf_whitelist_paths.join('\n') : ''
    495508        );
     509        $firewallLearningEnabled.prop('checked', !!Number(data.learning_mode_enabled || 0));
     510        $firewallLearningThreshold.val(Number(data.learning_suggestion_threshold || 3));
     511        $firewallLearningWindow.val(Number(data.learning_suggestion_window_days || 7));
    496512        $firewallTrustCloudflare.prop('checked', !!Number(data.trust_cloudflare || 0));
    497513        $firewallTrustedProxies.val(
     
    830846            applySettings(data.settings || {});
    831847            renderLoginAccess(data.login_access || {});
     848            renderLearningSuggestions();
    832849        }
    833850
     
    862879        $firewallWafCommandEnabled.prop('disabled', !!isBusy);
    863880        $firewallWafWhitelistPaths.prop('disabled', !!isBusy);
     881        $firewallLearningEnabled.prop('disabled', !!isBusy);
     882        $firewallLearningThreshold.prop('disabled', !!isBusy);
     883        $firewallLearningWindow.prop('disabled', !!isBusy);
     884        $firewallLearningRefresh.prop('disabled', !!isBusy);
    864885        $firewallTrustCloudflare.prop('disabled', !!isBusy);
    865886        $firewallTrustedProxies.prop('disabled', !!isBusy);
     
    10161037    }
    10171038
     1039    function renderLearningSuggestions() {
     1040        if (!$firewallLearningList.length) {
     1041            return;
     1042        }
     1043
     1044        const enabled = $firewallLearningEnabled.is(':checked');
     1045        const suggestions = Array.isArray(state.learningSuggestions) ? state.learningSuggestions : [];
     1046
     1047        if (!enabled) {
     1048            $firewallLearningList.empty();
     1049            $firewallLearningEmpty
     1050                .text(i18n.firewall_learning_disabled || 'Learning suggestions are disabled.')
     1051                .show();
     1052            return;
     1053        }
     1054
     1055        if (!suggestions.length) {
     1056            $firewallLearningList.empty();
     1057            $firewallLearningEmpty
     1058                .text(i18n.firewall_learning_empty || 'No learning suggestions yet.')
     1059                .show();
     1060            return;
     1061        }
     1062
     1063        $firewallLearningEmpty.hide();
     1064        $firewallLearningList.html(suggestions.map(renderLearningItem).join(''));
     1065    }
     1066
     1067    function renderLearningItem(item) {
     1068        const pattern = String(item.pattern || '');
     1069        const lastSeen = item.last_seen_local || item.last_seen || '';
     1070        const count = Number(item.count || 0);
     1071        const ruleGroup = item.rule_group || '';
     1072        const ruleId = item.rule_id || '';
     1073        const action = item.approval_action || '';
     1074        const restRoute = item.approval_rest_route || '';
     1075        const actionLabel = action
     1076            ? `${i18n.firewall_approval_action || 'Action'}: ${action}`
     1077            : (restRoute ? `${i18n.firewall_approval_route || 'Route'}: ${restRoute}` : '');
     1078
     1079        return `
     1080            <div class="vulntitan-firewall-log-item vulntitan-firewall-approval-card">
     1081                <div class="vulntitan-firewall-log-head">
     1082                    <span class="vulntitan-firewall-log-badge is-warning">
     1083                        ${escapeHtml(i18n.firewall_learning_suggestion || 'Learning suggestion')}
     1084                    </span>
     1085                    <time class="vulntitan-firewall-log-time">${escapeHtml(lastSeen)}</time>
     1086                </div>
     1087                <div class="vulntitan-firewall-log-request">${escapeHtml(pattern || '/')}</div>
     1088                <div class="vulntitan-firewall-log-meta">
     1089                    <span class="vulntitan-firewall-log-meta-item">${escapeHtml(i18n.firewall_learning_hits || 'Hits')}: ${count}</span>
     1090                    ${ruleGroup ? `<span class="vulntitan-firewall-log-meta-item">Group: ${escapeHtml(ruleGroup)}</span>` : ''}
     1091                    ${ruleId ? `<span class="vulntitan-firewall-log-meta-item">Rule: ${escapeHtml(ruleId)}</span>` : ''}
     1092                    ${actionLabel ? `<span class="vulntitan-firewall-log-meta-item">${escapeHtml(actionLabel)}</span>` : ''}
     1093                </div>
     1094                <div class="vulntitan-firewall-approval-pattern">
     1095                    <span>${escapeHtml(i18n.firewall_approval_pattern || 'Whitelist pattern')}:</span>
     1096                    <code>${escapeHtml(pattern)}</code>
     1097                </div>
     1098                <div class="vulntitan-firewall-approval-actions">
     1099                    <button type="button" class="vulntitan-fw-btn vulntitan-fw-btn-primary" data-firewall-learning-allow="${escapeHtml(pattern)}">
     1100                        ${escapeHtml(i18n.firewall_learning_allow || 'Allow')}
     1101                    </button>
     1102                    <button type="button" class="vulntitan-fw-btn vulntitan-fw-btn-secondary" data-firewall-learning-dismiss="${escapeHtml(pattern)}">
     1103                        ${escapeHtml(i18n.firewall_learning_dismiss || 'Dismiss')}
     1104                    </button>
     1105                </div>
     1106            </div>
     1107        `;
     1108    }
     1109
     1110    function loadLearningSuggestions(options) {
     1111        if (!$firewallLearningList.length) {
     1112            return;
     1113        }
     1114
     1115        const config = $.extend({
     1116            silent: false,
     1117            force: false
     1118        }, options || {});
     1119
     1120        if (!$firewallLearningEnabled.is(':checked')) {
     1121            state.learningSuggestions = [];
     1122            renderLearningSuggestions();
     1123            return;
     1124        }
     1125
     1126        if (state.learningLoading) {
     1127            return;
     1128        }
     1129
     1130        if (state.learningLoaded && !config.force) {
     1131            renderLearningSuggestions();
     1132            return;
     1133        }
     1134
     1135        state.learningLoading = true;
     1136
     1137        if (!config.silent) {
     1138            setFeedback('info', i18n.firewall_learning_loading || 'Loading learning suggestions...');
     1139        }
     1140
     1141        $.post(VulnTitan.ajaxUrl, {
     1142            action: 'vulntitan_firewall_get_learning',
     1143            nonce: VulnTitan.nonce
     1144        }, function (response) {
     1145            if (!response || !response.success) {
     1146                const message = (response && response.data && response.data.message)
     1147                    ? response.data.message
     1148                    : (i18n.firewall_learning_failed || 'Failed to load learning suggestions.');
     1149                if (!config.silent) {
     1150                    setFeedback('error', message);
     1151                }
     1152                return;
     1153            }
     1154
     1155            const payload = response.data || {};
     1156            state.learningSuggestions = Array.isArray(payload.suggestions) ? payload.suggestions : [];
     1157            state.learningLoaded = true;
     1158            renderLearningSuggestions();
     1159
     1160            if (!config.silent) {
     1161                setFeedback('success', i18n.firewall_learning_updated || 'Learning suggestions updated.');
     1162            }
     1163        }).fail(function () {
     1164            if (!config.silent) {
     1165                setFeedback('error', i18n.firewall_learning_failed || 'Failed to load learning suggestions.');
     1166            }
     1167        }).always(function () {
     1168            state.learningLoading = false;
     1169        });
     1170    }
     1171
     1172    function maybeLoadLearningSuggestions(force) {
     1173        if (force) {
     1174            loadLearningSuggestions({ force: true, silent: true });
     1175            return;
     1176        }
     1177
     1178        if (!state.learningLoaded) {
     1179            loadLearningSuggestions({ silent: true });
     1180        } else {
     1181            renderLearningSuggestions();
     1182        }
     1183    }
     1184
    10181185    function saveSettings() {
    10191186        if (state.requestInFlight) {
     
    10511218            waf_command_injection_enabled: $firewallWafCommandEnabled.is(':checked') ? 1 : 0,
    10521219            waf_whitelist_paths: String($firewallWafWhitelistPaths.val() || ''),
     1220            learning_mode_enabled: $firewallLearningEnabled.is(':checked') ? 1 : 0,
     1221            learning_suggestion_threshold: Number($firewallLearningThreshold.val() || 3),
     1222            learning_suggestion_window_days: Number($firewallLearningWindow.val() || 7),
    10531223            trust_cloudflare: $firewallTrustCloudflare.is(':checked') ? 1 : 0,
    10541224            trusted_proxies: String($firewallTrustedProxies.val() || ''),
     
    10791249            });
    10801250
     1251            const activeTab = String($firewallTabs.filter('.is-active').data('firewallTab') || '');
     1252            if (activeTab === 'waf' && $firewallLearningEnabled.is(':checked')) {
     1253                state.learningLoaded = false;
     1254                loadLearningSuggestions({
     1255                    silent: true,
     1256                    force: true
     1257                });
     1258            }
     1259
    10811260            const muInstall = payload.mu_loader_install || {};
    10821261            if (payload.notice) {
     
    12381417    }
    12391418
     1419    function sendLearningAction(action, pattern, $button, confirmMessage, successMessage) {
     1420        const cleanPattern = String(pattern || '').trim();
     1421        if (!cleanPattern) {
     1422            setFeedback('error', i18n.firewall_approval_invalid || 'Invalid learning suggestion.');
     1423            return;
     1424        }
     1425
     1426        if (confirmMessage && !window.confirm(confirmMessage)) {
     1427            return;
     1428        }
     1429
     1430        if ($button && $button.length) {
     1431            $button.prop('disabled', true);
     1432        }
     1433
     1434        setFeedback('info', i18n.firewall_action_in_progress || 'Working...');
     1435
     1436        $.post(VulnTitan.ajaxUrl, {
     1437            action: action,
     1438            nonce: VulnTitan.nonce,
     1439            pattern: cleanPattern
     1440        }, function (response) {
     1441            if (!response || !response.success) {
     1442                const message = (response && response.data && response.data.message)
     1443                    ? response.data.message
     1444                    : (i18n.firewall_action_failed || 'Action failed.');
     1445                setFeedback('error', message);
     1446                return;
     1447            }
     1448
     1449            const payload = response.data || {};
     1450            if (payload.settings) {
     1451                applySettings(payload.settings);
     1452            }
     1453
     1454            if (Array.isArray(payload.suggestions)) {
     1455                state.learningSuggestions = payload.suggestions;
     1456                state.learningLoaded = true;
     1457                renderLearningSuggestions();
     1458            }
     1459
     1460            setFeedback('success', successMessage);
     1461        }).fail(function () {
     1462            setFeedback('error', i18n.firewall_action_failed || 'Action failed.');
     1463        }).always(function () {
     1464            if ($button && $button.length) {
     1465                $button.prop('disabled', false);
     1466            }
     1467        });
     1468    }
     1469
    12401470    function handleFeedToggle() {
    12411471        state.feedPaused = !state.feedPaused;
     
    13581588    });
    13591589
     1590    $firewallLearningRefresh.off('click').on('click', function () {
     1591        loadLearningSuggestions({
     1592            silent: false,
     1593            force: true
     1594        });
     1595    });
     1596
     1597    $firewallLearningEnabled.off('change').on('change', function () {
     1598        if ($firewallLearningEnabled.is(':checked')) {
     1599            state.learningLoaded = false;
     1600            loadLearningSuggestions({
     1601                silent: true,
     1602                force: true
     1603            });
     1604            return;
     1605        }
     1606
     1607        state.learningLoaded = false;
     1608        state.learningSuggestions = [];
     1609        renderLearningSuggestions();
     1610    });
     1611
     1612    $firewallLearningList.off('click', '[data-firewall-learning-allow]').on('click', '[data-firewall-learning-allow]', function () {
     1613        const $button = $(this);
     1614        const pattern = String($button.data('firewallLearningAllow') || '');
     1615        sendLearningAction(
     1616            'vulntitan_firewall_apply_learning',
     1617            pattern,
     1618            $button,
     1619            i18n.firewall_learning_confirm_allow || 'Allow this suggested pattern and add it to the WAF whitelist?',
     1620            i18n.firewall_approval_success || 'Whitelist updated.'
     1621        );
     1622    });
     1623
     1624    $firewallLearningList.off('click', '[data-firewall-learning-dismiss]').on('click', '[data-firewall-learning-dismiss]', function () {
     1625        const $button = $(this);
     1626        const pattern = String($button.data('firewallLearningDismiss') || '');
     1627        sendLearningAction(
     1628            'vulntitan_firewall_dismiss_learning',
     1629            pattern,
     1630            $button,
     1631            i18n.firewall_learning_confirm_dismiss || 'Dismiss this learning suggestion?',
     1632            i18n.firewall_learning_dismissed || 'Learning suggestion dismissed.'
     1633        );
     1634    });
     1635
    13601636    $firewallLoadMore.off('click', '[data-firewall-load-more]').on('click', '[data-firewall-load-more]', function () {
    13611637        state.visibleLogLimit += LOG_PAGE_SIZE;
  • vulntitan/trunk/assets/js/firewall.min.js

    r3483644 r3483739  
    4141    const $firewallWafCommandEnabled = $('#vulntitan-firewall-waf-command-enabled');
    4242    const $firewallWafWhitelistPaths = $('#vulntitan-firewall-waf-whitelist-paths');
     43    const $firewallLearningEnabled = $('#vulntitan-firewall-learning-enabled');
     44    const $firewallLearningThreshold = $('#vulntitan-firewall-learning-threshold');
     45    const $firewallLearningWindow = $('#vulntitan-firewall-learning-window');
     46    const $firewallLearningRefresh = $('#vulntitan-firewall-learning-refresh');
     47    const $firewallLearningList = $('#vulntitan-firewall-learning-list');
     48    const $firewallLearningEmpty = $('#vulntitan-firewall-learning-empty');
    4349    const $firewallTrustCloudflare = $('#vulntitan-firewall-trust-cloudflare');
    4450    const $firewallTrustedProxies = $('#vulntitan-firewall-trusted-proxies');
     
    7480        allLogs: [],
    7581        approvals: [],
     82        learningSuggestions: [],
     83        learningLoaded: false,
     84        learningLoading: false,
    7685        selectedLogId: '',
    7786        visibleLogLimit: LOG_PAGE_SIZE,
     
    432441                .prop('hidden', !isActive);
    433442        });
     443
     444        if (normalizedTab === 'waf') {
     445            maybeLoadLearningSuggestions(false);
     446        }
    434447    }
    435448
     
    494507            Array.isArray(data.waf_whitelist_paths) ? data.waf_whitelist_paths.join('\n') : ''
    495508        );
     509        $firewallLearningEnabled.prop('checked', !!Number(data.learning_mode_enabled || 0));
     510        $firewallLearningThreshold.val(Number(data.learning_suggestion_threshold || 3));
     511        $firewallLearningWindow.val(Number(data.learning_suggestion_window_days || 7));
    496512        $firewallTrustCloudflare.prop('checked', !!Number(data.trust_cloudflare || 0));
    497513        $firewallTrustedProxies.val(
     
    814830    }
    815831
     832    function applyPayload(payload, options) {
     833        const data = payload || {};
     834        const config = $.extend({
     835            syncSettings: true
     836        }, options || {});
     837
     838        latestMuLoaderStatus = data.mu_loader || latestMuLoaderStatus;
     839        state.allLogs = Array.isArray(data.logs) ? data.logs : [];
     840        state.approvals = Array.isArray(data.approvals) ? data.approvals : [];
     841        state.proxyStatus = data.proxy_status || state.proxyStatus || {};
     842        state.lastUpdatedAt = new Date();
     843        state.lastRefreshFailed = false;
     844
     845        if (config.syncSettings) {
     846            applySettings(data.settings || {});
     847            renderLoginAccess(data.login_access || {});
     848            renderLearningSuggestions();
     849        }
     850
     851        renderOverview(data.summary || {}, latestMuLoaderStatus || {});
     852        renderFeed();
     853        renderApprovals();
     854        updateFeedChrome();
     855        updateProxyWarnings();
     856    }
     857
     858    function setBusyState(isBusy) {
     859        state.hardBusy = !!isBusy;
     860
     861        $firewallRoot.toggleClass('is-busy', !!isBusy);
     862        $firewallSaveSettings.prop('disabled', !!isBusy);
     863        $firewallRefresh.prop('disabled', !!isBusy);
     864        $firewallClearLogs.prop('disabled', !!isBusy);
     865        $firewallFeedToggle.prop('disabled', !!isBusy);
     866        $firewallFeedSearch.prop('disabled', !!isBusy);
     867        $firewallFeedFilters.prop('disabled', !!isBusy);
     868        $firewallLoadMore.find('[data-firewall-load-more]').prop('disabled', !!isBusy);
     869        $firewallTabs.prop('disabled', !!isBusy);
     870        $firewallEnabled.prop('disabled', !!isBusy);
     871        $firewallLoginProtection.prop('disabled', !!isBusy);
     872        $firewallCustomLoginSlug.prop('disabled', !!isBusy);
     873        $firewallTwoFactorEnabled.prop('disabled', !!isBusy);
     874        $firewallCaptchaProvider.prop('disabled', !!isBusy);
     875        $firewallXmlrpcPolicy.prop('disabled', !!isBusy);
     876        $firewallXmlrpcAllowlist.prop('disabled', !!isBusy);
     877        $firewallWeakPasswordBlockingEnabled.prop('disabled', !!isBusy);
     878        $firewallWafSqliEnabled.prop('disabled', !!isBusy);
     879        $firewallWafCommandEnabled.prop('disabled', !!isBusy);
     880        $firewallWafWhitelistPaths.prop('disabled', !!isBusy);
     881        $firewallLearningEnabled.prop('disabled', !!isBusy);
     882        $firewallLearningThreshold.prop('disabled', !!isBusy);
     883        $firewallLearningWindow.prop('disabled', !!isBusy);
     884        $firewallLearningRefresh.prop('disabled', !!isBusy);
     885        $firewallTrustCloudflare.prop('disabled', !!isBusy);
     886        $firewallTrustedProxies.prop('disabled', !!isBusy);
     887        $firewallMaxAttempts.prop('disabled', !!isBusy);
     888        $firewallLockoutMinutes.prop('disabled', !!isBusy);
     889        $firewallCommentShieldEnabled.prop('disabled', !!isBusy);
     890        $firewallCommentSuspiciousAction.prop('disabled', !!isBusy);
     891        $firewallCommentMinSubmitSeconds.prop('disabled', !!isBusy);
     892        $firewallCommentMaxLinks.prop('disabled', !!isBusy);
     893        $firewallCommentRateLimitAttempts.prop('disabled', !!isBusy);
     894        $firewallCommentRateLimitWindowMinutes.prop('disabled', !!isBusy);
     895        $firewallLogRetention.prop('disabled', !!isBusy);
     896        $firewallLockoutAllowlist.prop('disabled', !!isBusy);
     897        $firewallWeeklySummaryEmailEnabled.prop('disabled', !!isBusy);
     898        $firewallWeeklySummaryEmailRecipient.prop('disabled', !!isBusy);
     899        refreshIdentityControlState();
     900    }
     901
     902    function loadFirewallData(options) {
     903        const config = $.extend({
     904            showLoadingNotice: false,
     905            hardBusy: false,
     906            silentError: false,
     907            clearFeedbackOnSuccess: false,
     908            syncSettings: false
     909        }, options || {});
     910
     911        if (state.requestInFlight) {
     912            return;
     913        }
     914
     915        if (config.hardBusy) {
     916            setBusyState(true);
     917        }
     918
     919        state.requestInFlight = true;
     920        updateFeedChrome();
     921
     922        if (config.showLoadingNotice) {
     923            setFeedback('info', i18n.firewall_refreshing || 'Refreshing firewall data...');
     924        }
     925
     926        $.post(VulnTitan.ajaxUrl, {
     927            action: 'vulntitan_firewall_get_data',
     928            nonce: VulnTitan.nonce
     929        }, function (response) {
     930            if (!response || !response.success) {
     931                state.lastRefreshFailed = true;
     932                updateFeedChrome();
     933
     934                if (!config.silentError) {
     935                    const message = (response && response.data && response.data.message)
     936                        ? response.data.message
     937                        : (i18n.firewall_load_failed || 'Failed to load firewall data.');
     938                    setFeedback('error', message);
     939                }
     940                return;
     941            }
     942
     943            applyPayload(response.data || {}, {
     944                syncSettings: config.syncSettings
     945            });
     946
     947            if (config.clearFeedbackOnSuccess) {
     948                setFeedback('', '');
     949            }
     950        }).fail(function () {
     951            state.lastRefreshFailed = true;
     952            updateFeedChrome();
     953
     954            if (!config.silentError) {
     955                setFeedback('error', i18n.firewall_load_failed || 'Failed to load firewall data.');
     956            }
     957        }).always(function () {
     958            state.requestInFlight = false;
     959            updateFeedChrome();
     960
     961            if (config.hardBusy) {
     962                setBusyState(false);
     963            }
     964        });
     965    }
     966
    816967    function renderApprovals() {
    817968        if (!$firewallApprovalsList.length) {
     
    8861037    }
    8871038
    888     function applyPayload(payload, options) {
    889         const data = payload || {};
     1039    function renderLearningSuggestions() {
     1040        if (!$firewallLearningList.length) {
     1041            return;
     1042        }
     1043
     1044        const enabled = $firewallLearningEnabled.is(':checked');
     1045        const suggestions = Array.isArray(state.learningSuggestions) ? state.learningSuggestions : [];
     1046
     1047        if (!enabled) {
     1048            $firewallLearningList.empty();
     1049            $firewallLearningEmpty
     1050                .text(i18n.firewall_learning_disabled || 'Learning suggestions are disabled.')
     1051                .show();
     1052            return;
     1053        }
     1054
     1055        if (!suggestions.length) {
     1056            $firewallLearningList.empty();
     1057            $firewallLearningEmpty
     1058                .text(i18n.firewall_learning_empty || 'No learning suggestions yet.')
     1059                .show();
     1060            return;
     1061        }
     1062
     1063        $firewallLearningEmpty.hide();
     1064        $firewallLearningList.html(suggestions.map(renderLearningItem).join(''));
     1065    }
     1066
     1067    function renderLearningItem(item) {
     1068        const pattern = String(item.pattern || '');
     1069        const lastSeen = item.last_seen_local || item.last_seen || '';
     1070        const count = Number(item.count || 0);
     1071        const ruleGroup = item.rule_group || '';
     1072        const ruleId = item.rule_id || '';
     1073        const action = item.approval_action || '';
     1074        const restRoute = item.approval_rest_route || '';
     1075        const actionLabel = action
     1076            ? `${i18n.firewall_approval_action || 'Action'}: ${action}`
     1077            : (restRoute ? `${i18n.firewall_approval_route || 'Route'}: ${restRoute}` : '');
     1078
     1079        return `
     1080            <div class="vulntitan-firewall-log-item vulntitan-firewall-approval-card">
     1081                <div class="vulntitan-firewall-log-head">
     1082                    <span class="vulntitan-firewall-log-badge is-warning">
     1083                        ${escapeHtml(i18n.firewall_learning_suggestion || 'Learning suggestion')}
     1084                    </span>
     1085                    <time class="vulntitan-firewall-log-time">${escapeHtml(lastSeen)}</time>
     1086                </div>
     1087                <div class="vulntitan-firewall-log-request">${escapeHtml(pattern || '/')}</div>
     1088                <div class="vulntitan-firewall-log-meta">
     1089                    <span class="vulntitan-firewall-log-meta-item">${escapeHtml(i18n.firewall_learning_hits || 'Hits')}: ${count}</span>
     1090                    ${ruleGroup ? `<span class="vulntitan-firewall-log-meta-item">Group: ${escapeHtml(ruleGroup)}</span>` : ''}
     1091                    ${ruleId ? `<span class="vulntitan-firewall-log-meta-item">Rule: ${escapeHtml(ruleId)}</span>` : ''}
     1092                    ${actionLabel ? `<span class="vulntitan-firewall-log-meta-item">${escapeHtml(actionLabel)}</span>` : ''}
     1093                </div>
     1094                <div class="vulntitan-firewall-approval-pattern">
     1095                    <span>${escapeHtml(i18n.firewall_approval_pattern || 'Whitelist pattern')}:</span>
     1096                    <code>${escapeHtml(pattern)}</code>
     1097                </div>
     1098                <div class="vulntitan-firewall-approval-actions">
     1099                    <button type="button" class="vulntitan-fw-btn vulntitan-fw-btn-primary" data-firewall-learning-allow="${escapeHtml(pattern)}">
     1100                        ${escapeHtml(i18n.firewall_learning_allow || 'Allow')}
     1101                    </button>
     1102                    <button type="button" class="vulntitan-fw-btn vulntitan-fw-btn-secondary" data-firewall-learning-dismiss="${escapeHtml(pattern)}">
     1103                        ${escapeHtml(i18n.firewall_learning_dismiss || 'Dismiss')}
     1104                    </button>
     1105                </div>
     1106            </div>
     1107        `;
     1108    }
     1109
     1110    function loadLearningSuggestions(options) {
     1111        if (!$firewallLearningList.length) {
     1112            return;
     1113        }
     1114
    8901115        const config = $.extend({
    891             syncSettings: true
     1116            silent: false,
     1117            force: false
    8921118        }, options || {});
    8931119
    894         latestMuLoaderStatus = data.mu_loader || latestMuLoaderStatus;
    895         state.allLogs = Array.isArray(data.logs) ? data.logs : [];
    896         state.approvals = Array.isArray(data.approvals) ? data.approvals : [];
    897         state.proxyStatus = data.proxy_status || state.proxyStatus || {};
    898         state.lastUpdatedAt = new Date();
    899         state.lastRefreshFailed = false;
    900 
    901         if (config.syncSettings) {
    902             applySettings(data.settings || {});
    903             renderLoginAccess(data.login_access || {});
    904         }
    905 
    906         renderOverview(data.summary || {}, latestMuLoaderStatus || {});
    907         renderFeed();
    908         renderApprovals();
    909         updateFeedChrome();
    910         updateProxyWarnings();
    911     }
    912 
    913     function setBusyState(isBusy) {
    914         state.hardBusy = !!isBusy;
    915 
    916         $firewallRoot.toggleClass('is-busy', !!isBusy);
    917         $firewallSaveSettings.prop('disabled', !!isBusy);
    918         $firewallRefresh.prop('disabled', !!isBusy);
    919         $firewallClearLogs.prop('disabled', !!isBusy);
    920         $firewallFeedToggle.prop('disabled', !!isBusy);
    921         $firewallFeedSearch.prop('disabled', !!isBusy);
    922         $firewallFeedFilters.prop('disabled', !!isBusy);
    923         $firewallLoadMore.find('[data-firewall-load-more]').prop('disabled', !!isBusy);
    924         $firewallTabs.prop('disabled', !!isBusy);
    925         $firewallEnabled.prop('disabled', !!isBusy);
    926         $firewallLoginProtection.prop('disabled', !!isBusy);
    927         $firewallCustomLoginSlug.prop('disabled', !!isBusy);
    928         $firewallTwoFactorEnabled.prop('disabled', !!isBusy);
    929         $firewallCaptchaProvider.prop('disabled', !!isBusy);
    930         $firewallXmlrpcPolicy.prop('disabled', !!isBusy);
    931         $firewallXmlrpcAllowlist.prop('disabled', !!isBusy);
    932         $firewallWeakPasswordBlockingEnabled.prop('disabled', !!isBusy);
    933         $firewallWafSqliEnabled.prop('disabled', !!isBusy);
    934         $firewallWafCommandEnabled.prop('disabled', !!isBusy);
    935         $firewallWafWhitelistPaths.prop('disabled', !!isBusy);
    936         $firewallTrustCloudflare.prop('disabled', !!isBusy);
    937         $firewallTrustedProxies.prop('disabled', !!isBusy);
    938         $firewallMaxAttempts.prop('disabled', !!isBusy);
    939         $firewallLockoutMinutes.prop('disabled', !!isBusy);
    940         $firewallCommentShieldEnabled.prop('disabled', !!isBusy);
    941         $firewallCommentSuspiciousAction.prop('disabled', !!isBusy);
    942         $firewallCommentMinSubmitSeconds.prop('disabled', !!isBusy);
    943         $firewallCommentMaxLinks.prop('disabled', !!isBusy);
    944         $firewallCommentRateLimitAttempts.prop('disabled', !!isBusy);
    945         $firewallCommentRateLimitWindowMinutes.prop('disabled', !!isBusy);
    946         $firewallLogRetention.prop('disabled', !!isBusy);
    947         $firewallLockoutAllowlist.prop('disabled', !!isBusy);
    948         $firewallWeeklySummaryEmailEnabled.prop('disabled', !!isBusy);
    949         $firewallWeeklySummaryEmailRecipient.prop('disabled', !!isBusy);
    950         refreshIdentityControlState();
    951     }
    952 
    953     function loadFirewallData(options) {
    954         const config = $.extend({
    955             showLoadingNotice: false,
    956             hardBusy: false,
    957             silentError: false,
    958             clearFeedbackOnSuccess: false,
    959             syncSettings: false
    960         }, options || {});
    961 
    962         if (state.requestInFlight) {
    963             return;
    964         }
    965 
    966         if (config.hardBusy) {
    967             setBusyState(true);
    968         }
    969 
    970         state.requestInFlight = true;
    971         updateFeedChrome();
    972 
    973         if (config.showLoadingNotice) {
    974             setFeedback('info', i18n.firewall_refreshing || 'Refreshing firewall data...');
     1120        if (!$firewallLearningEnabled.is(':checked')) {
     1121            state.learningSuggestions = [];
     1122            renderLearningSuggestions();
     1123            return;
     1124        }
     1125
     1126        if (state.learningLoading) {
     1127            return;
     1128        }
     1129
     1130        if (state.learningLoaded && !config.force) {
     1131            renderLearningSuggestions();
     1132            return;
     1133        }
     1134
     1135        state.learningLoading = true;
     1136
     1137        if (!config.silent) {
     1138            setFeedback('info', i18n.firewall_learning_loading || 'Loading learning suggestions...');
    9751139        }
    9761140
    9771141        $.post(VulnTitan.ajaxUrl, {
    978             action: 'vulntitan_firewall_get_data',
     1142            action: 'vulntitan_firewall_get_learning',
    9791143            nonce: VulnTitan.nonce
    9801144        }, function (response) {
    9811145            if (!response || !response.success) {
    982                 state.lastRefreshFailed = true;
    983                 updateFeedChrome();
    984 
    985                 if (!config.silentError) {
    986                     const message = (response && response.data && response.data.message)
    987                         ? response.data.message
    988                         : (i18n.firewall_load_failed || 'Failed to load firewall data.');
     1146                const message = (response && response.data && response.data.message)
     1147                    ? response.data.message
     1148                    : (i18n.firewall_learning_failed || 'Failed to load learning suggestions.');
     1149                if (!config.silent) {
    9891150                    setFeedback('error', message);
    9901151                }
     
    9921153            }
    9931154
    994             applyPayload(response.data || {}, {
    995                 syncSettings: config.syncSettings
    996             });
    997 
    998             if (config.clearFeedbackOnSuccess) {
    999                 setFeedback('', '');
     1155            const payload = response.data || {};
     1156            state.learningSuggestions = Array.isArray(payload.suggestions) ? payload.suggestions : [];
     1157            state.learningLoaded = true;
     1158            renderLearningSuggestions();
     1159
     1160            if (!config.silent) {
     1161                setFeedback('success', i18n.firewall_learning_updated || 'Learning suggestions updated.');
    10001162            }
    10011163        }).fail(function () {
    1002             state.lastRefreshFailed = true;
    1003             updateFeedChrome();
    1004 
    1005             if (!config.silentError) {
    1006                 setFeedback('error', i18n.firewall_load_failed || 'Failed to load firewall data.');
     1164            if (!config.silent) {
     1165                setFeedback('error', i18n.firewall_learning_failed || 'Failed to load learning suggestions.');
    10071166            }
    10081167        }).always(function () {
    1009             state.requestInFlight = false;
    1010             updateFeedChrome();
    1011 
    1012             if (config.hardBusy) {
    1013                 setBusyState(false);
    1014             }
     1168            state.learningLoading = false;
    10151169        });
     1170    }
     1171
     1172    function maybeLoadLearningSuggestions(force) {
     1173        if (force) {
     1174            loadLearningSuggestions({ force: true, silent: true });
     1175            return;
     1176        }
     1177
     1178        if (!state.learningLoaded) {
     1179            loadLearningSuggestions({ silent: true });
     1180        } else {
     1181            renderLearningSuggestions();
     1182        }
    10161183    }
    10171184
     
    10511218            waf_command_injection_enabled: $firewallWafCommandEnabled.is(':checked') ? 1 : 0,
    10521219            waf_whitelist_paths: String($firewallWafWhitelistPaths.val() || ''),
     1220            learning_mode_enabled: $firewallLearningEnabled.is(':checked') ? 1 : 0,
     1221            learning_suggestion_threshold: Number($firewallLearningThreshold.val() || 3),
     1222            learning_suggestion_window_days: Number($firewallLearningWindow.val() || 7),
    10531223            trust_cloudflare: $firewallTrustCloudflare.is(':checked') ? 1 : 0,
    10541224            trusted_proxies: String($firewallTrustedProxies.val() || ''),
     
    10791249            });
    10801250
     1251            const activeTab = String($firewallTabs.filter('.is-active').data('firewallTab') || '');
     1252            if (activeTab === 'waf' && $firewallLearningEnabled.is(':checked')) {
     1253                state.learningLoaded = false;
     1254                loadLearningSuggestions({
     1255                    silent: true,
     1256                    force: true
     1257                });
     1258            }
     1259
    10811260            const muInstall = payload.mu_loader_install || {};
    10821261            if (payload.notice) {
     
    12381417    }
    12391418
     1419    function sendLearningAction(action, pattern, $button, confirmMessage, successMessage) {
     1420        const cleanPattern = String(pattern || '').trim();
     1421        if (!cleanPattern) {
     1422            setFeedback('error', i18n.firewall_approval_invalid || 'Invalid learning suggestion.');
     1423            return;
     1424        }
     1425
     1426        if (confirmMessage && !window.confirm(confirmMessage)) {
     1427            return;
     1428        }
     1429
     1430        if ($button && $button.length) {
     1431            $button.prop('disabled', true);
     1432        }
     1433
     1434        setFeedback('info', i18n.firewall_action_in_progress || 'Working...');
     1435
     1436        $.post(VulnTitan.ajaxUrl, {
     1437            action: action,
     1438            nonce: VulnTitan.nonce,
     1439            pattern: cleanPattern
     1440        }, function (response) {
     1441            if (!response || !response.success) {
     1442                const message = (response && response.data && response.data.message)
     1443                    ? response.data.message
     1444                    : (i18n.firewall_action_failed || 'Action failed.');
     1445                setFeedback('error', message);
     1446                return;
     1447            }
     1448
     1449            const payload = response.data || {};
     1450            if (payload.settings) {
     1451                applySettings(payload.settings);
     1452            }
     1453
     1454            if (Array.isArray(payload.suggestions)) {
     1455                state.learningSuggestions = payload.suggestions;
     1456                state.learningLoaded = true;
     1457                renderLearningSuggestions();
     1458            }
     1459
     1460            setFeedback('success', successMessage);
     1461        }).fail(function () {
     1462            setFeedback('error', i18n.firewall_action_failed || 'Action failed.');
     1463        }).always(function () {
     1464            if ($button && $button.length) {
     1465                $button.prop('disabled', false);
     1466            }
     1467        });
     1468    }
     1469
    12401470    function handleFeedToggle() {
    12411471        state.feedPaused = !state.feedPaused;
     
    13581588    });
    13591589
     1590    $firewallLearningRefresh.off('click').on('click', function () {
     1591        loadLearningSuggestions({
     1592            silent: false,
     1593            force: true
     1594        });
     1595    });
     1596
     1597    $firewallLearningEnabled.off('change').on('change', function () {
     1598        if ($firewallLearningEnabled.is(':checked')) {
     1599            state.learningLoaded = false;
     1600            loadLearningSuggestions({
     1601                silent: true,
     1602                force: true
     1603            });
     1604            return;
     1605        }
     1606
     1607        state.learningLoaded = false;
     1608        state.learningSuggestions = [];
     1609        renderLearningSuggestions();
     1610    });
     1611
     1612    $firewallLearningList.off('click', '[data-firewall-learning-allow]').on('click', '[data-firewall-learning-allow]', function () {
     1613        const $button = $(this);
     1614        const pattern = String($button.data('firewallLearningAllow') || '');
     1615        sendLearningAction(
     1616            'vulntitan_firewall_apply_learning',
     1617            pattern,
     1618            $button,
     1619            i18n.firewall_learning_confirm_allow || 'Allow this suggested pattern and add it to the WAF whitelist?',
     1620            i18n.firewall_approval_success || 'Whitelist updated.'
     1621        );
     1622    });
     1623
     1624    $firewallLearningList.off('click', '[data-firewall-learning-dismiss]').on('click', '[data-firewall-learning-dismiss]', function () {
     1625        const $button = $(this);
     1626        const pattern = String($button.data('firewallLearningDismiss') || '');
     1627        sendLearningAction(
     1628            'vulntitan_firewall_dismiss_learning',
     1629            pattern,
     1630            $button,
     1631            i18n.firewall_learning_confirm_dismiss || 'Dismiss this learning suggestion?',
     1632            i18n.firewall_learning_dismissed || 'Learning suggestion dismissed.'
     1633        );
     1634    });
     1635
    13601636    $firewallLoadMore.off('click', '[data-firewall-load-more]').on('click', '[data-firewall-load-more]', function () {
    13611637        state.visibleLogLimit += LOG_PAGE_SIZE;
  • vulntitan/trunk/includes/Admin/Admin.php

    r3483333 r3483739  
    179179                    'firewall_approval_no_pattern' => esc_html__('No safe auto-approval pattern available.', 'vulntitan'),
    180180                    'firewall_approval_invalid' => esc_html__('Invalid approval request.', 'vulntitan'),
     181                    'firewall_learning_loading' => esc_html__('Loading learning suggestions...', 'vulntitan'),
     182                    'firewall_learning_updated' => esc_html__('Learning suggestions updated.', 'vulntitan'),
     183                    'firewall_learning_failed' => esc_html__('Failed to load learning suggestions.', 'vulntitan'),
     184                    'firewall_learning_empty' => esc_html__('No learning suggestions yet.', 'vulntitan'),
     185                    'firewall_learning_disabled' => esc_html__('Learning suggestions are disabled.', 'vulntitan'),
     186                    'firewall_learning_suggestion' => esc_html__('Learning suggestion', 'vulntitan'),
     187                    'firewall_learning_hits' => esc_html__('Hits', 'vulntitan'),
     188                    'firewall_learning_last_seen' => esc_html__('Last seen', 'vulntitan'),
     189                    'firewall_learning_allow' => esc_html__('Allow', 'vulntitan'),
     190                    'firewall_learning_dismiss' => esc_html__('Dismiss', 'vulntitan'),
     191                    'firewall_learning_dismissed' => esc_html__('Learning suggestion dismissed.', 'vulntitan'),
     192                    'firewall_learning_confirm_allow' => esc_html__('Allow this suggested pattern and add it to the WAF whitelist?', 'vulntitan'),
     193                    'firewall_learning_confirm_dismiss' => esc_html__('Dismiss this learning suggestion?', 'vulntitan'),
     194                    'firewall_learning_refresh' => esc_html__('Refresh suggestions', 'vulntitan'),
    181195                ],
    182196            ]);
  • vulntitan/trunk/includes/Admin/Ajax.php

    r3483644 r3483739  
    2929        add_action('wp_ajax_vulntitan_firewall_unblock_ip', [$this, 'firewallUnblockIp']);
    3030        add_action('wp_ajax_vulntitan_firewall_allowlist_ip', [$this, 'firewallAllowlistIp']);
     31        add_action('wp_ajax_vulntitan_firewall_get_learning', [$this, 'firewallGetLearning']);
     32        add_action('wp_ajax_vulntitan_firewall_apply_learning', [$this, 'firewallApplyLearning']);
     33        add_action('wp_ajax_vulntitan_firewall_dismiss_learning', [$this, 'firewallDismissLearning']);
    3134    }
    3235
     
    9295            'waf_command_injection_enabled' => isset($_POST['waf_command_injection_enabled']) ? (int) wp_unslash($_POST['waf_command_injection_enabled']) : 1,
    9396            'waf_whitelist_paths' => isset($_POST['waf_whitelist_paths']) ? (string) wp_unslash($_POST['waf_whitelist_paths']) : '',
     97            'learning_mode_enabled' => isset($_POST['learning_mode_enabled']) ? (int) wp_unslash($_POST['learning_mode_enabled']) : 0,
     98            'learning_suggestion_threshold' => isset($_POST['learning_suggestion_threshold']) ? (int) wp_unslash($_POST['learning_suggestion_threshold']) : 3,
     99            'learning_suggestion_window_days' => isset($_POST['learning_suggestion_window_days']) ? (int) wp_unslash($_POST['learning_suggestion_window_days']) : 7,
    94100            'trust_cloudflare' => isset($_POST['trust_cloudflare']) ? (int) wp_unslash($_POST['trust_cloudflare']) : 0,
    95101            'trusted_proxies' => isset($_POST['trusted_proxies']) ? (string) wp_unslash($_POST['trusted_proxies']) : '',
     
    271277            'allowlisted' => !$alreadyAllowlisted,
    272278            'settings' => $settings,
     279        ]);
     280    }
     281
     282    public function firewallGetLearning(): void
     283    {
     284        check_ajax_referer('vulntitan_ajax_scan', 'nonce');
     285
     286        if (!current_user_can('manage_options')) {
     287            wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403);
     288        }
     289
     290        $settings = FirewallService::getSettings();
     291        $enabled = !empty($settings['learning_mode_enabled']);
     292        $suggestions = $enabled ? FirewallService::getLearningSuggestions(30) : [];
     293
     294        wp_send_json_success([
     295            'enabled' => $enabled ? 1 : 0,
     296            'suggestions' => $suggestions,
     297        ]);
     298    }
     299
     300    public function firewallApplyLearning(): void
     301    {
     302        check_ajax_referer('vulntitan_ajax_scan', 'nonce');
     303
     304        if (!current_user_can('manage_options')) {
     305            wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403);
     306        }
     307
     308        $pattern = isset($_POST['pattern']) ? trim((string) wp_unslash($_POST['pattern'])) : '';
     309        if ($pattern === '') {
     310            wp_send_json_error(['message' => esc_html__('Invalid learning suggestion.', 'vulntitan')], 400);
     311        }
     312
     313        $result = FirewallService::applyLearningSuggestion($pattern);
     314        if (empty($result['success'])) {
     315            wp_send_json_error(['message' => esc_html__('Invalid learning suggestion.', 'vulntitan')], 400);
     316        }
     317
     318        wp_send_json_success([
     319            'settings' => $result['settings'] ?? FirewallService::getSettings(),
     320            'suggestions' => FirewallService::getLearningSuggestions(30),
     321        ]);
     322    }
     323
     324    public function firewallDismissLearning(): void
     325    {
     326        check_ajax_referer('vulntitan_ajax_scan', 'nonce');
     327
     328        if (!current_user_can('manage_options')) {
     329            wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403);
     330        }
     331
     332        $pattern = isset($_POST['pattern']) ? trim((string) wp_unslash($_POST['pattern'])) : '';
     333        if ($pattern === '') {
     334            wp_send_json_error(['message' => esc_html__('Invalid learning suggestion.', 'vulntitan')], 400);
     335        }
     336
     337        if (!FirewallService::dismissLearningSuggestion($pattern)) {
     338            wp_send_json_error(['message' => esc_html__('Invalid learning suggestion.', 'vulntitan')], 400);
     339        }
     340
     341        wp_send_json_success([
     342            'suggestions' => FirewallService::getLearningSuggestions(30),
    273343        ]);
    274344    }
  • vulntitan/trunk/includes/Admin/Pages/Firewall.php

    r3483644 r3483739  
    423423                                                    </label>
    424424                                                </div>
     425
     426                                                <div class="vulntitan-firewall-section">
     427                                                    <div class="vulntitan-firewall-section-title"><?php esc_html_e('Learning Mode', 'vulntitan'); ?></div>
     428                                                    <div class="vulntitan-firewall-section-desc"><?php esc_html_e('Generate smart allowlist suggestions from repeated admin-triggered WAF blocks. This never auto-whitelists—everything requires approval.', 'vulntitan'); ?></div>
     429
     430                                                    <label class="vulntitan-firewall-toggle">
     431                                                        <input type="checkbox" id="vulntitan-firewall-learning-enabled" class="vulntitan-firewall-checkbox">
     432                                                        <span><?php esc_html_e('Enable learning suggestions', 'vulntitan'); ?></span>
     433                                                    </label>
     434
     435                                                    <div class="vulntitan-firewall-field-grid">
     436                                                        <label class="vulntitan-firewall-field">
     437                                                            <span class="vulntitan-firewall-field-label"><?php esc_html_e('Minimum hits to suggest', 'vulntitan'); ?></span>
     438                                                            <input type="number" id="vulntitan-firewall-learning-threshold" class="vulntitan-firewall-input" min="2" max="20" value="3">
     439                                                        </label>
     440
     441                                                        <label class="vulntitan-firewall-field">
     442                                                            <span class="vulntitan-firewall-field-label"><?php esc_html_e('Lookback window (days)', 'vulntitan'); ?></span>
     443                                                            <input type="number" id="vulntitan-firewall-learning-window" class="vulntitan-firewall-input" min="1" max="30" value="7">
     444                                                        </label>
     445
     446                                                        <div class="vulntitan-firewall-field">
     447                                                            <span class="vulntitan-firewall-field-label"><?php esc_html_e('Suggestions', 'vulntitan'); ?></span>
     448                                                            <button type="button" class="vulntitan-fw-btn vulntitan-fw-btn-secondary" id="vulntitan-firewall-learning-refresh"><?php esc_html_e('Refresh suggestions', 'vulntitan'); ?></button>
     449                                                            <small class="vulntitan-firewall-field-help"><?php esc_html_e('Learning only analyzes WAF blocks triggered by logged-in administrators.', 'vulntitan'); ?></small>
     450                                                        </div>
     451                                                    </div>
     452                                                </div>
     453
     454                                                <div class="vulntitan-firewall-section">
     455                                                    <div class="vulntitan-firewall-section-title"><?php esc_html_e('Learning Suggestions', 'vulntitan'); ?></div>
     456                                                    <div class="vulntitan-firewall-section-desc"><?php esc_html_e('Review suggested whitelist patterns derived from repeated WAF blocks. Approving adds a pattern to the WAF whitelist.', 'vulntitan'); ?></div>
     457                                                    <div id="vulntitan-firewall-learning-empty" class="vulntitan-firewall-approvals-empty">
     458                                                        <?php esc_html_e('Learning suggestions are disabled.', 'vulntitan'); ?>
     459                                                    </div>
     460                                                    <div id="vulntitan-firewall-learning-list" class="vulntitan-firewall-log-list"></div>
     461                                                </div>
    425462                                            </section>
    426463
  • vulntitan/trunk/includes/Services/FirewallService.php

    r3483644 r3483739  
    88    protected const OPTION_MU_STATUS = 'vulntitan_firewall_mu_status';
    99    protected const OPTION_SCHEMA_VERSION = 'vulntitan_firewall_schema_version';
     10    protected const OPTION_LEARNING_DISMISSED = 'vulntitan_firewall_learning_dismissed';
    1011    protected const MU_LOADER_FILENAME = 'vulntitan-firewall.php';
    1112    protected const DEFAULT_LOG_RETENTION_DAYS = 30;
     
    4142            'waf_command_injection_enabled' => 1,
    4243            'waf_whitelist_paths' => [],
     44            'learning_mode_enabled' => 0,
     45            'learning_suggestion_threshold' => 3,
     46            'learning_suggestion_window_days' => 7,
    4347            'trust_cloudflare' => 0,
    4448            'trusted_proxies' => [],
     
    728732    }
    729733
     734    public static function getLearningSuggestions(int $limit = 20): array
     735    {
     736        $settings = self::getSettings();
     737        if (empty($settings['learning_mode_enabled'])) {
     738            return [];
     739        }
     740
     741        if (!self::ensureTable()) {
     742            return [];
     743        }
     744
     745        global $wpdb;
     746
     747        $safeLimit = max(1, min(50, $limit));
     748        $minHits = max(2, min(20, (int)($settings['learning_suggestion_threshold'] ?? 3)));
     749        $windowDays = max(1, min(30, (int)($settings['learning_suggestion_window_days'] ?? 7)));
     750        $threshold = gmdate('Y-m-d H:i:s', time() - ($windowDays * DAY_IN_SECONDS));
     751        $likePattern = '%"approval_status":"pending"%';
     752        $queryLimit = max(200, min(2000, $safeLimit * 80));
     753        $tableName = self::getTableName();
     754
     755        $rows = $wpdb->get_results(
     756            $wpdb->prepare(
     757                "SELECT id, rule_group, rule_id, request_method, request_host, request_uri, request_path, details, created_at
     758                 FROM {$tableName}
     759                 WHERE event_type = 'request_blocked'
     760                   AND rule_group IN ('waf_sqli', 'waf_command_injection', 'waf')
     761                   AND details LIKE %s
     762                   AND created_at >= %s
     763                 ORDER BY id DESC
     764                 LIMIT {$queryLimit}",
     765                $likePattern,
     766                $threshold
     767            ),
     768            ARRAY_A
     769        );
     770
     771        if (!is_array($rows) || !$rows) {
     772            return [];
     773        }
     774
     775        $dismissed = self::getLearningDismissedPatterns();
     776        $whitelist = $settings['waf_whitelist_paths'] ?? [];
     777        if (!is_array($whitelist)) {
     778            $whitelist = [];
     779        }
     780
     781        $grouped = [];
     782
     783        foreach ($rows as $row) {
     784            $details = self::decodeDetails((string)($row['details'] ?? ''));
     785            if (($details['approval_status'] ?? '') !== 'pending') {
     786                continue;
     787            }
     788
     789            $row['details'] = $details;
     790            $pattern = self::buildApprovalPattern($row);
     791            if ($pattern === '') {
     792                continue;
     793            }
     794
     795            if (in_array($pattern, $whitelist, true) || in_array($pattern, $dismissed, true)) {
     796                continue;
     797            }
     798
     799            $key = strtolower($pattern);
     800            if (!isset($grouped[$key])) {
     801                $grouped[$key] = [
     802                    'pattern' => $pattern,
     803                    'count' => 0,
     804                    'rule_group' => (string)($row['rule_group'] ?? ''),
     805                    'rule_id' => (string)($row['rule_id'] ?? ''),
     806                    'approval_action' => isset($details['approval_action']) ? self::sanitizeShortText((string) $details['approval_action'], 80) : '',
     807                    'approval_rest_route' => isset($details['approval_rest_route']) ? self::sanitizeShortText((string) $details['approval_rest_route'], 160) : '',
     808                    'request_path' => (string)($row['request_path'] ?? ''),
     809                    'request_uri' => (string)($row['request_uri'] ?? ''),
     810                    'last_seen' => (string)($row['created_at'] ?? ''),
     811                ];
     812            }
     813
     814            $grouped[$key]['count']++;
     815
     816            if ((string)($row['created_at'] ?? '') > (string)$grouped[$key]['last_seen']) {
     817                $grouped[$key]['last_seen'] = (string)($row['created_at'] ?? '');
     818            }
     819        }
     820
     821        $filtered = array_filter($grouped, function ($item) use ($minHits) {
     822            return (int)($item['count'] ?? 0) >= $minHits;
     823        });
     824
     825        if (!$filtered) {
     826            return [];
     827        }
     828
     829        $sorted = array_values($filtered);
     830        usort($sorted, function ($a, $b) {
     831            $countDiff = (int)($b['count'] ?? 0) - (int)($a['count'] ?? 0);
     832            if ($countDiff !== 0) {
     833                return $countDiff;
     834            }
     835            return strcmp((string)($b['last_seen'] ?? ''), (string)($a['last_seen'] ?? ''));
     836        });
     837
     838        $sorted = array_slice($sorted, 0, $safeLimit);
     839
     840        foreach ($sorted as &$item) {
     841            $item['last_seen_local'] = self::toLocalDate((string)($item['last_seen'] ?? ''));
     842        }
     843        unset($item);
     844
     845        return $sorted;
     846    }
     847
     848    public static function applyLearningSuggestion(string $pattern): array
     849    {
     850        $pattern = self::normalizeLearningPattern($pattern);
     851        if ($pattern === '') {
     852            return [
     853                'success' => false,
     854                'message' => 'invalid_pattern',
     855            ];
     856        }
     857
     858        $settings = self::getSettings();
     859        $whitelist = $settings['waf_whitelist_paths'] ?? [];
     860        if (!is_array($whitelist)) {
     861            $whitelist = [];
     862        }
     863
     864        if (!in_array($pattern, $whitelist, true)) {
     865            $whitelist[] = $pattern;
     866            $settings = self::saveSettings([
     867                'waf_whitelist_paths' => $whitelist,
     868            ]);
     869        }
     870
     871        self::removeLearningDismissedPattern($pattern);
     872
     873        return [
     874            'success' => true,
     875            'settings' => $settings,
     876        ];
     877    }
     878
     879    public static function dismissLearningSuggestion(string $pattern): bool
     880    {
     881        $pattern = self::normalizeLearningPattern($pattern);
     882        if ($pattern === '') {
     883            return false;
     884        }
     885
     886        $dismissed = self::getLearningDismissedPatterns();
     887        if (!in_array($pattern, $dismissed, true)) {
     888            $dismissed[] = $pattern;
     889        }
     890
     891        self::storeLearningDismissedPatterns($dismissed);
     892
     893        return true;
     894    }
     895
     896    protected static function normalizeLearningPattern(string $pattern): string
     897    {
     898        $pattern = trim($pattern);
     899        if ($pattern === '') {
     900            return '';
     901        }
     902
     903        $normalized = self::sanitizeWhitelistPaths([$pattern]);
     904        return $normalized[0] ?? '';
     905    }
     906
     907    protected static function getLearningDismissedPatterns(): array
     908    {
     909        $stored = get_option(self::OPTION_LEARNING_DISMISSED, []);
     910        if (!is_array($stored)) {
     911            $stored = [];
     912        }
     913
     914        return self::sanitizeWhitelistPaths($stored);
     915    }
     916
     917    protected static function storeLearningDismissedPatterns(array $patterns): void
     918    {
     919        $patterns = self::sanitizeWhitelistPaths($patterns);
     920        if (count($patterns) > 200) {
     921            $patterns = array_slice($patterns, -200);
     922        }
     923
     924        update_option(self::OPTION_LEARNING_DISMISSED, $patterns, false);
     925    }
     926
     927    protected static function removeLearningDismissedPattern(string $pattern): void
     928    {
     929        $pattern = self::normalizeLearningPattern($pattern);
     930        if ($pattern === '') {
     931            return;
     932        }
     933
     934        $dismissed = self::getLearningDismissedPatterns();
     935        $filtered = array_values(array_filter($dismissed, function ($item) use ($pattern) {
     936            return $item !== $pattern;
     937        }));
     938
     939        if ($filtered !== $dismissed) {
     940            self::storeLearningDismissedPatterns($filtered);
     941        }
     942    }
     943
    730944    protected static function buildApprovalPattern(array $row): string
    731945    {
     
    9491163    }
    9501164
    951     protected static function getTrustedProxyIps(array $settings = null): array
     1165    protected static function getTrustedProxyIps(?array $settings = null): array
    9521166    {
    9531167        $trusted = [];
     
    13011515            'waf_command_injection_enabled' => !empty($settings['waf_command_injection_enabled']) ? 1 : 0,
    13021516            'waf_whitelist_paths' => $wafWhitelistPaths,
     1517            'learning_mode_enabled' => !empty($settings['learning_mode_enabled']) ? 1 : 0,
     1518            'learning_suggestion_threshold' => max(2, min(20, (int)($settings['learning_suggestion_threshold'] ?? $defaults['learning_suggestion_threshold']))),
     1519            'learning_suggestion_window_days' => max(1, min(30, (int)($settings['learning_suggestion_window_days'] ?? $defaults['learning_suggestion_window_days']))),
    13031520            'trust_cloudflare' => !empty($settings['trust_cloudflare']) ? 1 : 0,
    13041521            'trusted_proxies' => $trustedProxies,
  • vulntitan/trunk/readme.txt

    r3483644 r3483739  
    44Tested up to: 6.9
    55Requires PHP: 7.4
    6 Stable tag: 2.1.9
     6Stable tag: 2.1.10
    77License: GPLv2
    88License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    169169== Changelog ==
    170170
     171= v2.1.10 - 16 Mar, 2026 =
     172* Added Learning Mode suggestions for WAF whitelisting, with configurable thresholds and review-only approvals.
     173* Added a Learning Suggestions panel and actions to approve or dismiss suggested patterns.
     174* Fixed a PHP 8.4 deprecation warning by making trusted proxy settings nullable explicitly.
     175
    171176= v2.1.9 - 16 Mar, 2026 =
    172177* Added Proxy/CDN configuration in Firewall settings, including Trust Cloudflare and trusted proxy IPs.
  • vulntitan/trunk/vulntitan.php

    r3483644 r3483739  
    44 * Plugin URI: https://vulntitan.com/vulntitan/
    55 * Description: VulnTitan is a WordPress security plugin with vulnerability scanning, malware detection, file integrity monitoring, comment anti-spam protection, and a built-in firewall with WAF payload rules and login protection.
    6  * Version: 2.1.9
     6 * Version: 2.1.10
    77 * Author: Jaroslav Svetlik
    88 * Author URI: https://vulntitan.com
     
    3030
    3131// Define plugin constants
    32 define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.1.9');
     32define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.1.10');
    3333define('VULNTITAN_PLUGIN_BASENAME', plugin_basename(__FILE__));
    3434define('VULNTITAN_PLUGIN_DIR', untrailingslashit(plugin_dir_path(__FILE__)));
Note: See TracChangeset for help on using the changeset viewer.