PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.5
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.5
3.0.5 3.0.4 3.0.3 3.0.2 3.0.1 3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 All 93 releases
← All changes | admin/class-admin.php +719 -198 3.0.1 → 3.0.5 View file →
@@ -1008,10 +1008,38 @@
1008 1008 return count( $pending_users );
1009 1009 }
1010 1010
1011 1011 /**
1012 + * Whether XML-RPC is blocked, whole or its pingback methods
1013 + *
1014 + * Both levels have to say so: the three-way setting under WP Hardening, read
1015 + * through the module's own resolver, and the WP Hardening module that
1016 + * enforces it. Until 3.0.2 the Dashboard read login_security[disable_xmlrpc],
1017 + * the checkbox that moved to WP Hardening in 2.9.7 and is never written any
1018 + * more, so the score never granted its three points and the recommendation
1019 + * to disable XML-RPC stayed on for good.
1020 + *
1021 + * @since 3.0.2
1022 + *
1023 + * @param array $options Plugin options.
1024 + * @return bool
1025 + */
1026 + private function is_xmlrpc_protected( $options ) {
1027 + return ! empty( $options['modules']['wp_hardening'] )
1028 + && 'none' !== Vigilante_Comment_Security::resolve_xmlrpc_mode( $this->settings );
1029 + }
1030 +
1031 + /**
1012 1032 * Calculate comprehensive security score (0-100)
1013 1033 *
1034 + * The maximum is the same for every site, 129 points. A module that is
1035 + * switched off loses its weight and the points of its details, which stay
1036 + * in the total. Until 3.0.4 the details of a module only counted, on both
1037 + * sides of the fraction, while the module was on: a site with almost
1038 + * everything off kept a middling score, because what it had switched off
1039 + * left the total as well, and switching Security Audit off with no alert
1040 + * configured raised the score.
1041 + *
1014 1042 * @param array $options Plugin options.
1015 1043 * @return int Security score.
1016 1044 */
1017 1045 private function calculate_security_score( $options ) {
@@ -1037,11 +1065,11 @@
1037 1065 }
1038 1066 }
1039 1067
1040 1068 // Firewall details (10 points)
1069 + $max_score += 10;
1041 1070 if ( ! empty( $options['modules']['firewall'] ) ) {
1042 1071 $firewall = $options['firewall'] ?? array();
1043 - $max_score += 10;
1044 1072
1045 1073 $firewall_checks = array(
1046 1074 'block_sql_injection',
1047 1075 'block_xss_attacks',
@@ -1059,18 +1087,18 @@
1059 1087 $score += min( 10, $firewall_enabled * 2 );
1060 1088 }
1061 1089
1062 1090 // Login security details (10 points)
1091 + $max_score += 10;
1063 1092 if ( ! empty( $options['modules']['login_security'] ) ) {
1064 1093 $login = $options['login_security'] ?? array();
1065 - $max_score += 10;
1066 1094
1067 1095 // Max attempts configured
1068 1096 if ( isset( $login['max_attempts'] ) && $login['max_attempts'] <= 5 ) {
1069 1097 $score += 3;
1070 1098 }
1071 - // XML-RPC disabled
1072 - if ( ! empty( $login['disable_xmlrpc'] ) ) {
1099 + // XML-RPC blocked, whole or its pingback methods
1100 + if ( $this->is_xmlrpc_protected( $options ) ) {
1073 1101 $score += 3;
1074 1102 }
1075 1103 // 2FA enabled
1076 1104 if ( ! empty( $login['two_factor']['enabled'] ) ) {
@@ -1078,11 +1106,11 @@
1078 1106 }
1079 1107 }
1080 1108
1081 1109 // Security headers details (10 points)
1110 + $max_score += 10;
1082 1111 if ( ! empty( $options['modules']['security_headers'] ) ) {
1083 1112 $headers = $options['security_headers'] ?? array();
1084 - $max_score += 10;
1085 1113
1086 1114 if ( ! empty( $headers['x_frame_options'] ) ) {
1087 1115 $score += 2;
1088 1116 }
@@ -1097,11 +1125,11 @@
1097 1125 }
1098 1126 }
1099 1127
1100 1128 // User security details (10 points)
1129 + $max_score += 10;
1101 1130 if ( ! empty( $options['modules']['user_security'] ) ) {
1102 1131 $user = $options['user_security'] ?? array();
1103 - $max_score += 10;
1104 1132
1105 1133 if ( ! empty( $user['block_insecure_usernames'] ) ) {
1106 1134 $score += 3;
1107 1135 }
@@ -1115,14 +1143,14 @@
1115 1143 $score += 2;
1116 1144 }
1117 1145 }
1118 1146
1119 - // Audit alerts details (6 points) - only when Security Audit is on,
1120 - // because the alerting layer rides on top of the activity log. Leaving
1121 - // both alert legs off keeps these points unearned.
1147 + // Audit alerts details (6 points) - earned only when Security Audit is
1148 + // on, because the alerting layer rides on top of the activity log.
1149 + // Leaving both alert legs off keeps these points unearned.
1150 + $max_score += 6;
1122 1151 if ( ! empty( $options['modules']['activity_log'] ) ) {
1123 1152 $alerts = isset( $options['audit_alerts'] ) ? (array) $options['audit_alerts'] : array();
1124 - $max_score += 6;
1125 1153 if ( Vigilante_Audit_Alerts::immediate_is_active( $alerts ) ) {
1126 1154 $score += 3;
1127 1155 }
1128 1156 if ( Vigilante_Audit_Alerts::threshold_is_active( $alerts ) ) {
@@ -1143,10 +1171,13 @@
1143 1171 $self_tone = Vigilante_Self_Integrity::tone( Vigilante_Self_Integrity::display_state(), $self_enabled );
1144 1172 if ( $self_enabled ) {
1145 1173 $score += 10;
1146 1174 // The five points are for a check that ran and came out clean: a
1147 - // site that has never checked itself has not earned them.
1148 - if ( in_array( $self_tone, array( 'ok', 'info' ), true ) ) {
1175 + // site that has never checked itself has not earned them. Tasks
1176 + // that Vigilant had to schedule again, while that is only a
1177 + // warning, do not take them away: the files are as clean as they
1178 + // were (Vigilante_Self_Integrity::score_tone()).
1179 + if ( in_array( Vigilante_Self_Integrity::score_tone( Vigilante_Self_Integrity::display_state(), $self_enabled ), array( 'ok', 'info' ), true ) ) {
1149 1180 $score += 5;
1150 1181 }
1151 1182 }
1152 1183
@@ -1175,8 +1206,24 @@
1175 1206 return $percent;
1176 1207 }
1177 1208
1178 1209 /**
1210 + * Recommendation of the Dashboard for a critical self-protection state
1211 + *
1212 + * @param string $kind What the alarm is about, from Vigilante_Self_Integrity::alarm_kind().
1213 + * @return string
1214 + */
1215 + private function self_critical_recommendation( $kind ) {
1216 + if ( 'tasks' === $kind ) {
1217 + return __( 'Something keeps removing Vigilant scheduled tasks: File Integrity says what to look for.', 'vigilante' );
1218 + }
1219 + if ( 'hooks' === $kind ) {
1220 + return __( 'Something removed the hooks of Vigilant self-protection: File Integrity says what to look for.', 'vigilante' );
1221 + }
1222 + return __( 'Vigilant own files have been changed: repair Vigilant from File Integrity before anything else.', 'vigilante' );
1223 + }
1224 +
1225 + /**
1179 1226 * Get security recommendations based on current settings
1180 1227 *
1181 1228 * @param array $options Plugin options.
1182 1229 * @return array Array of recommendations.
@@ -1187,17 +1234,12 @@
1187 1234 // Self-protection first: if Vigilant itself cannot be trusted, nothing
1188 1235 // else on this card means much.
1189 1236 $vg_self_enabled = Vigilante_Self_Integrity::is_on();
1190 1237 $vg_self_tone = Vigilante_Self_Integrity::tone( Vigilante_Self_Integrity::display_state(), $vg_self_enabled );
1191 - if ( ! $vg_self_enabled ) {
1238 + // Switched off has no setting to turn back on: it takes code on the
1239 + // site, and the recommendation says where to look for it.
1240 + if ( 'off' === $vg_self_tone ) {
1192 1241 $recommendations[] = array(
1193 - 'icon' => 'shield',
1194 - 'priority' => 'high',
1195 - 'tab' => 'file-integrity',
1196 - 'message' => __( 'Turn Vigilant self-protection on, so a change to Vigilant own files does not go unnoticed.', 'vigilante' ),
1197 - );
1198 - } elseif ( 'off' === $vg_self_tone ) {
1199 - $recommendations[] = array(
1200 1242 'icon' => 'warning',
1201 1243 'priority' => 'critical',
1202 1244 'tab' => 'file-integrity',
1203 1245 'message' => __( 'Something on this site switched Vigilant self-protection off: File Integrity says which file does it.', 'vigilante' ),
@@ -1206,9 +1248,9 @@
1206 1248 $recommendations[] = array(
1207 1249 'icon' => 'warning',
1208 1250 'priority' => 'critical',
1209 1251 'tab' => 'file-integrity',
1210 - 'message' => __( 'Vigilant own files have been changed: repair Vigilant from File Integrity before anything else.', 'vigilante' ),
1252 + 'message' => $this->self_critical_recommendation( Vigilante_Self_Integrity::alarm_kind( Vigilante_Self_Integrity::display_state(), $vg_self_enabled ) ),
1211 1253 );
1212 1254 } elseif ( 'warning' === $vg_self_tone ) {
1213 1255 $recommendations[] = array(
1214 1256 'icon' => 'shield',
@@ -1310,14 +1352,14 @@
1310 1352 );
1311 1353 }
1312 1354
1313 1355 // Low: XML-RPC enabled (only if login security is active)
1314 - if ( ! empty( $options['modules']['login_security'] ) && empty( $login['disable_xmlrpc'] ) ) {
1356 + if ( ! empty( $options['modules']['login_security'] ) && ! $this->is_xmlrpc_protected( $options ) ) {
1315 1357 $recommendations[] = array(
1316 1358 'icon' => 'info',
1317 1359 'priority' => 'low',
1318 1360 'message' => __( 'Disable XML-RPC if not needed (reduces attack surface).', 'vigilante' ),
1319 - 'tab' => 'login',
1361 + 'tab' => 'wp-hardening',
1320 1362 );
1321 1363 }
1322 1364
1323 1365 // Low: Strong passwords not enforced (only if user security is active)
@@ -1809,9 +1851,8 @@
1809 1851 'scanComplete' => __( 'Scan complete', 'vigilante' ),
1810 1852 'loading' => __( 'Loading...', 'vigilante' ),
1811 1853 'searching' => __( 'Searching...', 'vigilante' ),
1812 1854 'noUsersFound' => __( 'No users found', 'vigilante' ),
1813 - 'searchError' => __( 'Error searching users', 'vigilante' ),
1814 1855 'sending' => __( 'Sending...', 'vigilante' ),
1815 1856 'sendNotification' => __( 'Send notification now', 'vigilante' ),
1816 1857 'notificationsSent' => __( 'notifications sent', 'vigilante' ),
1817 1858 'skipped' => __( 'skipped', 'vigilante' ),
@@ -1820,10 +1861,11 @@
1820 1861 // Header tester strings
1821 1862 'testHeaders' => __( 'Test Headers', 'vigilante' ),
1822 1863 'testing' => __( 'Testing...', 'vigilante' ),
1823 1864 'score' => __( 'Score', 'vigilante' ),
1824 - 'enabledHeaders' => __( 'Enabled headers', 'vigilante' ),
1825 - 'missingHeaders' => __( 'Missing headers', 'vigilante' ),
1865 + 'enabledHeaders' => __( 'Passed', 'vigilante' ),
1866 + 'missingHeaders' => __( 'Failing', 'vigilante' ),
1867 + 'headersMeasured' => __( 'Measured right now on the home page of the site, with the same checks as Security Check.', 'vigilante' ),
1826 1868 'warnings' => __( 'Warnings', 'vigilante' ),
1827 1869 // File integrity scan results strings
1828 1870 'scanResults' => __( 'Scan Results', 'vigilante' ),
1829 1871 'ok' => __( 'OK', 'vigilante' ),
@@ -1830,9 +1872,9 @@
1830 1872 'modified' => __( 'Modified', 'vigilante' ),
1831 1873 'suspicious' => __( 'Suspicious', 'vigilante' ),
1832 1874 'totalScanned' => __( 'Total Scanned', 'vigilante' ),
1833 1875 'suspiciousFiles' => __( 'Suspicious Files', 'vigilante' ),
1834 - 'suspiciousWarning' => __( 'These files may contain malicious code or are in unexpected locations. Review immediately!', 'vigilante' ),
1876 + 'suspiciousWarning' => __( 'Files that can run code where none is expected, or that contain code written the way malware hides itself. Real infections show up here, and so do harmless files that plugins generate. Check the path and the reason before acting.', 'vigilante' ),
1835 1877 'file' => __( 'File', 'vigilante' ),
1836 1878 'reason' => __( 'Reason', 'vigilante' ),
1837 1879 'type' => __( 'Type', 'vigilante' ),
1838 1880 'unknown' => __( 'Unknown', 'vigilante' ),
@@ -1841,13 +1883,13 @@
1841 1883 'logWhatItMeans' => __( 'What it means', 'vigilante' ),
1842 1884 'logWhatToDo' => __( 'What to do', 'vigilante' ),
1843 1885 'logSelfSeeDetails' => __( 'Open File Integrity for the full detail', 'vigilante' ),
1844 1886 'modifiedFiles' => __( 'Modified Files', 'vigilante' ),
1845 - 'modifiedDescription' => __( 'These files (apparently) differ from the original WordPress or plugin versions.', 'vigilante' ),
1887 + 'modifiedDescription' => __( 'These files do not match the copy WordPress.org publishes for the installed version.', 'vigilante' ),
1846 1888 'extraFiles' => __( 'Extra Files', 'vigilante' ),
1847 1889 'extra' => __( 'Extra', 'vigilante' ),
1848 1890 'ignored' => __( 'Ignored', 'vigilante' ),
1849 - 'extraDescription' => __( 'PHP files found in plugins or themes that are not part of the original distribution from WordPress.org.', 'vigilante' ),
1891 + 'extraDescription' => __( 'Files that are not part of WordPress, or of the plugin or theme they sit in. Many are legitimate, and an injected file looks the same from here, so confirm where each one comes from.', 'vigilante' ),
1850 1892 'actions' => __( 'Actions', 'vigilante' ),
1851 1893 'ignore' => __( 'Ignore', 'vigilante' ),
1852 1894 'ignoring' => __( 'Ignoring...', 'vigilante' ),
1853 1895 'fileIgnored' => __( 'File added to ignored list.', 'vigilante' ),
@@ -1855,9 +1897,8 @@
1855 1897 'confirmClearIgnored' => __( 'Remove all files from the ignored list? They will appear in scan results again.', 'vigilante' ),
1856 1898 'ignoredCleared' => __( 'Ignored files list cleared. Page will reload...', 'vigilante' ),
1857 1899 'selectAll' => __( 'Select all', 'vigilante' ),
1858 1900 'bulkIgnoreSelected' => __( 'Ignore selected', 'vigilante' ),
1859 - 'bulkUnignoreSelected'=> __( 'Stop ignoring selected', 'vigilante' ),
1860 1901 'bulkNoSelection' => __( 'Select at least one file first.', 'vigilante' ),
1861 1902 'bulkConfirmIgnore' => __( 'Ignore the selected files? They will be hidden from future scan results until you remove them from the ignored list.', 'vigilante' ),
1862 1903 'bulkConfirmUnignore' => __( 'Remove the selected files from the ignored list? They will appear in scan results again.', 'vigilante' ),
1863 1904 'bulkProcessing' => __( 'Processing...', 'vigilante' ),
@@ -1862,11 +1903,11 @@
1862 1903 'bulkConfirmUnignore' => __( 'Remove the selected files from the ignored list? They will appear in scan results again.', 'vigilante' ),
1863 1904 'bulkProcessing' => __( 'Processing...', 'vigilante' ),
1864 1905 /* translators: %d: number of files selected for bulk action. */
1865 1906 'bulkSelectedCount' => __( '%d selected', 'vigilante' ),
1866 - 'allClear' => __( 'All files verified - no issues found!', 'vigilante' ),
1907 + 'allClear' => __( 'Good job! Nothing to report in the files that were checked.', 'vigilante' ),
1867 1908 'criticalConfigTitle' => __( 'Critical config files modified', 'vigilante' ),
1868 - 'criticalConfigDesc' => __( 'These files are common targets for code injection. Review the changes and approve if they are legitimate. Vigilant\'s own blocks are excluded from this check.', 'vigilante' ),
1909 + 'criticalConfigDesc' => __( 'These files changed since you last approved them. They are a common target for code injection, and they are also rewritten by cache, security and redirection plugins, often on every update. Review the changes and approve them if they belong to something you use: approving records the current content as the reference and does not touch the file. Vigilant\'s own blocks are excluded from this check, and Vigilant never restores these files by itself.', 'vigilante' ),
1869 1910 'approve' => __( 'Approve', 'vigilante' ),
1870 1911 'approving' => __( 'Approving...', 'vigilante' ),
1871 1912 'approvalLockedNotice' => $this->critical_approval_notice(),
1872 1913 'criticalApproved' => __( 'Change approved. Next scan will use the current state as baseline.', 'vigilante' ),
@@ -1959,13 +2000,10 @@
1959 2000 'noLogsToExport' => __( 'No logs to export', 'vigilante' ),
1960 2001 'exportFailed' => __( 'Export failed', 'vigilante' ),
1961 2002 'exportLogs' => __( 'Export Logs', 'vigilante' ),
1962 2003 // Backup strings
1963 - 'backupCreated' => __( 'Backup created successfully.', 'vigilante' ),
1964 2004 'createBackupNow' => __( 'Download Backup', 'vigilante' ),
1965 2005 'downloadBackup' => __( 'Download Backup (.zip)', 'vigilante' ),
1966 - /* translators: %d: number of tables */
1967 - 'tablesCount' => __( '%d tables', 'vigilante' ),
1968 2006 /* translators: 1: table count, 2: human-readable size */
1969 2007 'dbTablesTotal' => __( '%1$d tables total (%2$s)', 'vigilante' ),
1970 2008 /* translators: 1: selected count, 2: human-readable size */
1971 2009 'dbTablesSelected' => __( '%1$d tables selected (%2$s)', 'vigilante' ),
@@ -1972,15 +2010,12 @@
1972 2010 // Settings search strings
1973 2011 'searchNoResults' => __( 'No matching settings found.', 'vigilante' ),
1974 2012 /* translators: %d: number of results that did not fit in the list. */
1975 2013 'searchMoreResults' => __( '%d more results. Refine the search to see them.', 'vigilante' ),
1976 - 'searchInTab' => __( 'in', 'vigilante' ),
1977 - // Modules string
1978 - /* translators: 1: enabled count, 2: total count */
1979 - 'modulesEnabled' => __( '%1$d / %2$d modules enabled', 'vigilante' ),
1980 2014 // Activity log label maps for JS rendering
1981 2015 'eventTypeLabels' => array(
1982 2016 'login' => __( 'Login', 'vigilante' ),
2017 + '2fa' => __( 'Two-factor', 'vigilante' ),
1983 2018 'user' => __( 'User', 'vigilante' ),
1984 2019 'content' => __( 'Content', 'vigilante' ),
1985 2020 'plugin' => __( 'Plugin', 'vigilante' ),
1986 2021 'theme' => __( 'Theme', 'vigilante' ),
@@ -2046,10 +2081,8 @@
2046 2081 /* translators: 1: first item number, 2: last item number, 3: total items */
2047 2082 'paginationOf' => __( '%1$d–%2$d of %3$d', 'vigilante' ),
2048 2083 'paginationEmpty' => __( '0 items', 'vigilante' ),
2049 2084 // Security Analyzer strings
2050 - 'analyzerScanNow' => __( 'Scan now', 'vigilante' ),
2051 - 'analyzerScanning' => __( 'Scanning…', 'vigilante' ),
2052 2085 'analyzerFastPhase' => __( 'Running fast checks…', 'vigilante' ),
2053 2086 'analyzerSlowPhase' => __( 'Running remote checks…', 'vigilante' ),
2054 2087 'analyzerScanComplete' => __( 'Security scan complete.', 'vigilante' ),
2055 2088 'analyzerScanFailed' => __( 'Security scan failed.', 'vigilante' ),
@@ -2055,11 +2088,8 @@
2055 2088 'analyzerScanFailed' => __( 'Security scan failed.', 'vigilante' ),
2056 2089 'analyzerShowDetails' => __( 'Show detailed breakdown', 'vigilante' ),
2057 2090 'analyzerHideDetails' => __( 'Hide detailed breakdown', 'vigilante' ),
2058 2091 'analyzerGoToSetting' => __( 'Go to setting', 'vigilante' ),
2059 - 'analyzerNoData' => __( 'No data yet — run a scan to populate this category.', 'vigilante' ),
2060 - 'analyzerJustNow' => __( 'just now', 'vigilante' ),
2061 - 'analyzerAgo' => __( 'ago', 'vigilante' ),
2062 2092 'analyzerSettingsSaved' => __( 'Analyzer settings saved.', 'vigilante' ),
2063 2093 'analyzerLastScanJustNow' => __( 'Last scan just now', 'vigilante' ),
2064 2094 'analyzerQualityExcellent' => __( 'Excellent', 'vigilante' ),
2065 2095 'analyzerQualityGood' => __( 'Good', 'vigilante' ),
@@ -2065,9 +2095,9 @@
2065 2095 'analyzerQualityGood' => __( 'Good', 'vigilante' ),
2066 2096 'analyzerQualityFair' => __( 'Fair', 'vigilante' ),
2067 2097 'analyzerQualityPoor' => __( 'Poor', 'vigilante' ),
2068 2098 'analyzerQualityCritical' => __( 'Critical', 'vigilante' ),
2069 - 'analyzerPts' => __( 'pts', 'vigilante' ),
2099 + 'analyzerNotMeasured' => __( 'Not measured', 'vigilante' ),
2070 2100 'analyzerLearnMore' => __( 'Learn more', 'vigilante' ),
2071 2101 'analyzerInfoAllClear' => __( 'All clear', 'vigilante' ),
2072 2102 /* translators: %d: number of findings in an info-only category */
2073 2103 'analyzerInfoFindings' => __( '%d findings', 'vigilante' ),
@@ -2129,9 +2159,11 @@
2129 2159 <p class="vigilante-self-notice-title">
2130 2160 <span class="dashicons dashicons-shield" aria-hidden="true"></span>
2131 2161 <strong>
2132 2162 <?php
2133 - if ( 'critical' === $tone ) {
2163 + if ( 'critical' === $tone && in_array( $summary['kind'], array( 'tasks', 'hooks' ), true ) ) {
2164 + echo esc_html( $this->self_integrity_headline( $tone, $summary['state'] ) );
2165 + } elseif ( 'critical' === $tone ) {
2134 2166 esc_html_e( 'Vigilant detected changes in its own files', 'vigilante' );
2135 2167 } elseif ( 'off' === $tone ) {
2136 2168 esc_html_e( 'Vigilant self-protection is switched off by code', 'vigilante' );
2137 2169 } else {
@@ -2142,9 +2174,13 @@
2142 2174 </p>
2143 2175 <p class="vigilante-self-notice-text">
2144 2176 <span>
2145 2177 <?php
2146 - if ( 'critical' === $tone ) {
2178 + if ( 'critical' === $tone && ! empty( $summary['tasks'] ) ) {
2179 + esc_html_e( 'Vigilant scheduled them again each time, but something keeps removing them. File Integrity says what to look for.', 'vigilante' );
2180 + } elseif ( 'critical' === $tone && 'hooks' === $summary['kind'] ) {
2181 + esc_html_e( 'Code on this site removed hooks that run the self-check of Vigilant. File Integrity says which ones and what to look for.', 'vigilante' );
2182 + } elseif ( 'critical' === $tone ) {
2147 2183 esc_html_e( 'Your security plugin may have been tampered with, and while that is true nothing it reports can be trusted.', 'vigilante' );
2148 2184 } elseif ( 'off' === $tone ) {
2149 2185 esc_html_e( 'Nothing is checking that Vigilant own files are intact. File Integrity says which file switches it off.', 'vigilante' );
2150 2186 } else {
@@ -2149,9 +2185,11 @@
2149 2185 esc_html_e( 'Nothing is checking that Vigilant own files are intact. File Integrity says which file switches it off.', 'vigilante' );
2150 2186 } else {
2151 2187 esc_html_e( 'File Integrity says what was found, what it means and what to do about it.', 'vigilante' );
2152 2188 }
2153 - if ( $network ) {
2189 + // Repairing is for a case about the files: tasks, removed
2190 + // hooks and a filter are not repaired with a clean copy.
2191 + if ( $network && ! empty( $summary['about_files'] ) ) {
2154 2192 echo ' ' . esc_html__( 'Only your network administrator can repair Vigilant, because its files are shared by every site in the network. Let them know.', 'vigilante' );
2155 2193 }
2156 2194 ?>
2157 2195 </span>
@@ -2653,10 +2691,10 @@
2653 2691
2654 2692 /**
2655 2693 * Render the Security Analyzer (Security Check) widget + expandable full report.
2656 2694 *
2657 - * Lives in the Dashboard tab between the Configuration Score status card and
2658 - * the modules grid. Uses the last persisted scan to hydrate server-side so the
2695 + * Lives in the Dashboard tab above the configuration card and the modules
2696 + * grid: it carries the only letter of the page. Uses the last persisted scan to hydrate server-side so the
2659 2697 * first paint shows real data; "Scan now" runs the 2-phase AJAX to refresh.
2660 2698 *
2661 2699 * @param array $last_scan Result of Vigilante_Security_Analyzer::get_last_scan().
2662 2700 * @param array $history Score history (oldest first).
@@ -2687,14 +2725,34 @@
2687 2725 */
2688 2726 $self_summary = $this->self_integrity_summary();
2689 2727 $self_capped = in_array( $self_summary['tone'], array( 'critical', 'off' ), true )
2690 2728 || ( 'self_integrity' === ( isset( $last_scan['capped_by'] ) ? $last_scan['capped_by'] : '' ) );
2729 + // Why the note under the title says the score is capped: something
2730 + // that is true now, about tasks or about files, or only what the
2731 + // stored report found when it ran. In that last case the note does not
2732 + // say that anything is wrong now: a report stored before the notice
2733 + // about tasks closed, or before a repair, kept saying in the present
2734 + // that the files had been changed until the next scan.
2735 + $self_capped_now = in_array( $self_summary['tone'], array( 'critical', 'off' ), true );
2736 + $self_capped_why = 'stored';
2737 + if ( $self_capped_now ) {
2738 + $self_capped_why = 'files';
2739 + if ( 'tasks' === $self_summary['kind'] ) {
2740 + $self_capped_why = 'tasks';
2741 + } elseif ( in_array( $self_summary['kind'], array( 'off', 'hooks' ), true ) ) {
2742 + // The check itself is switched off or unhooked: no file was found changed.
2743 + $self_capped_why = 'check';
2744 + }
2745 + }
2691 2746 if ( $self_capped && $has_data && $score > Vigilante_Security_Analyzer::SCORE_CAP_ON_TAMPER ) {
2692 2747 $score = Vigilante_Security_Analyzer::SCORE_CAP_ON_TAMPER;
2693 - $grade = 'E';
2694 2748 }
2749 + if ( $has_data && '' !== $grade ) {
2750 + $grade = Vigilante_SA_Helpers::grade_for_score( $score );
2751 + }
2695 2752
2696 - $quality = self::analyzer_quality_tag( $score );
2753 + $quality = self::analyzer_quality_tag( $score );
2754 + $not_measured = isset( $counts['skip'] ) ? (int) $counts['skip'] : 0;
2697 2755 ?>
2698 2756 <div class="vigilante-analyzer" id="vigilante-analyzer"
2699 2757 data-has-data="<?php echo $has_data ? '1' : '0'; ?>">
2700 2758 <div class="vigilante-analyzer-header">
@@ -2703,9 +2761,9 @@
2703 2761 <span class="dashicons dashicons-shield-alt" aria-hidden="true"></span>
2704 2762 <?php esc_html_e( 'Security Check', 'vigilante' ); ?>
2705 2763 </h2>
2706 2764 <p class="description">
2707 - <?php esc_html_e( 'On-demand audit of what an attacker would see right now, plus 13 internal checks impossible from the outside.', 'vigilante' ); ?>
2765 + <?php esc_html_e( 'On-demand audit of what an attacker would see right now, plus internal checks impossible from the outside.', 'vigilante' ); ?>
2708 2766 </p>
2709 2767 <p class="vigilante-analyzer-last-scan" data-role="ran-at">
2710 2768 <span class="dashicons dashicons-clock" aria-hidden="true"></span>
2711 2769 <?php echo esc_html( $ran_at_human ); ?>
@@ -2719,12 +2777,22 @@
2719 2777 </div>
2720 2778 </div>
2721 2779
2722 2780 <?php if ( $self_capped ) : ?>
2723 - <p class="vigilante-analyzer-capped">
2781 + <p class="vigilante-analyzer-capped" data-why="<?php echo esc_attr( $self_capped_why ); ?>">
2724 2782 <span class="dashicons dashicons-shield" aria-hidden="true"></span>
2725 - <strong><?php esc_html_e( 'This score is not reliable right now.', 'vigilante' ); ?></strong>
2726 - <?php esc_html_e( 'Vigilant own files have been changed, and every other result on this page is produced by that same code. The score is held at the bottom of the scale until the files verify clean again.', 'vigilante' ); ?>
2783 + <strong><?php 'stored' === $self_capped_why ? esc_html_e( 'This score is from an earlier scan.', 'vigilante' ) : esc_html_e( 'This score is not reliable right now.', 'vigilante' ); ?></strong>
2784 + <?php
2785 + if ( 'stored' === $self_capped_why ) {
2786 + esc_html_e( 'When this scan ran, Vigilant self-protection was reporting a critical finding, so the score was held at the bottom of the scale. Run a new scan to bring it up to date.', 'vigilante' );
2787 + } elseif ( 'tasks' === $self_capped_why ) {
2788 + esc_html_e( 'Something keeps removing Vigilant scheduled tasks, so its scans and its daily check do not run when they should. The score is held at the bottom of the scale while that goes on.', 'vigilante' );
2789 + } elseif ( 'check' === $self_capped_why ) {
2790 + esc_html_e( 'Code on this site is keeping Vigilant self-protection from running as it should, so the code that produces every other result on this page is not being checked as it should. The score is held at the bottom of the scale while that goes on.', 'vigilante' );
2791 + } else {
2792 + esc_html_e( 'Vigilant own files have been changed, and every other result on this page is produced by that same code. The score is held at the bottom of the scale until the files verify clean again.', 'vigilante' );
2793 + }
2794 + ?>
2727 2795 <a href="<?php echo esc_url( admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' ) ); ?>">
2728 2796 <?php esc_html_e( 'See what to do', 'vigilante' ); ?>
2729 2797 </a>
2730 2798 </p>
@@ -2769,8 +2837,20 @@
2769 2837 <span class="dashicons dashicons-dismiss" aria-hidden="true"></span>
2770 2838 <strong data-role="fail"><?php echo esc_html( isset( $counts['fail'] ) ? $counts['fail'] : 0 ); ?></strong>
2771 2839 <span class="vigilante-analyzer-count-label"><?php esc_html_e( 'Failing', 'vigilante' ); ?></span>
2772 2840 </span>
2841 + <?php
2842 + // What could not be measured is left out of the score, so
2843 + // it is said here: a score over fewer checks is not the
2844 + // same score.
2845 + ?>
2846 + <span class="vigilante-analyzer-count vigilante-analyzer-count--skip" data-role="skip-wrap"
2847 + title="<?php esc_attr_e( 'Checks that could not be run this time. They are not part of the score.', 'vigilante' ); ?>"
2848 + <?php echo $not_measured > 0 ? '' : 'hidden'; ?>>
2849 + <span class="dashicons dashicons-minus" aria-hidden="true"></span>
2850 + <strong data-role="skip"><?php echo esc_html( $not_measured ); ?></strong>
2851 + <span class="vigilante-analyzer-count-label"><?php esc_html_e( 'Not measured', 'vigilante' ); ?></span>
2852 + </span>
2773 2853 </div>
2774 2854
2775 2855 <?php
2776 2856 // Sparkline of recent scores. Require at least 3 data points so the trend
@@ -2848,18 +2928,29 @@
2848 2928 <div class="vigilante-analyzer-categories" data-role="categories">
2849 2929 <?php foreach ( $categories_def as $slug => $meta ) :
2850 2930 $cat = isset( $categories[ $slug ] ) ? $categories[ $slug ] : array();
2851 2931 $earned = isset( $cat['earned'] ) ? (int) $cat['earned'] : 0;
2852 - // Always use the declared meta as the source of truth for the maximum.
2853 - // The cached scan may carry an old max if a check was added/removed
2854 - // between releases (see 2.6.1: closed_plugins raised internal from 22 to 28
2855 - // but cached scans still reported max=22 until reset).
2856 - $cat_max = (int) $meta['max'];
2932 + $checks = isset( $cat['checks'] ) ? (array) $cat['checks'] : array();
2857 2933 $info_only = ! empty( $meta['info_only'] ) || 0 === (int) $meta['max'];
2934 + // The maximum of a category is what its checks could
2935 + // score this time: one that could not be measured, or
2936 + // that is only informative, is not in it. The report
2937 + // adds it up from the checks themselves
2938 + // (Vigilante_Security_Analyzer::summarize_categories()),
2939 + // so it cannot drift from them. Before the first scan
2940 + // there are no checks and the declared figure is shown.
2941 + $cat_max = ( ! empty( $checks ) && isset( $cat['max'] ) ) ? (int) $cat['max'] : (int) $meta['max'];
2858 2942 $cat_pct = $cat_max > 0 ? (int) round( ( $earned / $cat_max ) * 100 ) : 0;
2859 - $checks = isset( $cat['checks'] ) ? (array) $cat['checks'] : array();
2860 2943 $cat_counts = isset( $cat['counts'] ) && is_array( $cat['counts'] ) ? $cat['counts'] : array( 'pass' => 0, 'warn' => 0, 'fail' => 0, 'info' => 0 );
2861 2944 $cat_quality = self::analyzer_quality_tag( $cat_pct );
2945 + // Nothing in the category could be scored: that is not
2946 + // a zero, and it does not get the red label of one.
2947 + if ( ! $info_only && ! empty( $checks ) && 0 === $cat_max ) {
2948 + $cat_quality = array(
2949 + 'label' => __( 'Not measured', 'vigilante' ),
2950 + 'slug' => 'info',
2951 + );
2952 + }
2862 2953 $info_count = isset( $cat_counts['info'] ) ? (int) $cat_counts['info'] : 0;
2863 2954 ?>
2864 2955 <details class="vigilante-analyzer-category<?php echo $info_only ? ' vigilante-analyzer-category--info' : ''; ?>"
2865 2956 data-category="<?php echo esc_attr( $slug ); ?>"
@@ -2908,9 +2999,9 @@
2908 2999 </span>
2909 3000 <?php endif; ?>
2910 3001 <?php if ( ! $info_only ) : ?>
2911 3002 <span class="vigilante-analyzer-category-score">
2912 - <span data-role="earned"><?php echo esc_html( $earned ); ?></span><span class="vigilante-analyzer-category-score-sep">/</span><?php echo esc_html( $cat_max ); ?>
3003 + <span data-role="earned"><?php echo esc_html( $earned ); ?></span><span class="vigilante-analyzer-category-score-sep">/</span><span data-role="max"><?php echo esc_html( $cat_max ); ?></span>
2913 3004 <span class="vigilante-analyzer-category-score-unit"><?php esc_html_e( 'pts', 'vigilante' ); ?></span>
2914 3005 </span>
2915 3006 <span class="vigilante-analyzer-category-bar" aria-hidden="true">
2916 3007 <span class="vigilante-analyzer-category-bar-fill vigilante-analyzer-category-bar-fill--<?php echo esc_attr( $cat_quality['slug'] ); ?>"
@@ -2987,23 +3078,27 @@
2987 3078 * @param int $pct 0..100.
2988 3079 * @return array{label:string,slug:string}
2989 3080 */
2990 3081 public static function analyzer_quality_tag( $pct ) {
2991 - $pct = max( 0, min( 100, (int) $pct ) );
2992 - // "Excellent" is reserved for a perfect score — a single missing point drops to Good.
2993 - if ( 100 === $pct ) {
2994 - return array( 'label' => __( 'Excellent', 'vigilante' ), 'slug' => 'a' );
3082 + if ( ! class_exists( 'Vigilante_SA_Helpers' ) ) {
3083 + require_once VIGILANTE_INCLUDES_DIR . 'security-analyzer/class-sa-helpers.php';
2995 3084 }
2996 - if ( $pct >= 70 ) {
2997 - return array( 'label' => __( 'Good', 'vigilante' ), 'slug' => 'b' );
2998 - }
2999 - if ( $pct >= 50 ) {
3000 - return array( 'label' => __( 'Fair', 'vigilante' ), 'slug' => 'c' );
3001 - }
3002 - if ( $pct >= 30 ) {
3003 - return array( 'label' => __( 'Poor', 'vigilante' ), 'slug' => 'd' );
3004 - }
3005 - return array( 'label' => __( 'Critical', 'vigilante' ), 'slug' => 'e' );
3085 +
3086 + // The word follows the letter, and the letter has one scale:
3087 + // Vigilante_SA_Helpers::grade_for_score().
3088 + $labels = array(
3089 + 'A' => __( 'Excellent', 'vigilante' ),
3090 + 'B' => __( 'Good', 'vigilante' ),
3091 + 'C' => __( 'Fair', 'vigilante' ),
3092 + 'D' => __( 'Poor', 'vigilante' ),
3093 + 'E' => __( 'Critical', 'vigilante' ),
3094 + );
3095 + $grade = Vigilante_SA_Helpers::grade_for_score( $pct );
3096 +
3097 + return array(
3098 + 'label' => $labels[ $grade ],
3099 + 'slug' => strtolower( $grade ),
3100 + );
3006 3101 }
3007 3102
3008 3103 /**
3009 3104 * Render a single analyzer check row (used both server-side and via JS template).
@@ -3105,10 +3200,93 @@
3105 3200 /**
3106 3201 * Render dashboard tab
3107 3202 */
3108 3203 /**
3204 + * The Vigilant configuration card of the Dashboard: how much is switched on
3205 + *
3206 + * A bar with a percentage and, on purpose, neither a circle nor a letter.
3207 + * Until 3.0.4 this was the Configuration Score, drawn exactly like the
3208 + * score of Security Check next to it, and two letters side by side read
3209 + * as two grades of the same site that did not agree. The grade is the one
3210 + * of Security Check, which measures the site; this only counts settings,
3211 + * so it sits with the recommendations and the module switches it counts.
3212 + * The figure is still the one of calculate_security_score().
3213 + *
3214 + * @since 3.0.5
3215 + *
3216 + * @param int $security_score What calculate_security_score() gives, 0 to 100.
3217 + * @param array $options The settings of the plugin.
3218 + * @param string|bool $active_preset Slug of the preset in force, if any.
3219 + * @param array $presets The presets, by slug.
3220 + */
3221 + private function render_configuration_card( $security_score, $options, $active_preset, $presets ) {
3222 + $modules = ( isset( $options['modules'] ) && is_array( $options['modules'] ) ) ? $options['modules'] : array();
3223 + $modules_on = count( array_filter( $modules ) );
3224 + $level = max( 0, min( 100, (int) $security_score ) );
3225 + ?>
3226 + <div class="vigilante-status-card vigilante-configuration-card">
3227 + <h2><?php esc_html_e( 'Vigilant configuration', 'vigilante' ); ?></h2>
3228 + <p class="vigilante-score-kind description">
3229 + <?php esc_html_e( 'How much of what Vigilant offers is switched on here.', 'vigilante' ); ?>
3230 + </p>
3231 + <div class="vigilante-configuration-level">
3232 + <div class="vigilante-configuration-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100"
3233 + aria-valuenow="<?php echo esc_attr( $level ); ?>"
3234 + aria-label="<?php esc_attr_e( 'Vigilant configuration', 'vigilante' ); ?>">
3235 + <span class="vigilante-configuration-bar-fill" style="width: <?php echo esc_attr( $level ); ?>%;"></span>
3236 + </div>
3237 + <strong class="vigilante-configuration-percent"><?php echo esc_html( $level ); ?>%</strong>
3238 + <div class="vigilante-config-status">
3239 + <?php if ( $active_preset && isset( $presets[ $active_preset ] ) ) : ?>
3240 + <span class="vigilante-preset-badge vigilante-preset-<?php echo esc_attr( $active_preset ); ?>">
3241 + <?php echo esc_html( $presets[ $active_preset ]['name'] ); ?>
3242 + </span>
3243 + <?php else : ?>
3244 + <span class="vigilante-preset-badge vigilante-preset-custom">
3245 + <?php esc_html_e( 'Custom Configuration', 'vigilante' ); ?>
3246 + </span>
3247 + <?php endif; ?>
3248 + </div>
3249 + </div>
3250 + <p class="vigilante-configuration-modules description">
3251 + <?php
3252 + echo esc_html(
3253 + sprintf(
3254 + /* translators: 1: number of modules switched on, 2: number of modules */
3255 + __( '%1$d of %2$d modules switched on.', 'vigilante' ),
3256 + $modules_on,
3257 + count( $modules )
3258 + )
3259 + );
3260 + ?>
3261 + </p>
3262 +
3263 + <?php
3264 + $recommendations = $this->get_security_recommendations( $options );
3265 + if ( ! empty( $recommendations ) ) :
3266 + ?>
3267 + <div class="vigilante-recommendations">
3268 + <h4><?php esc_html_e( 'Recommendations', 'vigilante' ); ?></h4>
3269 + <ul class="vigilante-recommendations-grid">
3270 + <?php foreach ( $recommendations as $rec ) : ?>
3271 + <li>
3272 + <span class="dashicons dashicons-<?php echo esc_attr( $rec['icon'] ); ?> vigilante-priority-<?php echo esc_attr( $rec['priority'] ); ?>"></span>
3273 + <?php echo esc_html( $rec['message'] ); ?>
3274 + <?php if ( ! empty( $rec['tab'] ) ) : ?>
3275 + <a href="<?php echo esc_url( admin_url( 'admin.php?page=vigilante&tab=' . $rec['tab'] ) ); ?>" class="vigilante-rec-link" title="<?php esc_attr_e( 'Go to settings', 'vigilante' ); ?>"><span class="dashicons dashicons-arrow-right-alt2"></span></a>
3276 + <?php endif; ?>
3277 + </li>
3278 + <?php endforeach; ?>
3279 + </ul>
3280 + </div>
3281 + <?php endif; ?>
3282 + </div>
3283 + <?php
3284 + }
3285 +
3286 + /**
3109 3287 * One line strip at the top of the Dashboard tab: the state of Vigilant own
3110 - * files, above the Configuration Score and the Security Check, because
3288 + * files, above the Security Check and the configuration card, because
3111 3289 * nothing else on this screen means much while it is red.
3112 3290 */
3113 3291 private function render_self_protection_strip() {
3114 3292 $summary = $this->self_integrity_summary();
@@ -3176,67 +3354,11 @@
3176 3354 $analyzer_settings = isset( $options['security_analyzer'] ) ? $options['security_analyzer'] : array();
3177 3355 ?>
3178 3356 <div class="vigilante-dashboard">
3179 3357 <?php $this->render_self_protection_strip(); ?>
3180 - <div class="vigilante-status-card">
3181 - <h2><?php esc_html_e( 'Configuration Score', 'vigilante' ); ?></h2>
3182 - <p class="vigilante-score-kind description">
3183 - <?php esc_html_e( 'How well Vigilante is configured right now. Pair it with the Security Check below to see the real-world result.', 'vigilante' ); ?>
3184 - </p>
3185 - <div class="vigilante-security-score">
3186 - <?php
3187 - // Grade thresholds: A (90+), B (70-89), C (50-69), D (30-49), E (0-29)
3188 - if ( $security_score >= 90 ) {
3189 - $grade = 'A';
3190 - } elseif ( $security_score >= 70 ) {
3191 - $grade = 'B';
3192 - } elseif ( $security_score >= 50 ) {
3193 - $grade = 'C';
3194 - } elseif ( $security_score >= 30 ) {
3195 - $grade = 'D';
3196 - } else {
3197 - $grade = 'E';
3198 - }
3199 - ?>
3200 - <div class="vigilante-score-circle vigilante-grade-<?php echo esc_attr( strtolower( $grade ) ); ?>">
3201 - <span class="vigilante-grade"><?php echo esc_html( $grade ); ?></span>
3202 - <span class="vigilante-score-text"><?php echo esc_html( $security_score ); ?>%</span>
3203 - </div>
3204 - <div class="vigilante-config-status">
3205 - <?php if ( $active_preset && isset( $presets[ $active_preset ] ) ) : ?>
3206 - <span class="vigilante-preset-badge vigilante-preset-<?php echo esc_attr( $active_preset ); ?>">
3207 - <?php echo esc_html( $presets[ $active_preset ]['name'] ); ?>
3208 - </span>
3209 - <?php else : ?>
3210 - <span class="vigilante-preset-badge vigilante-preset-custom">
3211 - <?php esc_html_e( 'Custom Configuration', 'vigilante' ); ?>
3212 - </span>
3213 - <?php endif; ?>
3214 - </div>
3215 - </div>
3216 -
3217 - <?php
3218 - $recommendations = $this->get_security_recommendations( $options );
3219 - if ( ! empty( $recommendations ) ) :
3220 - ?>
3221 - <div class="vigilante-recommendations">
3222 - <h4><?php esc_html_e( 'Recommendations', 'vigilante' ); ?></h4>
3223 - <ul class="vigilante-recommendations-grid">
3224 - <?php foreach ( $recommendations as $rec ) : ?>
3225 - <li>
3226 - <span class="dashicons dashicons-<?php echo esc_attr( $rec['icon'] ); ?> vigilante-priority-<?php echo esc_attr( $rec['priority'] ); ?>"></span>
3227 - <?php echo esc_html( $rec['message'] ); ?>
3228 - <?php if ( ! empty( $rec['tab'] ) ) : ?>
3229 - <a href="<?php echo esc_url( admin_url( 'admin.php?page=vigilante&tab=' . $rec['tab'] ) ); ?>" class="vigilante-rec-link" title="<?php esc_attr_e( 'Go to settings', 'vigilante' ); ?>"><span class="dashicons dashicons-arrow-right-alt2"></span></a>
3230 - <?php endif; ?>
3231 - </li>
3232 - <?php endforeach; ?>
3233 - </ul>
3234 - </div>
3235 - <?php endif; ?>
3236 - </div>
3358 + <?php $this->render_analyzer_widget( $analyzer_last_scan, $analyzer_history, $analyzer_categories_def, $analyzer_settings ); ?>
3237 3359
3238 - <?php $this->render_analyzer_widget( $analyzer_last_scan, $analyzer_history, $analyzer_categories_def, $analyzer_settings ); ?>
3360 + <?php $this->render_configuration_card( $security_score, $options, $active_preset, $presets ); ?>
3239 3361
3240 3362 <div class="vigilante-modules-grid">
3241 3363 <h2 id="vigilante-section-dashboard-modules"><?php esc_html_e( 'Security Modules', 'vigilante' ); ?></h2>
3242 3364 <p class="description"><?php esc_html_e( 'Enable or disable security modules. Each module controls a tab with detailed settings.', 'vigilante' ); ?></p>
@@ -3721,8 +3843,9 @@
3721 3843 <label>
3722 3844 <input type="checkbox" name="firewall[block_bad_bots]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( ! empty( $options['block_bad_bots'] ) ); ?>>
3723 3845 <?php esc_html_e( 'Block known malicious bots and scanners', 'vigilante' ); ?>
3724 3846 </label>
3847 + <p class="description"><?php esc_html_e( 'The list also stops SEO crawlers such as Ahrefs, Semrush, Majestic and Screaming Frog, and command line clients such as curl, wget and python-requests. To let one of them through, add its name to the User-Agent Whitelist below. On Apache part of the list is applied in .htaccess, before WordPress loads, so those requests get a 403 and do not appear in the Security Audit.', 'vigilante' ); ?></p>
3725 3848 </td>
3726 3849 </tr>
3727 3850 </table>
3728 3851
@@ -4035,9 +4158,9 @@
4035 4158 <th scope="row"><?php esc_html_e( 'Limit HTTP Methods', 'vigilante' ); ?></th>
4036 4159 <td>
4037 4160 <label>
4038 4161 <input type="checkbox" name="firewall[limit_http_methods]" value="1" <?php checked( ! empty( $options['limit_http_methods'] ) ); ?>>
4039 - <?php esc_html_e( 'Allow only GET, POST, HEAD (blocks PUT, DELETE, TRACE, etc.)', 'vigilante' ); ?>
4162 + <?php esc_html_e( 'Block the HTTP methods WordPress does not use (TRACE, TRACK, CONNECT and any unknown one). GET, POST, HEAD, OPTIONS, PUT, PATCH and DELETE keep working.', 'vigilante' ); ?>
4040 4163 </label>
4041 4164 </td>
4042 4165 </tr>
4043 4166 </table>
@@ -4773,8 +4896,9 @@
4773 4896 <label>
4774 4897 <input type="checkbox" name="security_headers[csp][enabled]" value="1" <?php checked( ! empty( $options['csp']['enabled'] ) ); ?>>
4775 4898 <?php esc_html_e( 'Enable Content Security Policy', 'vigilante' ); ?>
4776 4899 </label>
4900 + <p class="description"><?php esc_html_e( 'The default policy includes upgrade-insecure-requests, which asks browsers to request every http:// resource over https. Vigilant writes it only when the site address starts with https://, because on a site served over http it would stop every stylesheet, script and image from loading. If your site is served over https through a proxy or CDN that terminates TLS, set the site address to https:// in Settings > General; the rules follow the next time this tab is saved or Vigilant is updated.', 'vigilante' ); ?></p>
4777 4901 </td>
4778 4902 </tr>
4779 4903 <tr>
4780 4904 <th scope="row"><?php esc_html_e( 'Report Only Mode', 'vigilante' ); ?></th>
@@ -6317,9 +6441,9 @@
6317 6441 <select id="vigilante-f-audit-alerts-immediate-min-severity" name="audit_alerts[immediate][min_severity]">
6318 6442 <option value="critical" <?php selected( $alert_severity, 'critical' ); ?>><?php esc_html_e( 'Critical only (recommended)', 'vigilante' ); ?></option>
6319 6443 <option value="warning" <?php selected( $alert_severity, 'warning' ); ?>><?php esc_html_e( 'Warning and Critical', 'vigilante' ); ?></option>
6320 6444 </select>
6321 - <p class="description"><?php esc_html_e( 'A new administrator, a closed plugin or a privilege escalation are all logged as Critical, so "Critical only" already covers them.', 'vigilante' ); ?></p>
6445 + <p class="description"><?php esc_html_e( 'A closed plugin is logged as Critical, so "Critical only" covers it. A new administrator and a change of role are logged as Warning: they have their own emails, in Users > Admin monitoring, and reach this alert only with "Warning and Critical".', 'vigilante' ); ?></p>
6322 6446 </td>
6323 6447 </tr>
6324 6448 <tr id="field-audit-alerts-threshold">
6325 6449 <th scope="row"><?php esc_html_e( 'Threshold alerts', 'vigilante' ); ?></th>
@@ -6412,8 +6536,9 @@
6412 6536
6413 6537 // Label maps for translated display
6414 6538 $type_labels = array(
6415 6539 'login' => __( 'Login', 'vigilante' ),
6540 + '2fa' => __( 'Two-factor', 'vigilante' ),
6416 6541 'user' => __( 'User', 'vigilante' ),
6417 6542 'content' => __( 'Content', 'vigilante' ),
6418 6543 'plugin' => __( 'Plugin', 'vigilante' ),
6419 6544 'theme' => __( 'Theme', 'vigilante' ),
@@ -6442,8 +6567,9 @@
6442 6567 <input type="text" id="vigilante-log-search" aria-label="<?php esc_attr_e( 'Search the activity log', 'vigilante' ); ?>" size="1" placeholder="<?php esc_attr_e( 'Search logs (min. 3 characters)...', 'vigilante' ); ?>" class="vigilante-log-search-input">
6443 6568 <select id="vigilante-log-type-filter" aria-label="<?php esc_attr_e( 'Filter the log by event type', 'vigilante' ); ?>">
6444 6569 <option value=""><?php esc_html_e( 'All Types', 'vigilante' ); ?></option>
6445 6570 <option value="login"><?php esc_html_e( 'Login', 'vigilante' ); ?></option>
6571 + <option value="2fa"><?php esc_html_e( 'Two-factor', 'vigilante' ); ?></option>
6446 6572 <option value="user"><?php esc_html_e( 'User', 'vigilante' ); ?></option>
6447 6573 <option value="content"><?php esc_html_e( 'Content', 'vigilante' ); ?></option>
6448 6574 <option value="plugin"><?php esc_html_e( 'Plugin', 'vigilante' ); ?></option>
6449 6575 <option value="theme"><?php esc_html_e( 'Theme', 'vigilante' ); ?></option>
@@ -6669,8 +6795,15 @@
6669 6795 $files = isset( $state['files_checked'] ) ? (int) $state['files_checked'] : 0;
6670 6796 $anchors = ( isset( $state['anchors'] ) && is_array( $state['anchors'] ) ) ? $state['anchors'] : array();
6671 6797 switch ( $tone ) {
6672 6798 case 'critical':
6799 + $kind = Vigilante_Self_Integrity::alarm_kind( $state, Vigilante_Self_Integrity::is_on() );
6800 + if ( 'tasks' === $kind ) {
6801 + return __( 'Vigilant scheduled tasks keep being removed', 'vigilante' );
6802 + }
6803 + if ( 'hooks' === $kind ) {
6804 + return __( 'Something removed the hooks of Vigilant self-protection', 'vigilante' );
6805 + }
6673 6806 return __( 'Changes detected in Vigilant own files', 'vigilante' );
6674 6807 case 'warning':
6675 6808 return ( $files < 1 )
6676 6809 ? __( 'Vigilant could not check its own files', 'vigilante' )
@@ -6721,14 +6854,31 @@
6721 6854 }
6722 6855 $enabled = Vigilante_Self_Integrity::is_on();
6723 6856 $state = Vigilante_Self_Integrity::display_state();
6724 6857 $findings = Vigilante_Self_Integrity::state_findings( $state, $enabled );
6858 + $groups = $this->self_findings_by_case( $findings );
6859 + // Whether any case on screen is one a clean copy of the plugin fixes,
6860 + // which is what the guidance marks as repair: the notice only sends
6861 + // to whoever can repair then. Tasks, removed hooks, a filter and a
6862 + // check that has not run for days are not.
6863 + $about_files = false;
6864 + foreach ( $groups as $group ) {
6865 + if ( ! empty( $group['repair'] ) ) {
6866 + $about_files = true;
6867 + break;
6868 + }
6869 + }
6725 6870 $summary = array(
6726 - 'state' => $state,
6727 - 'findings' => $findings,
6728 - 'groups' => $this->self_findings_by_case( $findings ),
6729 - 'tone' => Vigilante_Self_Integrity::tone( $state, $enabled ),
6730 - 'enabled' => $enabled,
6871 + 'state' => $state,
6872 + 'findings' => $findings,
6873 + 'groups' => $groups,
6874 + 'about_files' => $about_files,
6875 + 'tone' => Vigilante_Self_Integrity::tone( $state, $enabled ),
6876 + 'enabled' => $enabled,
6877 + // What the alarm is about (files, off, hooks or tasks), so no
6878 + // text says that files changed when that is not what was found.
6879 + 'kind' => Vigilante_Self_Integrity::alarm_kind( $state, $enabled ),
6880 + 'tasks' => Vigilante_Self_Integrity::alarm_is_about_tasks( $state, $enabled ),
6731 6881 );
6732 6882 $cached = $summary;
6733 6883 return $summary;
6734 6884 }
@@ -6795,14 +6945,42 @@
6795 6945
6796 6946 <div class="vigilante-self-cases">
6797 6947 <?php
6798 6948 $first = true;
6949 + /*
6950 + * Reinstalling fixes files, not a filter someone wrote, a hook
6951 + * someone removed or a scheduled task that went missing: the
6952 + * button only appears when at least one of the cases on screen
6953 + * is one a clean copy solves. Offering it next to "switched off
6954 + * by code" was telling the person to fix something else. And
6955 + * when every case is about scheduled tasks there is nothing to
6956 + * repair, so the note about repairing by hand stays out too.
6957 + */
6958 + $vg_offer_repair = false;
6959 + $vg_scan_helps = false;
6799 6960 foreach ( $groups as $group ) {
6800 - $this->render_self_case( $group, $group['files'], $group['severity'], $first );
6961 + $vg_is_task = ( 0 === strpos( (string) $group['key'], 'cron_' ) );
6962 + $vg_unit = 'file';
6963 + if ( $vg_is_task ) {
6964 + $vg_unit = 'task';
6965 + } elseif ( 'hooks_removed' === $group['key'] ) {
6966 + $vg_unit = 'hook';
6967 + }
6968 + $this->render_self_case( $group, $group['files'], $group['severity'], $first, $vg_unit );
6801 6969 $first = false;
6970 + if ( ! empty( $group['repair'] ) ) {
6971 + $vg_offer_repair = true;
6972 + }
6973 + // A scan checks files: it clears nothing about a task, which
6974 + // closes on its own, nor about removed hooks, which the next
6975 + // admin page looks for again.
6976 + if ( ! $vg_is_task && 'hooks_removed' !== $group['key'] ) {
6977 + $vg_scan_helps = true;
6978 + }
6802 6979 }
6803 6980
6804 - if ( in_array( $tone, array( 'critical', 'warning' ), true ) ) {
6981 + // How to replace the files by hand is for a case a clean copy fixes.
6982 + if ( $vg_offer_repair && in_array( $tone, array( 'critical', 'warning' ), true ) ) {
6805 6983 $can_repair = class_exists( 'Vigilante_Self_Repair' ) && Vigilante_Self_Repair::can_repair();
6806 6984 if ( ! $can_repair ) {
6807 6985 if ( class_exists( 'Vigilante_Self_Repair' ) && ! Vigilante_Self_Repair::folder_is_the_distributed_one() ) {
6808 6986 $vg_reason = 'renamed';
@@ -6830,9 +7008,10 @@
6830 7008 }
6831 7009 ?>
6832 7010 </div>
6833 7011
6834 - <?php if ( $total > count( $findings ) ) : ?>
7012 + <?php // Switched off, the box only holds that case: the stored total is of the last check that ran. ?>
7013 + <?php if ( $enabled && $total > count( $findings ) ) : ?>
6835 7014 <p class="description">
6836 7015 <?php
6837 7016 printf(
6838 7017 /* translators: 1: findings shown, 2: findings found in total */
@@ -6843,24 +7022,8 @@
6843 7022 ?>
6844 7023 </p>
6845 7024 <?php endif; ?>
6846 7025
6847 - <?php
6848 - /*
6849 - * Reinstalling fixes files, not a filter someone wrote or a hook
6850 - * someone removed: the button only appears when at least one of the
6851 - * cases on screen is one a clean copy solves. Offering it next to
6852 - * "switched off by code" was telling the person to fix something
6853 - * else.
6854 - */
6855 - $vg_offer_repair = false;
6856 - foreach ( $groups as $vg_group ) {
6857 - if ( ! empty( $vg_group['repair'] ) ) {
6858 - $vg_offer_repair = true;
6859 - break;
6860 - }
6861 - }
6862 - ?>
6863 7026 <p class="vigilante-self-actions">
6864 7027 <?php if ( $vg_offer_repair && class_exists( 'Vigilante_Self_Repair' ) && Vigilante_Self_Repair::can_repair() ) : ?>
6865 7028 <a class="button button-primary" href="<?php echo esc_url( Vigilante_Self_Repair::action_url() ); ?>">
6866 7029 <?php esc_html_e( 'Repair Vigilant', 'vigilante' ); ?>
@@ -6867,9 +7030,9 @@
6867 7030 </a>
6868 7031 <span class="description">
6869 7032 <?php esc_html_e( 'It downloads a clean copy from WordPress.org, asks before changing anything, and keeps your settings and log.', 'vigilante' ); ?>
6870 7033 </span>
6871 - <?php else : ?>
7034 + <?php elseif ( $vg_scan_helps ) : ?>
6872 7035 <span class="description">
6873 7036 <?php esc_html_e( 'After fixing it, run a new scan with the Run Scan Now button above.', 'vigilante' ); ?>
6874 7037 </span>
6875 7038 <?php endif; ?>
@@ -6926,10 +7089,13 @@
6926 7089 * @param array $guidance Guidance entry (title, meaning, steps).
6927 7090 * @param array $files Paths this case was found in.
6928 7091 * @param string $severity critical|warning, empty for the how-to-repair row.
6929 7092 * @param bool $open Whether the row starts open.
7093 + * @param string $unit What is being counted, file, task or hook: a
7094 + * scheduled task and a removed hook were counted
7095 + * as "1 file" until 3.0.4.
6930 7096 */
6931 - private function render_self_case( $guidance, $files = array(), $severity = '', $open = false ) {
7097 + private function render_self_case( $guidance, $files = array(), $severity = '', $open = false, $unit = 'file' ) {
6932 7098 $shown = array_slice( (array) $files, 0, 20 );
6933 7099 $hidden = count( (array) $files ) - count( $shown );
6934 7100 ?>
6935 7101 <details class="vigilante-self-case vigilante-self-case--<?php echo esc_attr( '' !== $severity ? $severity : 'plain' ); ?>"<?php echo $open ? ' open' : ''; ?>>
@@ -6937,13 +7103,27 @@
6937 7103 <span class="vigilante-self-case-title"><?php echo esc_html( $guidance['title'] ); ?></span>
6938 7104 <?php if ( ! empty( $files ) ) : ?>
6939 7105 <span class="vigilante-self-case-count">
6940 7106 <?php
6941 - printf(
6942 - /* translators: %d: number of files of this finding */
6943 - esc_html( _n( '%d file', '%d files', count( (array) $files ), 'vigilante' ) ),
6944 - (int) count( (array) $files )
6945 - );
7107 + if ( 'task' === $unit ) {
7108 + printf(
7109 + /* translators: %d: number of scheduled tasks of this finding */
7110 + esc_html( _n( '%d task', '%d tasks', count( (array) $files ), 'vigilante' ) ),
7111 + (int) count( (array) $files )
7112 + );
7113 + } elseif ( 'hook' === $unit ) {
7114 + printf(
7115 + /* translators: %d: number of removed hooks of this finding */
7116 + esc_html( _n( '%d hook', '%d hooks', count( (array) $files ), 'vigilante' ) ),
7117 + (int) count( (array) $files )
7118 + );
7119 + } else {
7120 + printf(
7121 + /* translators: %d: number of files of this finding */
7122 + esc_html( _n( '%d file', '%d files', count( (array) $files ), 'vigilante' ) ),
7123 + (int) count( (array) $files )
7124 + );
7125 + }
6946 7126 ?>
6947 7127 </span>
6948 7128 <?php endif; ?>
6949 7129 <?php if ( '' !== $severity ) : ?>
@@ -6987,8 +7167,49 @@
6987 7167 </details>
6988 7168 <?php
6989 7169 }
6990 7170
7171 + /**
7172 + * Print what each kind of finding in a list usually is and what to do
7173 + *
7174 + * One line per kind actually present in the list, from the single source
7175 + * of that text, Vigilante_File_Integrity::finding_guidance().
7176 + *
7177 + * @param array $items Findings of one section of the last scan.
7178 + */
7179 + private function render_fi_guidance( $items ) {
7180 + $seen = array();
7181 + $guides = array();
7182 +
7183 + foreach ( (array) $items as $item ) {
7184 + $type = is_array( $item ) ? (string) ( $item['type'] ?? '' ) : '';
7185 +
7186 + if ( '' === $type || 'vigilante_self' === $type || isset( $seen[ $type ] ) ) {
7187 + continue;
7188 + }
7189 + $seen[ $type ] = true;
7190 +
7191 + $guide = Vigilante_File_Integrity::finding_guidance( $type );
7192 +
7193 + if ( '' !== $guide['advice'] ) {
7194 + // A plugin and a theme share their text: one line for both,
7195 + // under the two names.
7196 + $guides[ $guide['advice'] ][] = $guide['label'];
7197 + }
7198 + }
7199 +
7200 + if ( empty( $guides ) ) {
7201 + return;
7202 + }
7203 + ?>
7204 + <ul class="vigilante-fi-guidance" style="margin: 8px 0 12px 18px; list-style: disc;">
7205 + <?php foreach ( $guides as $advice => $labels ) : ?>
7206 + <li><strong><?php echo esc_html( implode( ', ', $labels ) ); ?>:</strong> <?php echo esc_html( $advice ); ?></li>
7207 + <?php endforeach; ?>
7208 + </ul>
7209 + <?php
7210 + }
7211 +
6991 7212 private function render_tab_file_integrity() {
6992 7213 $is_disabled = $this->render_module_disabled_notice( 'file_integrity' );
6993 7214 $options = $this->settings->get_section( 'file_integrity' );
6994 7215 // On the main site of a network the critical-file scan is the network's
@@ -7060,9 +7281,10 @@
7060 7281 <h2>
7061 7282 <?php esc_html_e( 'File Integrity Monitoring', 'vigilante' ); ?>
7062 7283 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
7063 7284 </h2>
7064 - <p><?php esc_html_e( 'Detects file modifications using WordPress.org checksums.', 'vigilante' ); ?></p>
7285 + <p><?php esc_html_e( 'Compares WordPress, and the plugins and themes that come from WordPress.org, with the official copy of each file, looks for code where there should be none (uploads and the site root) and watches wp-config.php and .htaccess for changes.', 'vigilante' ); ?></p>
7286 + <p class="description"><?php esc_html_e( 'What it cannot compare: plugins and themes that are not on WordPress.org, must-use plugins, drop-ins (advanced-cache.php, object-cache.php and the like) and the other folders of wp-content have no official copy. They are searched for hidden code, which finds malware that disguises itself and misses most code written in the open. The results name what could not be compared.', 'vigilante' ); ?></p>
7065 7287
7066 7288 <table class="form-table">
7067 7289 <tr>
7068 7290 <th scope="row"><?php esc_html_e( 'Automatic Scans', 'vigilante' ); ?></th>
@@ -7085,13 +7307,13 @@
7085 7307 <tr>
7086 7308 <th scope="row"><label for="vigilante-f-file-integrity-notify-level"><?php esc_html_e( 'Email Notifications', 'vigilante' ); ?></label></th>
7087 7309 <td>
7088 7310 <select id="vigilante-f-file-integrity-notify-level" name="file_integrity[notify_level]">
7089 - <option value="all" <?php selected( $notify_level, 'all' ); ?>><?php esc_html_e( 'All issues (modified + suspicious)', 'vigilante' ); ?></option>
7090 - <option value="suspicious_only" <?php selected( $notify_level, 'suspicious_only' ); ?>><?php esc_html_e( 'Suspicious files only', 'vigilante' ); ?></option>
7311 + <option value="all" <?php selected( $notify_level, 'all' ); ?>><?php esc_html_e( 'Every finding, modified files included', 'vigilante' ); ?></option>
7312 + <option value="suspicious_only" <?php selected( $notify_level, 'suspicious_only' ); ?>><?php esc_html_e( 'Serious findings only', 'vigilante' ); ?></option>
7091 7313 <option value="disabled" <?php selected( $notify_level, 'disabled' ); ?>><?php esc_html_e( 'Disabled', 'vigilante' ); ?></option>
7092 7314 </select>
7093 - <p class="description"><?php esc_html_e( '"Suspicious files only" reduces noise by skipping modified file notifications. Recommended for most sites.', 'vigilante' ); ?></p>
7315 + <p class="description"><?php esc_html_e( 'Serious findings are suspicious and extra files, changes in wp-config.php or .htaccess and closed plugins. That is the recommended choice: a file that is only modified is rarely more than an update that did not finish. The email goes out when a scan finds something new: a finding you were already told about is not sent again, unless the file changes once more.', 'vigilante' ); ?></p>
7094 7316 </td>
7095 7317 </tr>
7096 7318 <tr>
7097 7319 <th scope="row"><?php esc_html_e( 'Instant Alert', 'vigilante' ); ?></th>
@@ -7097,11 +7319,11 @@
7097 7319 <th scope="row"><?php esc_html_e( 'Instant Alert', 'vigilante' ); ?></th>
7098 7320 <td>
7099 7321 <label>
7100 7322 <input type="checkbox" name="file_integrity[instant_alert]" value="1" <?php checked( ! empty( $options['instant_alert'] ) ); ?>>
7101 - <?php esc_html_e( 'Send immediate alert when modified, suspicious or additional files are detected, or when a closed plugin is found', 'vigilante' ); ?>
7323 + <?php esc_html_e( 'Email every finding when a scan ends, modified files included, and email a closed plugin as soon as the daily check finds it', 'vigilante' ); ?>
7102 7324 </label>
7103 - <p class="description"><?php esc_html_e( 'Fires even if the Email Notifications setting above is set to Disabled.', 'vigilante' ); ?></p>
7325 + <p class="description"><?php esc_html_e( 'This takes precedence over the choice above, Disabled included.', 'vigilante' ); ?></p>
7104 7326 <p class="description">
7105 7327 <?php
7106 7328 printf(
7107 7329 /* translators: %s: Link to notification settings */
@@ -7127,29 +7349,29 @@
7127 7349 <td>
7128 7350 <fieldset>
7129 7351 <label>
7130 7352 <input type="checkbox" name="file_integrity[scan_core]" value="1" <?php checked( $options['scan_core'] ?? true ); ?>>
7131 - <?php esc_html_e( 'Core files (compare against WordPress.org checksums)', 'vigilante' ); ?>
7353 + <?php esc_html_e( 'WordPress core (compared with WordPress.org) and the other files in the site root', 'vigilante' ); ?>
7132 7354 </label>
7133 7355 <br>
7134 7356 <label>
7135 7357 <input type="checkbox" name="file_integrity[scan_plugins]" value="1" <?php checked( $options['scan_plugins'] ?? true ); ?>>
7136 - <?php esc_html_e( 'Plugins (WordPress.org repository plugins)', 'vigilante' ); ?>
7358 + <?php esc_html_e( 'Plugins (compared with WordPress.org; the rest, and the other folders of wp-content, are only searched for hidden code)', 'vigilante' ); ?>
7137 7359 </label>
7138 7360 <br>
7139 7361 <label>
7140 7362 <input type="checkbox" name="file_integrity[scan_themes]" value="1" <?php checked( $options['scan_themes'] ?? true ); ?>>
7141 - <?php esc_html_e( 'Themes (WordPress.org repository themes)', 'vigilante' ); ?>
7363 + <?php esc_html_e( 'Themes (compared with WordPress.org; the rest are only searched for hidden code)', 'vigilante' ); ?>
7142 7364 </label>
7143 7365 <br>
7144 7366 <label>
7145 7367 <input type="checkbox" name="file_integrity[scan_uploads]" value="1" <?php checked( $options['scan_uploads'] ?? true ); ?>>
7146 - <?php esc_html_e( 'Uploads directory (detect PHP files, double extensions, .htaccess)', 'vigilante' ); ?>
7368 + <?php esc_html_e( 'Uploads directory (PHP and other scripts, double extensions, .htaccess and PHP configuration files)', 'vigilante' ); ?>
7147 7369 </label>
7148 7370 <br>
7149 7371 <label>
7150 7372 <input type="checkbox" name="file_integrity[scan_critical_config]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( $options['scan_critical_config'] ?? true ); ?>>
7151 - <?php esc_html_e( 'Critical config files (wp-config.php, .htaccess baseline monitoring)', 'vigilante' ); ?>
7373 + <?php esc_html_e( 'wp-config.php and .htaccess (any change since you last approved them)', 'vigilante' ); ?>
7152 7374 <?php if ( $vg_main_locked ) : ?>
7153 7375 <span class="description" style="display:block;margin-left:24px;"><?php echo esc_html( Vigilante_Settings::get_shared_files_notice() ); ?></span>
7154 7376 <?php endif; ?>
7155 7377 </label>
@@ -7163,10 +7385,10 @@
7163 7385 </tr>
7164 7386 <tr>
7165 7387 <th scope="row"><label for="vigilante-f-file-integrity-excluded-paths"><?php esc_html_e( 'Excluded Paths', 'vigilante' ); ?></label></th>
7166 7388 <td>
7167 - <textarea id="vigilante-f-file-integrity-excluded-paths" name="file_integrity[excluded_paths]" rows="4" class="large-text code" placeholder="wp-content/cache&#10;wp-content/languages"><?php echo esc_textarea( implode( "\n", $options['excluded_paths'] ?? array() ) ); ?></textarea>
7168 - <p class="description"><?php esc_html_e( 'One path per line, relative to the WordPress root. A path such as wp-content/cache excludes exactly that folder and everything under it. A name on its own, such as cache, excludes any folder called exactly that, wherever it is.', 'vigilante' ); ?></p>
7389 + <textarea id="vigilante-f-file-integrity-excluded-paths" name="file_integrity[excluded_paths]" rows="4" class="large-text code" placeholder="wp-content/backups"><?php echo esc_textarea( implode( "\n", $options['excluded_paths'] ?? array() ) ); ?></textarea>
7390 + <p class="description"><?php esc_html_e( 'One path per line, relative to the WordPress root. A path such as wp-content/backups excludes exactly that folder and everything under it. A name on its own, such as backups, excludes any folder called exactly that, wherever it is. Nothing in an excluded folder is looked at, so keep the list short.', 'vigilante' ); ?></p>
7169 7391 </td>
7170 7392 </tr>
7171 7393 <tr>
7172 7394 <th scope="row"><label for="vigilante-f-file-integrity-excluded-extensions"><?php esc_html_e( 'Excluded Extensions', 'vigilante' ); ?></label></th>
@@ -7209,11 +7431,49 @@
7209 7431 <div id="vigilante-scan-results" class="vigilante-settings-section" style="display:none;"></div>
7210 7432
7211 7433 <?php $this->render_self_protection_box( $options ); ?>
7212 7434
7435 + <?php
7436 + // Whole folders of code left out of every scan. A partial scan tells
7437 + // how to check the rest by excluding what it did check, and those
7438 + // lines have to come out afterwards: with them in place a scan comes
7439 + // out complete and clean without having looked there. Said for as
7440 + // long as they are in the list, whoever put them there and why.
7441 + $vg_fi_tool = new Vigilante_File_Integrity( $this->settings, $this->database );
7442 + $vg_excluded_code = $vg_fi_tool->excluded_code_folders();
7443 + $vg_folders_to_skip = $vg_fi_tool->folders_to_exclude( $last_results );
7444 + ?>
7445 + <?php if ( ! empty( $vg_excluded_code ) ) : ?>
7446 + <div id="vigilante-fi-excluded-code" class="notice notice-warning inline">
7447 + <p>
7448 + <strong>
7449 + <?php
7450 + printf(
7451 + /* translators: %s: Comma separated list of folders */
7452 + esc_html__( 'Excluded Paths holds whole folders of plugins or themes, or the uploads folder: %s.', 'vigilante' ),
7453 + esc_html( implode( ', ', $vg_excluded_code ) )
7454 + );
7455 + ?>
7456 + </strong>
7457 + <?php esc_html_e( 'No scan looks inside them, the scheduled one included, and a scan can come out complete and clean without having checked them. If you added them to check the rest of a large site, take them out once that scan is done.', 'vigilante' ); ?>
7458 + </p>
7459 + </div>
7460 + <?php endif; ?>
7461 +
7213 7462 <?php if ( $last_scan || $has_closed || $closed_last_check > 0 ) : ?>
7214 7463 <div id="vigilante-section-fi-last-scan" class="vigilante-settings-section">
7215 7464 <h2><?php esc_html_e( 'Last Scan Results', 'vigilante' ); ?></h2>
7465 + <?php
7466 + // A scan the server stops halfway stores nothing, and neither
7467 + // does one WordPress never starts: the tab kept showing the
7468 + // last result that did finish, with nothing to say that scans
7469 + // had stopped. Its date is the only sign.
7470 + if ( $last_scan && $this->settings->is_module_enabled( 'file_integrity' ) && Vigilante_File_Integrity::is_scan_overdue( $last_scan, $options['scan_frequency'] ?? 'daily' ) ) :
7471 + ?>
7472 + <div class="notice notice-warning inline">
7473 + <p><strong><?php esc_html_e( 'Scans are scheduled, and none has finished since the date below.', 'vigilante' ); ?></strong> <?php esc_html_e( 'Either WordPress is not running its scheduled tasks, or the server stops the scan before it ends, and a scan that is stopped stores no result. What you see here is old. Run a scan now: if it finishes, the scheduled tasks are what is failing. If it does not, the site is too large for the time the server gives a request, and adding large folders that do not need checking to Excluded Paths leaves more time for the rest.', 'vigilante' ); ?></p>
7474 + </div>
7475 + <?php endif; ?>
7216 7476 <?php if ( $last_scan ) : ?>
7217 7477 <p>
7218 7478 <?php
7219 7479 // Build the "X files scanned" hint inline with the date so it doesn't
@@ -7296,12 +7556,112 @@
7296 7556 </div>
7297 7557 <?php endif; ?>
7298 7558 </div>
7299 7559
7560 + <?php
7561 + // A scan that could not finish, or could not get the official
7562 + // copy to compare with, checked less than it seems. Until now
7563 + // its errors were stored and never shown, and the tab could
7564 + // say that every file had passed.
7565 + $vg_scan_errors = ( $last_results && ! empty( $last_results['errors'] ) && is_array( $last_results['errors'] ) ) ? $last_results['errors'] : array();
7566 + $vg_scan_partial = ! empty( $vg_scan_errors ) || ( $last_results && ! empty( $last_results['incomplete'] ) );
7567 +
7568 + // Files of the official copies that are not on the server. Core
7569 + // files under wp-content are left out: results stored before
7570 + // the bundled themes and plugins got their own turn list
7571 + // there the ones people delete on purpose.
7572 + $vg_missing_core = array();
7573 + if ( $last_results && ! empty( $last_results['missing'] ) && is_array( $last_results['missing'] ) ) {
7574 + foreach ( $last_results['missing'] as $item ) {
7575 + if ( ! is_array( $item ) || ! isset( $item['file'] ) ) {
7576 + continue;
7577 + }
7578 +
7579 + $vg_missing_type = (string) ( $item['type'] ?? '' );
7580 +
7581 + if ( in_array( $vg_missing_type, array( 'plugin', 'theme' ), true )
7582 + || ( 'core' === $vg_missing_type && 0 !== strpos( (string) $item['file'], 'wp-content/' ) ) ) {
7583 + $vg_missing_core[] = (string) $item['file'];
7584 + }
7585 + }
7586 + }
7587 + ?>
7588 +
7589 + <?php if ( $last_results && ! empty( $last_results['unverified'] ) && is_array( $last_results['unverified'] ) ) : ?>
7590 + <p class="description">
7591 + <?php
7592 + printf(
7593 + /* translators: %s: Comma separated list of plugin and theme names */
7594 + esc_html__( 'No official copy to compare with, so only searched for hidden code: %s.', 'vigilante' ),
7595 + esc_html( implode( ', ', array_unique( array_map( 'strval', $last_results['unverified'] ) ) ) )
7596 + );
7597 + ?>
7598 + </p>
7599 + <?php endif; ?>
7600 +
7601 + <?php
7602 + // What the scan did not get to, by name. A scan starts from
7603 + // the beginning every time, so on a site too large for it
7604 + // these are the same on every scan, and whoever reads the
7605 + // result has to know that nothing here was looked at.
7606 + $vg_scan_notes = array_merge( $vg_scan_errors, Vigilante_File_Integrity::not_checked_lines( $last_results ) );
7607 + ?>
7608 + <?php if ( $vg_scan_partial ) : ?>
7609 + <div class="notice notice-warning inline">
7610 + <p><strong><?php esc_html_e( 'The last scan could not check everything, so its result is partial.', 'vigilante' ); ?></strong></p>
7611 + <?php if ( ! empty( $vg_scan_notes ) ) : ?>
7612 + <ul style="margin: 0 0 8px 18px; list-style: disc;">
7613 + <?php foreach ( $vg_scan_notes as $vg_scan_note ) : ?>
7614 + <li><?php echo esc_html( (string) $vg_scan_note ); ?></li>
7615 + <?php endforeach; ?>
7616 + </ul>
7617 + <?php endif; ?>
7618 + <p>
7619 + <?php
7620 + printf(
7621 + /* translators: %d: Seconds a scan runs for at most */
7622 + esc_html__( 'Run the scan again: a second scan is faster, because what it downloads from WordPress.org is kept for a while. If the result is still partial, either the server cannot reach WordPress.org or the site is larger than a scan can check in its %d seconds, and then every scan leaves the same part unchecked. Adding large folders that do not need checking to Excluded Paths leaves more time for the rest.', 'vigilante' ),
7623 + (int) Vigilante_File_Integrity::MAX_SCAN_SECONDS
7624 + );
7625 + ?>
7626 + </p>
7627 + <?php if ( ! empty( $vg_folders_to_skip ) ) : ?>
7628 + <p><strong><?php esc_html_e( 'To check what was left out:', 'vigilante' ); ?></strong></p>
7629 + <ol style="margin: 0 0 8px 18px;">
7630 + <li><?php esc_html_e( 'Deal first with what this scan found in the plugins and themes it did check: the next result will not list it. Then add the lines below, which are their folders, to Excluded Paths, higher up on this page, and save.', 'vigilante' ); ?></li>
7631 + <li><?php esc_html_e( 'Press Run Scan Now. The scan spends its time on the rest. If it comes out partial again, repeat with the lines it gives then.', 'vigilante' ); ?></li>
7632 + <li><?php esc_html_e( 'Take those lines out of Excluded Paths. While they are there no scan looks inside those folders, the scheduled one included.', 'vigilante' ); ?></li>
7633 + </ol>
7634 + <textarea id="vigilante-fi-folders-to-exclude" readonly rows="<?php echo (int) min( 12, max( 3, count( $vg_folders_to_skip ) ) ); ?>" class="large-text code" style="margin-bottom: 8px;"><?php echo esc_textarea( implode( "\n", $vg_folders_to_skip ) ); ?></textarea>
7635 + <?php endif; ?>
7636 + </div>
7637 + <?php endif; ?>
7638 +
7639 + <?php if ( ! empty( $vg_missing_core ) ) : ?>
7640 + <div class="vigilante-file-list">
7641 + <h3><?php esc_html_e( 'Missing Files', 'vigilante' ); ?></h3>
7642 + <p class="description"><?php esc_html_e( 'These files are in the official copy and not on the server. Reinstall what they belong to (WordPress from Dashboard > Updates, a plugin or a theme from its own screen) and scan again.', 'vigilante' ); ?></p>
7643 + <ul style="margin: 8px 0 12px 18px; list-style: disc;">
7644 + <?php foreach ( array_slice( $vg_missing_core, 0, 20 ) as $vg_missing_file ) : ?>
7645 + <li><code><?php echo esc_html( $vg_missing_file ); ?></code></li>
7646 + <?php endforeach; ?>
7647 + </ul>
7648 + <?php if ( count( $vg_missing_core ) > 20 ) : ?>
7649 + <p class="description">
7650 + <?php
7651 + /* translators: %d: Number of additional files */
7652 + printf( esc_html__( '... and %d more', 'vigilante' ), (int) ( count( $vg_missing_core ) - 20 ) );
7653 + ?>
7654 + </p>
7655 + <?php endif; ?>
7656 + </div>
7657 + <?php endif; ?>
7658 +
7300 7659 <?php if ( ! empty( $last_results['suspicious'] ) ) : ?>
7301 7660 <div class="vigilante-file-list vigilante-suspicious-files vigilante-paginated-section" data-bulk-mode="ignore">
7302 7661 <h3 style="color: #d63638;"><?php esc_html_e( 'Suspicious Files', 'vigilante' ); ?></h3>
7303 - <p class="description" style="color: #d63638;"><?php esc_html_e( '&#9888; Warning: These files may contain malicious code or are in unexpected locations. Review immediately!', 'vigilante' ); ?></p>
7662 + <p class="description" style="color: #d63638;"><?php esc_html_e( '&#9888; Files that can run code where none is expected, or that contain code written the way malware hides itself. Real infections show up here, and so do harmless files that plugins generate. Check the path and the reason before acting.', 'vigilante' ); ?></p>
7663 + <?php $this->render_fi_guidance( $last_results['suspicious'] ); ?>
7304 7664 <div class="vigilante-fi-bulk-bar">
7305 7665 <button type="button" class="button vigilante-bulk-ignore" disabled><?php esc_html_e( 'Ignore selected', 'vigilante' ); ?></button>
7306 7666 <span class="vigilante-fi-bulk-count" aria-live="polite"></span>
7307 7667 </div>
@@ -7337,8 +7697,11 @@
7337 7697 $file_path = (string) $item;
7338 7698 }
7339 7699 if ( 'vigilante_self' === $file_type ) {
7340 7700 $file_type = __( 'Vigilant (self)', 'vigilante' );
7701 + } else {
7702 + // A name a person can read, instead of the internal slug.
7703 + $file_type = Vigilante_File_Integrity::finding_guidance( $file_type )['label'];
7341 7704 }
7342 7705 ?>
7343 7706 <tr>
7344 7707 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
@@ -7357,9 +7720,10 @@
7357 7720
7358 7721 <?php if ( ! empty( $last_results['extra'] ) ) : ?>
7359 7722 <div class="vigilante-file-list vigilante-extra-files vigilante-paginated-section" data-bulk-mode="ignore">
7360 7723 <h3 style="color: #b32d2e;"><?php esc_html_e( 'Extra Files', 'vigilante' ); ?></h3>
7361 - <p class="description"><?php esc_html_e( 'PHP files found in plugins or themes that are not part of the original distribution from WordPress.org. May be legitimate customizations or injected backdoors.', 'vigilante' ); ?></p>
7724 + <p class="description"><?php esc_html_e( 'Files that are not part of WordPress, or of the plugin or theme they sit in. Many are legitimate, and an injected file looks the same from here, so confirm where each one comes from.', 'vigilante' ); ?></p>
7725 + <?php $this->render_fi_guidance( $last_results['extra'] ); ?>
7362 7726 <div class="vigilante-fi-bulk-bar">
7363 7727 <button type="button" class="button vigilante-bulk-ignore" disabled><?php esc_html_e( 'Ignore selected', 'vigilante' ); ?></button>
7364 7728 <span class="vigilante-fi-bulk-count" aria-live="polite"></span>
7365 7729 </div>
@@ -7381,8 +7745,11 @@
7381 7745 $file_reason = is_array( $item ) ? ( $item['reason'] ?? __( 'Unknown', 'vigilante' ) ) : __( 'Unknown', 'vigilante' );
7382 7746 $file_type = is_array( $item ) ? ( $item['type'] ?? 'unknown' ) : 'unknown';
7383 7747 if ( 'vigilante_self' === $file_type ) {
7384 7748 $file_type = __( 'Vigilant (self)', 'vigilante' );
7749 + } else {
7750 + // A name a person can read, instead of the internal slug.
7751 + $file_type = Vigilante_File_Integrity::finding_guidance( $file_type )['label'];
7385 7752 }
7386 7753 ?>
7387 7754 <tr>
7388 7755 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
@@ -7431,9 +7798,9 @@
7431 7798 <?php if ( ! empty( $critical_modified ) ) : ?>
7432 7799 <div class="vigilante-file-list vigilante-critical-config-files">
7433 7800 <h3 style="color: #e36210;"><?php esc_html_e( 'Critical config files modified', 'vigilante' ); ?></h3>
7434 7801 <p class="description">
7435 - <?php esc_html_e( 'These files are common targets for code injection. Review the changes and approve if they are legitimate. Vigilant\'s own blocks are excluded from this check.', 'vigilante' ); ?>
7802 + <?php esc_html_e( 'These files changed since you last approved them. They are a common target for code injection, and they are also rewritten by cache, security and redirection plugins, often on every update. Review the changes and approve them if they belong to something you use: approving records the current content as the reference and does not touch the file. Vigilant\'s own blocks are excluded from this check, and Vigilant never restores these files by itself.', 'vigilante' ); ?>
7436 7803 </p>
7437 7804 <table class="wp-list-table widefat fixed striped">
7438 7805 <thead>
7439 7806 <tr>
@@ -7594,9 +7961,10 @@
7594 7961
7595 7962 <?php if ( ! empty( $regular_modified ) ) : ?>
7596 7963 <div class="vigilante-file-list vigilante-paginated-section" data-bulk-mode="ignore">
7597 7964 <h3><?php esc_html_e( 'Modified Files', 'vigilante' ); ?></h3>
7598 - <p class="description"><?php esc_html_e( 'These files (apparently) differ from the original WordPress or plugin versions.', 'vigilante' ); ?></p>
7965 + <p class="description"><?php esc_html_e( 'These files do not match the copy WordPress.org publishes for the installed version.', 'vigilante' ); ?></p>
7966 + <?php $this->render_fi_guidance( $regular_modified ); ?>
7599 7967 <div class="vigilante-fi-bulk-bar">
7600 7968 <button type="button" class="button vigilante-bulk-ignore" disabled><?php esc_html_e( 'Ignore selected', 'vigilante' ); ?></button>
7601 7969 <span class="vigilante-fi-bulk-count" aria-live="polite"></span>
7602 7970 </div>
@@ -7627,8 +7995,11 @@
7627 7995 $file_path = (string) $item;
7628 7996 }
7629 7997 if ( 'vigilante_self' === $file_type ) {
7630 7998 $file_type = __( 'Vigilant (self)', 'vigilante' );
7999 + } else {
8000 + // A name a person can read, instead of the internal slug.
8001 + $file_type = Vigilante_File_Integrity::finding_guidance( $file_type )['label'];
7631 8002 }
7632 8003 ?>
7633 8004 <tr>
7634 8005 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
@@ -7650,11 +8021,11 @@
7650 8021 // above, this line contradicted it (its findings no longer
7651 8022 // travel in the tables below).
7652 8023 $vg_self_alarm = in_array( $this->self_integrity_summary( $options )['tone'], array( 'critical', 'off', 'warning' ), true );
7653 8024 ?>
7654 - <?php if ( $last_results && ! $vg_self_alarm && empty( $critical_modified ) && empty( $regular_modified ) && empty( $last_results['suspicious'] ) && empty( $last_results['extra'] ) && ! $has_closed ) : ?>
8025 + <?php if ( $last_results && ! $vg_self_alarm && empty( $critical_modified ) && empty( $regular_modified ) && empty( $last_results['suspicious'] ) && empty( $last_results['extra'] ) && ! $has_closed && ! $vg_scan_partial && empty( $vg_missing_core ) ) : ?>
7655 8026 <p class="vigilante-all-clear" style="color: #00a32a; font-weight: bold;">
7656 - <?php esc_html_e( 'Good Job! All files passed integrity check. No issues found.', 'vigilante' ); ?>
8027 + <?php esc_html_e( 'Good job! Nothing to report in the files that were checked.', 'vigilante' ); ?>
7657 8028 </p>
7658 8029 <?php endif; ?>
7659 8030 <?php endif; ?>
7660 8031 </div>
@@ -7703,9 +8074,9 @@
7703 8074
7704 8075 <?php if ( ! empty( $ignored_files ) ) : ?>
7705 8076 <div id="vigilante-section-fi-ignored" class="vigilante-settings-section">
7706 8077 <h2><?php esc_html_e( 'Ignored Files', 'vigilante' ); ?></h2>
7707 - <p class="description"><?php esc_html_e( 'These files are excluded from scan results and email notifications. They will still be scanned but any findings will be hidden.', 'vigilante' ); ?></p>
8078 + <p class="description"><?php esc_html_e( 'These files are excluded from scan results and email notifications. They will still be scanned but any findings will be hidden. To leave a whole folder out, add it to Excluded Paths instead.', 'vigilante' ); ?></p>
7708 8079 <div class="vigilante-paginated-section" data-bulk-mode="unignore">
7709 8080 <div class="vigilante-fi-bulk-bar">
7710 8081 <button type="button" class="button vigilante-bulk-unignore" disabled><?php esc_html_e( 'Stop ignoring selected', 'vigilante' ); ?></button>
7711 8082 <span class="vigilante-fi-bulk-count" aria-live="polite"></span>
@@ -7794,9 +8165,9 @@
7794 8165 } elseif ( isset( $_POST['data'] ) ) {
7795 8166 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
7796 8167 $raw_data = wp_unslash( $_POST['data'] );
7797 8168 parse_str( $raw_data, $data );
7798 - $data = map_deep( $data, 'sanitize_textarea_field' );
8169 + $data = $this->sanitize_posted_settings( $data );
7799 8170 } else {
7800 8171 $data = array();
7801 8172 }
7802 8173
@@ -8186,8 +8557,48 @@
8186 8557 return array( 'sent' => $sent, 'failed' => $failed );
8187 8558 }
8188 8559
8189 8560 /**
8561 + * Sanitize the settings a tab posted, once the serialized form is parsed
8562 + *
8563 + * Every value goes through sanitize_textarea_field() except the lists of
8564 + * file paths (Vigilante_Settings::path_list_keys()). That one deletes every
8565 + * %XX octet and, when it finds one, collapses repeated spaces, so an
8566 + * exclusion for a folder called foto%20x was saved as fotox and excluded
8567 + * nothing (measured on 1 oct 2026 against 3.0.1). The paths are read as
8568 + * paths before the generic sanitizer runs and put back after it.
8569 + *
8570 + * @since 3.0.2
8571 + *
8572 + * @param array $data Parsed form: section => key => value.
8573 + * @return array
8574 + */
8575 + private function sanitize_posted_settings( $data ) {
8576 + $paths = Vigilante_Settings::take_path_lists( $data );
8577 + $data = map_deep( $data, 'sanitize_textarea_field' );
8578 +
8579 + return Vigilante_Settings::restore_path_lists( $data, $paths );
8580 + }
8581 +
8582 + /**
8583 + * Sanitize an imported settings file, once it is decoded
8584 + *
8585 + * Same rule as sanitize_posted_settings(): the lists of paths keep what the
8586 + * export wrote, so an exclusion survives an export and an import.
8587 + *
8588 + * @since 3.0.2
8589 + *
8590 + * @param array $imported Decoded export.
8591 + * @return array
8592 + */
8593 + private function sanitize_imported_settings( $imported ) {
8594 + $paths = Vigilante_Settings::take_path_lists( $imported );
8595 + $imported = map_deep( $imported, 'sanitize_text_field' );
8596 +
8597 + return Vigilante_Settings::restore_path_lists( $imported, $paths );
8598 + }
8599 +
8600 + /**
8190 8601 * Process section data maintaining proper types from defaults
8191 8602 *
8192 8603 * @param array $submitted_data Data submitted from form.
8193 8604 * @param array $defaults Default values for this section.
@@ -8514,9 +8925,9 @@
8514 8925 wp_send_json_error( __( 'Invalid JSON format.', 'vigilante' ) );
8515 8926 }
8516 8927
8517 8928 // Sanitize imported data recursively
8518 - $imported = map_deep( $imported, 'sanitize_text_field' );
8929 + $imported = $this->sanitize_imported_settings( $imported );
8519 8930
8520 8931 // Validate structure: only sections and keys of the schema survive, and
8521 8932 // every value takes the type of its default. Until 2.11.0 this was an
8522 8933 // array_replace_recursive() of the file over the defaults, so any key in
@@ -8899,8 +9310,12 @@
8899 9310
8900 9311 delete_option( 'vigilante_last_integrity_results' );
8901 9312 delete_option( 'vigilante_last_integrity_scan' );
8902 9313
9314 + // Starting over also means being told again: the next scan emails
9315 + // whatever it finds, as if nothing had been reported before.
9316 + delete_option( Vigilante_File_Integrity::NOTIFIED_OPTION );
9317 +
8903 9318 /*
8904 9319 * A pending change to wp-config.php or the root .htaccess is closed by
8905 9320 * approving it, which takes the network. Clearing the results was one
8906 9321 * more way to close it without, until the next scan: the ignore list was
@@ -8952,9 +9367,29 @@
8952 9367 if ( ! current_user_can( 'manage_options' ) ) {
8953 9368 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
8954 9369 }
8955 9370
8956 - $file = isset( $_POST['file'] ) ? sanitize_text_field( wp_unslash( $_POST['file'] ) ) : '';
9371 + /*
9372 + * The path as the scan reported it, which is what the interface sends
9373 + * back. sanitize_text_field() is the wrong reader for it: it deletes
9374 + * every %XX octet and collapses repeated spaces, so a file named
9375 + * foto%20nueva.php was stored as fotonueva.php, the scan compared the
9376 + * list with the paths it finds one by one, never matched, and the
9377 + * warning came back (measured on 1 oct 2026 against 3.0.1). The four
9378 + * handlers of the ignore list read the path the same way.
9379 + *
9380 + * Known limits, found by the cross review, that only a file name with a
9381 + * stray < in it reaches. wp_strip_all_tags() shortens such a name at the
9382 + * < instead of refusing it, so the stored entry can match another file
9383 + * (a.php<b is stored as a.php), and the value ignore_file() returns is
9384 + * not read, so a refusal of its own is answered as success. The fix, if
9385 + * a site ever needs it, is to answer with an error when
9386 + * wp_strip_all_tags() changes what was sent and to check that return
9387 + * value. An entry the old reader stored for such a name (it wrote the
9388 + * < as &lt;) is listed but "Stop ignoring" cannot remove it; "Clear all
9389 + * ignored files" does.
9390 + */
9391 + $file = ( isset( $_POST['file'] ) && is_string( $_POST['file'] ) ) ? wp_strip_all_tags( wp_unslash( $_POST['file'] ) ) : '';
8957 9392
8958 9393 if ( empty( $file ) ) {
8959 9394 wp_send_json_error( __( 'No file specified.', 'vigilante' ) );
8960 9395 }
@@ -9007,9 +9442,9 @@
9007 9442 if ( ! current_user_can( 'manage_options' ) ) {
9008 9443 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
9009 9444 }
9010 9445
9011 - $file = isset( $_POST['file'] ) ? sanitize_text_field( wp_unslash( $_POST['file'] ) ) : '';
9446 + $file = ( isset( $_POST['file'] ) && is_string( $_POST['file'] ) ) ? wp_strip_all_tags( wp_unslash( $_POST['file'] ) ) : '';
9012 9447
9013 9448 if ( empty( $file ) ) {
9014 9449 wp_send_json_error( __( 'No file specified.', 'vigilante' ) );
9015 9450 }
@@ -9042,9 +9477,9 @@
9042 9477 $files = array();
9043 9478 $locked = $this->critical_approval_locked();
9044 9479 $shared = $locked ? array( 'wp-config.php', '.htaccess' ) : array();
9045 9480 foreach ( $raw_files as $f ) {
9046 - $clean = sanitize_text_field( $f );
9481 + $clean = is_string( $f ) ? wp_strip_all_tags( $f ) : '';
9047 9482 // Same rule as ajax_ignore_file(): the two shared files when the
9048 9483 // network locks them, and Vigilant's own files always.
9049 9484 if ( '' !== $clean && ! in_array( $clean, $shared, true ) && ! Vigilante_Self_Integrity::is_own_file_path( $clean ) ) {
9050 9485 $files[] = $clean;
@@ -9111,9 +9546,9 @@
9111 9546 }
9112 9547
9113 9548 $files = array();
9114 9549 foreach ( $raw_files as $f ) {
9115 - $clean = sanitize_text_field( $f );
9550 + $clean = is_string( $f ) ? wp_strip_all_tags( $f ) : '';
9116 9551 if ( '' !== $clean ) {
9117 9552 $files[] = $clean;
9118 9553 }
9119 9554 }
@@ -9235,13 +9670,99 @@
9235 9670 if ( ! current_user_can( 'manage_options' ) ) {
9236 9671 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
9237 9672 }
9238 9673
9239 - // Get security grade from settings (not from actual HTTP request)
9240 - $security_headers = new Vigilante_Security_Headers( $this->settings );
9241 - $results = $security_headers->get_security_grade();
9674 + $results = $this->headers_test_results();
9675 + if ( is_wp_error( $results ) ) {
9676 + wp_send_json_error( $results->get_error_message() );
9677 + }
9242 9678
9243 9679 wp_send_json_success( $results );
9680 + }
9681 +
9682 + /**
9683 + * What the Test Headers button shows: the headers that reach the browser
9684 + *
9685 + * Asks the home page and runs the header checks of Security Check on the
9686 + * answer, so the two can never say different things about the same site.
9687 + * Until 3.0.4 the button read the settings of the tab, graded them on a
9688 + * scale of its own and listed them as enabled headers: on a server that
9689 + * does not apply the rules of Vigilant it showed a B while Security
9690 + * Check, which does look, said that no header arrived.
9691 + *
9692 + * @since 3.0.5
9693 + *
9694 + * The lists carry text read from the answer of the site (the value of a
9695 + * header), so whoever prints them escapes them: displayHeaderResults()
9696 + * in assets/js/admin.js.
9697 + *
9698 + * They travel as passed, failing and notes. The names they had until
9699 + * 3.0.4 (headers, missing and warnings) stay in the answer, empty: a
9700 + * page that was open when the plugin was updated still runs the script
9701 + * it loaded, which prints those three without escaping them, and it must
9702 + * get the grade and no text read from the site.
9703 + *
9704 + * @return array|WP_Error grade, score (0 to 100), passed, failing and notes.
9705 + */
9706 + private function headers_test_results() {
9707 + if ( ! class_exists( 'Vigilante_Security_Analyzer' ) ) {
9708 + require_once VIGILANTE_INCLUDES_DIR . 'class-security-analyzer.php';
9709 + }
9710 + // The constructor loads the checks and their helpers.
9711 + new Vigilante_Security_Analyzer( $this->settings, $this->activity_log );
9712 + $no_answer = new WP_Error( 'vigilante_no_answer', __( 'Could not fetch the homepage to read response headers.', 'vigilante' ) );
9713 + // A new request every time: the button is pressed right after saving.
9714 + // With no answer there is nothing to grade, and the checks would ask
9715 + // for the page a second time.
9716 + if ( null === Vigilante_SA_Helpers::probe_home( true ) ) {
9717 + return $no_answer;
9718 + }
9719 +
9720 + $category = new Vigilante_SA_Category_Headers( $this->settings );
9721 + $passed = array();
9722 + $failed = array();
9723 + $notes = array();
9724 + $earned = 0;
9725 + $max = 0;
9726 +
9727 + foreach ( $category->run( 'slow' ) as $check ) {
9728 + if ( Vigilante_SA_Check_Result::STATE_SKIP === $check->state ) {
9729 + continue;
9730 + }
9731 + if ( Vigilante_SA_Check_Result::STATE_INFO === $check->state ) {
9732 + $notes[] = $check->detail;
9733 + continue;
9734 + }
9735 + $earned += (int) $check->score;
9736 + $max += (int) $check->max;
9737 + // The three lists are the three counters of Security Check.
9738 + if ( Vigilante_SA_Check_Result::STATE_PASS === $check->state ) {
9739 + $passed[] = $check->detail;
9740 + } elseif ( Vigilante_SA_Check_Result::STATE_WARN === $check->state ) {
9741 + $notes[] = $check->detail;
9742 + } else {
9743 + $failed[] = $check->detail;
9744 + }
9745 + }
9746 +
9747 + if ( $max < 1 ) {
9748 + return $no_answer;
9749 + }
9750 +
9751 + $score = (int) round( 100 * $earned / $max );
9752 + $sender = new Vigilante_Security_Headers( $this->settings );
9753 +
9754 + return array(
9755 + 'grade' => Vigilante_SA_Helpers::grade_for_score( $score ),
9756 + 'score' => $score,
9757 + 'passed' => $passed,
9758 + 'failing' => $failed,
9759 + 'notes' => array_merge( $notes, $sender->get_csp_warnings() ),
9760 + // For the script of a page opened before the update: see above.
9761 + 'headers' => array(),
9762 + 'missing' => array(),
9763 + 'warnings' => array(),
9764 + );
9244 9765 }
9245 9766
9246 9767 /**
9247 9768 * Sanitize section data (kept for compatibility)