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 +1626 -161 2.11.11 → 3.0.5 View file →
@@ -107,8 +107,11 @@
107 107 add_action( 'admin_init', array( $this, 'redirect_submenu_shortcuts' ) );
108 108 add_action( 'admin_init', array( $this, 'register_settings' ) );
109 109 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
110 110 add_action( 'admin_notices', array( $this, 'show_admin_notices' ) );
111 + // Self-protection speaks in the network admin too: on a network its
112 + // files are shared, and the super administrator is who can repair them.
113 + add_action( 'network_admin_notices', array( $this, 'maybe_show_self_protection_notice' ) );
111 114
112 115 // Highlight correct submenu based on active tab
113 116 add_filter( 'submenu_file', array( $this, 'highlight_submenu_tab' ) );
114 117
@@ -536,8 +539,68 @@
536 539 $this->migrate_pending_approval_per_site();
537 540
538 541 update_option( 'vigilante_db_version', '2.11.10' );
539 542 }
543 +
544 + /*
545 + * 3.0.0: self-protection package. This block must stay the LAST one of
546 + * run_migrations(): every block compares against the same $db_version
547 + * captured at the top, so the last update_option() that runs is the
548 + * one that sticks, and on a jump from any older version it has to be
549 + * this one.
550 + *
551 + * 1. Capture the self-integrity anchor (the manifest fingerprint) and
552 + * run an inline self-check. It covers every update the old code never
553 + * saw through the upgrader hook: from any 2.x, and manual or FTP
554 + * uploads. A plain new is enough, the constructor registers no hooks.
555 + * 2. Drop the cached Security Check report: the Internal category grows
556 + * from 30 to 33 points (the self_integrity check), and the cached
557 + * report would keep the old denominator until the next full scan.
558 + * Same reason and same background refresh as the 2.6.1 block above.
559 + * 3. Remove Vigilant's own files from the ignore list before that check.
560 + * Until 2.11.11 the generic scan listed them like any plugin, and any
561 + * administrator could ignore one (after a false alarm while
562 + * WordPress.org published new checksums, for example). Kept, those
563 + * entries would silence the self-check of those files for good,
564 + * PHP included.
565 + */
566 + if ( version_compare( $db_version, '3.0.0', '<' ) ) {
567 + if ( ! class_exists( 'Vigilante_Self_Integrity' ) ) {
568 + require_once VIGILANTE_INCLUDES_DIR . 'class-self-integrity.php';
569 + }
570 + $ignored_files = get_option( 'vigilante_ignored_files', array() );
571 + if ( is_array( $ignored_files ) && $ignored_files ) {
572 + $kept_files = array_values(
573 + array_filter(
574 + $ignored_files,
575 + function ( $ignored_file ) {
576 + return ! Vigilante_Self_Integrity::is_own_file_path( (string) $ignored_file );
577 + }
578 + )
579 + );
580 + if ( count( $kept_files ) !== count( $ignored_files ) ) {
581 + update_option( 'vigilante_ignored_files', $kept_files );
582 + if ( $this->activity_log ) {
583 + $this->activity_log->log(
584 + 'system',
585 + 'self_ignored_files_removed',
586 + __( 'Vigilant files were removed from the File Integrity ignore list: self-protection checks them now.', 'vigilante' ),
587 + array( 'removed' => count( $ignored_files ) - count( $kept_files ) ),
588 + 'info'
589 + );
590 + }
591 + }
592 + }
593 + $self_integrity = new Vigilante_Self_Integrity( $this->settings, $this->activity_log );
594 + $self_integrity->run_check( 'migration' );
595 +
596 + delete_option( 'vigilante_analyzer_last_scan' );
597 + if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
598 + wp_schedule_single_event( time() + 5, 'vigilante_under_attack_post_scan' );
599 + }
600 +
601 + update_option( 'vigilante_db_version', '3.0.0' );
602 + }
540 603 }
541 604
542 605 /**
543 606 * Move the pending-approval flag of a network to a key per site
@@ -724,17 +787,30 @@
724 787 $pending_count = $this->get_pending_approvals_count();
725 788
726 789 // Get security issues with severity
727 790 $security_status = $this->get_security_status_for_badge();
728 -
791 +
792 + /*
793 + * Self-protection: a change to Vigilant own files is the one finding
794 + * that has to be visible from any screen of WordPress, so it adds to
795 + * the counter and paints it red. A verified state, a state with fewer
796 + * references than usual and the check turned off add nothing: a counter
797 + * that is always on is a counter nobody reads.
798 + */
799 + $self_tone = Vigilante_Self_Integrity::tone(
800 + Vigilante_Self_Integrity::display_state(),
801 + Vigilante_Self_Integrity::is_on()
802 + );
803 + $self_badge = in_array( $self_tone, array( 'critical', 'off', 'warning' ), true ) ? 1 : 0;
804 +
729 805 // Total count for badge
730 - $total_badge = $pending_count + $security_status['count'];
731 -
806 + $total_badge = $pending_count + $security_status['count'] + $self_badge;
807 +
732 808 if ( $total_badge > 0 ) {
733 809 // Determine badge color:
734 810 // - Red (awaiting-mod): pending approvals OR critical modules disabled
735 811 // - Orange (update-plugins): only non-critical modules disabled
736 - if ( $pending_count > 0 || $security_status['has_critical'] ) {
812 + if ( $pending_count > 0 || $security_status['has_critical'] || in_array( $self_tone, array( 'critical', 'off' ), true ) ) {
737 813 $badge_class = 'awaiting-mod';
738 814 } else {
739 815 $badge_class = 'update-plugins vigilante-badge-warning';
740 816 }
@@ -776,13 +852,21 @@
776 852 'vigilante-activity-log',
777 853 array( $this, 'redirect_to_tab' )
778 854 );
779 855
780 - // File Integrity shortcut
856 + // File Integrity shortcut, with its own counter when the self-check has
857 + // something to say: that is the screen that explains it.
858 + $fi_menu_title = __( 'File Integrity', 'vigilante' );
859 + if ( $self_badge > 0 ) {
860 + $fi_menu_title .= sprintf(
861 + ' <span class="%s count-1"><span class="pending-count">1</span></span>',
862 + in_array( $self_tone, array( 'critical', 'off' ), true ) ? 'awaiting-mod' : 'update-plugins vigilante-badge-warning'
863 + );
864 + }
781 865 add_submenu_page(
782 866 'vigilante',
783 867 __( 'File Integrity', 'vigilante' ),
784 - __( 'File Integrity', 'vigilante' ),
868 + $fi_menu_title,
785 869 'manage_options',
786 870 'vigilante-file-integrity',
787 871 array( $this, 'redirect_to_tab' )
788 872 );
@@ -924,10 +1008,38 @@
924 1008 return count( $pending_users );
925 1009 }
926 1010
927 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 + /**
928 1032 * Calculate comprehensive security score (0-100)
929 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 + *
930 1042 * @param array $options Plugin options.
931 1043 * @return int Security score.
932 1044 */
933 1045 private function calculate_security_score( $options ) {
@@ -953,11 +1065,11 @@
953 1065 }
954 1066 }
955 1067
956 1068 // Firewall details (10 points)
1069 + $max_score += 10;
957 1070 if ( ! empty( $options['modules']['firewall'] ) ) {
958 1071 $firewall = $options['firewall'] ?? array();
959 - $max_score += 10;
960 1072
961 1073 $firewall_checks = array(
962 1074 'block_sql_injection',
963 1075 'block_xss_attacks',
@@ -975,18 +1087,18 @@
975 1087 $score += min( 10, $firewall_enabled * 2 );
976 1088 }
977 1089
978 1090 // Login security details (10 points)
1091 + $max_score += 10;
979 1092 if ( ! empty( $options['modules']['login_security'] ) ) {
980 1093 $login = $options['login_security'] ?? array();
981 - $max_score += 10;
982 1094
983 1095 // Max attempts configured
984 1096 if ( isset( $login['max_attempts'] ) && $login['max_attempts'] <= 5 ) {
985 1097 $score += 3;
986 1098 }
987 - // XML-RPC disabled
988 - if ( ! empty( $login['disable_xmlrpc'] ) ) {
1099 + // XML-RPC blocked, whole or its pingback methods
1100 + if ( $this->is_xmlrpc_protected( $options ) ) {
989 1101 $score += 3;
990 1102 }
991 1103 // 2FA enabled
992 1104 if ( ! empty( $login['two_factor']['enabled'] ) ) {
@@ -994,11 +1106,11 @@
994 1106 }
995 1107 }
996 1108
997 1109 // Security headers details (10 points)
1110 + $max_score += 10;
998 1111 if ( ! empty( $options['modules']['security_headers'] ) ) {
999 1112 $headers = $options['security_headers'] ?? array();
1000 - $max_score += 10;
1001 1113
1002 1114 if ( ! empty( $headers['x_frame_options'] ) ) {
1003 1115 $score += 2;
1004 1116 }
@@ -1013,11 +1125,11 @@
1013 1125 }
1014 1126 }
1015 1127
1016 1128 // User security details (10 points)
1129 + $max_score += 10;
1017 1130 if ( ! empty( $options['modules']['user_security'] ) ) {
1018 1131 $user = $options['user_security'] ?? array();
1019 - $max_score += 10;
1020 1132
1021 1133 if ( ! empty( $user['block_insecure_usernames'] ) ) {
1022 1134 $score += 3;
1023 1135 }
@@ -1031,14 +1143,14 @@
1031 1143 $score += 2;
1032 1144 }
1033 1145 }
1034 1146
1035 - // Audit alerts details (6 points) - only when Security Audit is on,
1036 - // because the alerting layer rides on top of the activity log. Leaving
1037 - // 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;
1038 1151 if ( ! empty( $options['modules']['activity_log'] ) ) {
1039 1152 $alerts = isset( $options['audit_alerts'] ) ? (array) $options['audit_alerts'] : array();
1040 - $max_score += 6;
1041 1153 if ( Vigilante_Audit_Alerts::immediate_is_active( $alerts ) ) {
1042 1154 $score += 3;
1043 1155 }
1044 1156 if ( Vigilante_Audit_Alerts::threshold_is_active( $alerts ) ) {
@@ -1045,8 +1157,31 @@
1045 1157 $score += 3;
1046 1158 }
1047 1159 }
1048 1160
1161 + /*
1162 + * Self-protection (15 points). Having it on is configuration, which is
1163 + * what this card measures; the state of the last check caps the card,
1164 + * because a plugin whose own files were changed is not well configured
1165 + * in any useful sense, whatever the rest of the settings say. The
1166 + * environment block below already mixes measured state into this score
1167 + * (WP_DEBUG, insecure usernames), so the card keeps its meaning.
1168 + */
1169 + $max_score += 15;
1170 + $self_enabled = Vigilante_Self_Integrity::is_on();
1171 + $self_tone = Vigilante_Self_Integrity::tone( Vigilante_Self_Integrity::display_state(), $self_enabled );
1172 + if ( $self_enabled ) {
1173 + $score += 10;
1174 + // The five points are for a check that ran and came out clean: a
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 ) ) {
1180 + $score += 5;
1181 + }
1182 + }
1183 +
1049 1184 // Environment checks (8 points) - penalize insecure server configuration
1050 1185 $max_score += 8;
1051 1186 $env_score = 8;
1052 1187
@@ -1062,12 +1197,33 @@
1062 1197 }
1063 1198
1064 1199 $score += max( 0, $env_score );
1065 1200
1066 - return $max_score > 0 ? round( ( $score / $max_score ) * 100 ) : 0;
1201 + $percent = $max_score > 0 ? (int) round( ( $score / $max_score ) * 100 ) : 0;
1202 + if ( in_array( $self_tone, array( 'critical', 'off' ), true ) ) {
1203 + // Same cap and same reason as the Security Check score.
1204 + $percent = min( $percent, Vigilante_Security_Analyzer::SCORE_CAP_ON_TAMPER );
1205 + }
1206 + return $percent;
1067 1207 }
1068 1208
1069 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 + /**
1070 1226 * Get security recommendations based on current settings
1071 1227 *
1072 1228 * @param array $options Plugin options.
1073 1229 * @return array Array of recommendations.
@@ -1074,8 +1230,46 @@
1074 1230 */
1075 1231 private function get_security_recommendations( $options ) {
1076 1232 $recommendations = array();
1077 1233
1234 + // Self-protection first: if Vigilant itself cannot be trusted, nothing
1235 + // else on this card means much.
1236 + $vg_self_enabled = Vigilante_Self_Integrity::is_on();
1237 + $vg_self_tone = Vigilante_Self_Integrity::tone( Vigilante_Self_Integrity::display_state(), $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 ) {
1241 + $recommendations[] = array(
1242 + 'icon' => 'warning',
1243 + 'priority' => 'critical',
1244 + 'tab' => 'file-integrity',
1245 + 'message' => __( 'Something on this site switched Vigilant self-protection off: File Integrity says which file does it.', 'vigilante' ),
1246 + );
1247 + } elseif ( 'critical' === $vg_self_tone ) {
1248 + $recommendations[] = array(
1249 + 'icon' => 'warning',
1250 + 'priority' => 'critical',
1251 + 'tab' => 'file-integrity',
1252 + 'message' => $this->self_critical_recommendation( Vigilante_Self_Integrity::alarm_kind( Vigilante_Self_Integrity::display_state(), $vg_self_enabled ) ),
1253 + );
1254 + } elseif ( 'warning' === $vg_self_tone ) {
1255 + $recommendations[] = array(
1256 + 'icon' => 'shield',
1257 + 'priority' => 'high',
1258 + 'tab' => 'file-integrity',
1259 + 'message' => __( 'Vigilant self-protection needs your attention: File Integrity says what it found and what to do.', 'vigilante' ),
1260 + );
1261 + } elseif ( 'none' === $vg_self_tone ) {
1262 + // The score holds back the points of a check that has not run yet,
1263 + // so the card has to say why instead of just showing a lower number.
1264 + $recommendations[] = array(
1265 + 'icon' => 'shield',
1266 + 'priority' => 'high',
1267 + 'tab' => 'file-integrity',
1268 + 'message' => __( 'Vigilant has not checked its own files yet: run a scan from File Integrity.', 'vigilante' ),
1269 + );
1270 + }
1271 +
1078 1272 // Critical: Firewall disabled
1079 1273 if ( empty( $options['modules']['firewall'] ) ) {
1080 1274 $recommendations[] = array(
1081 1275 'icon' => 'warning',
@@ -1158,14 +1352,14 @@
1158 1352 );
1159 1353 }
1160 1354
1161 1355 // Low: XML-RPC enabled (only if login security is active)
1162 - if ( ! empty( $options['modules']['login_security'] ) && empty( $login['disable_xmlrpc'] ) ) {
1356 + if ( ! empty( $options['modules']['login_security'] ) && ! $this->is_xmlrpc_protected( $options ) ) {
1163 1357 $recommendations[] = array(
1164 1358 'icon' => 'info',
1165 1359 'priority' => 'low',
1166 1360 'message' => __( 'Disable XML-RPC if not needed (reduces attack surface).', 'vigilante' ),
1167 - 'tab' => 'login',
1361 + 'tab' => 'wp-hardening',
1168 1362 );
1169 1363 }
1170 1364
1171 1365 // Low: Strong passwords not enforced (only if user security is active)
@@ -1478,8 +1672,9 @@
1478 1672 // File Integrity
1479 1673 array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'File Integrity Monitoring', 'vigilante' ), 'anchor' => 'vigilante-section-fi-monitoring', 'label' => __( 'File Integrity Monitoring', 'vigilante' ), 'label_en' => 'File Integrity Monitoring', 'keywords' => _x( 'file integrity monitoring files checksum checksums tamper', 'settings search keywords', 'vigilante' ) ),
1480 1674 array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'File Integrity Monitoring', 'vigilante' ), 'anchor' => 'vigilante-section-fi-monitoring', 'label' => __( 'Scan schedule', 'vigilante' ), 'label_en' => 'Scan schedule', 'keywords' => _x( 'scan schedule scans scanning check cron', 'settings search keywords', 'vigilante' ) ),
1481 1675 array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'File Integrity Monitoring', 'vigilante' ), 'anchor' => 'vigilante-section-fi-monitoring', 'label' => __( 'Instant alert', 'vigilante' ), 'label_en' => 'Instant alert', 'keywords' => _x( 'instant alert alerts notification warning email', 'settings search keywords', 'vigilante' ) ),
1676 + array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'Vigilant self-protection', 'vigilante' ), 'anchor' => 'vigilante-section-fi-self', 'label' => __( 'Vigilant self-protection', 'vigilante' ), 'label_en' => 'Vigilant self-protection', 'keywords' => _x( 'self protection selfprotection self-check autoproteccion manifest sha256 checksums tampering tampered repair reinstall own files guardian integrity of the plugin', 'settings search keywords', 'vigilante' ) ),
1482 1677 array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'Ignored Files', 'vigilante' ), 'anchor' => 'vigilante-section-fi-ignored', 'label' => __( 'Ignored Files', 'vigilante' ), 'label_en' => 'Ignored Files', 'keywords' => _x( 'ignored files file exclude', 'settings search keywords', 'vigilante' ) ),
1483 1678 // Security Audit
1484 1679 array( 'tab' => 'activity-log', 'tab_label' => __( 'Security Audit', 'vigilante' ), 'section' => __( 'Security Audit Settings', 'vigilante' ), 'anchor' => 'vigilante-section-audit-settings', 'label' => __( 'Retention', 'vigilante' ), 'label_en' => 'Retention', 'keywords' => _x( 'retention keep days storage log', 'settings search keywords', 'vigilante' ) ),
1485 1680 array( 'tab' => 'activity-log', 'tab_label' => __( 'Security Audit', 'vigilante' ), 'section' => __( 'Security Audit Settings', 'vigilante' ), 'anchor' => 'vigilante-section-audit-settings', 'label' => __( 'Events to Log', 'vigilante' ), 'label_en' => 'Events to Log', 'keywords' => _x( 'events to log', 'settings search keywords', 'vigilante' ) ),
@@ -1632,8 +1827,9 @@
1632 1827 );
1633 1828
1634 1829 wp_localize_script( 'vigilante-admin', 'vigilanteAdmin', array(
1635 1830 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
1831 + 'selfBoxUrl' => esc_url( admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' ) ),
1636 1832 'nonce' => wp_create_nonce( 'vigilante_admin_nonce' ),
1637 1833 'currentUserId' => get_current_user_id(),
1638 1834 'logoutUrl' => wp_logout_url( wp_login_url() ),
1639 1835 'adminUrl' => admin_url( 'admin.php?page=vigilante' ),
@@ -1655,9 +1851,8 @@
1655 1851 'scanComplete' => __( 'Scan complete', 'vigilante' ),
1656 1852 'loading' => __( 'Loading...', 'vigilante' ),
1657 1853 'searching' => __( 'Searching...', 'vigilante' ),
1658 1854 'noUsersFound' => __( 'No users found', 'vigilante' ),
1659 - 'searchError' => __( 'Error searching users', 'vigilante' ),
1660 1855 'sending' => __( 'Sending...', 'vigilante' ),
1661 1856 'sendNotification' => __( 'Send notification now', 'vigilante' ),
1662 1857 'notificationsSent' => __( 'notifications sent', 'vigilante' ),
1663 1858 'skipped' => __( 'skipped', 'vigilante' ),
@@ -1666,10 +1861,11 @@
1666 1861 // Header tester strings
1667 1862 'testHeaders' => __( 'Test Headers', 'vigilante' ),
1668 1863 'testing' => __( 'Testing...', 'vigilante' ),
1669 1864 'score' => __( 'Score', 'vigilante' ),
1670 - 'enabledHeaders' => __( 'Enabled headers', 'vigilante' ),
1671 - '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' ),
1672 1868 'warnings' => __( 'Warnings', 'vigilante' ),
1673 1869 // File integrity scan results strings
1674 1870 'scanResults' => __( 'Scan Results', 'vigilante' ),
1675 1871 'ok' => __( 'OK', 'vigilante' ),
@@ -1676,19 +1872,24 @@
1676 1872 'modified' => __( 'Modified', 'vigilante' ),
1677 1873 'suspicious' => __( 'Suspicious', 'vigilante' ),
1678 1874 'totalScanned' => __( 'Total Scanned', 'vigilante' ),
1679 1875 'suspiciousFiles' => __( 'Suspicious Files', 'vigilante' ),
1680 - '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' ),
1681 1877 'file' => __( 'File', 'vigilante' ),
1682 1878 'reason' => __( 'Reason', 'vigilante' ),
1683 1879 'type' => __( 'Type', 'vigilante' ),
1684 1880 'unknown' => __( 'Unknown', 'vigilante' ),
1881 + 'selfType' => __( 'Vigilant (self)', 'vigilante' ),
1882 + 'logWhatHappened' => __( 'What happened', 'vigilante' ),
1883 + 'logWhatItMeans' => __( 'What it means', 'vigilante' ),
1884 + 'logWhatToDo' => __( 'What to do', 'vigilante' ),
1885 + 'logSelfSeeDetails' => __( 'Open File Integrity for the full detail', 'vigilante' ),
1685 1886 'modifiedFiles' => __( 'Modified Files', 'vigilante' ),
1686 - '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' ),
1687 1888 'extraFiles' => __( 'Extra Files', 'vigilante' ),
1688 1889 'extra' => __( 'Extra', 'vigilante' ),
1689 1890 'ignored' => __( 'Ignored', 'vigilante' ),
1690 - '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' ),
1691 1892 'actions' => __( 'Actions', 'vigilante' ),
1692 1893 'ignore' => __( 'Ignore', 'vigilante' ),
1693 1894 'ignoring' => __( 'Ignoring...', 'vigilante' ),
1694 1895 'fileIgnored' => __( 'File added to ignored list.', 'vigilante' ),
@@ -1696,9 +1897,8 @@
1696 1897 'confirmClearIgnored' => __( 'Remove all files from the ignored list? They will appear in scan results again.', 'vigilante' ),
1697 1898 'ignoredCleared' => __( 'Ignored files list cleared. Page will reload...', 'vigilante' ),
1698 1899 'selectAll' => __( 'Select all', 'vigilante' ),
1699 1900 'bulkIgnoreSelected' => __( 'Ignore selected', 'vigilante' ),
1700 - 'bulkUnignoreSelected'=> __( 'Stop ignoring selected', 'vigilante' ),
1701 1901 'bulkNoSelection' => __( 'Select at least one file first.', 'vigilante' ),
1702 1902 'bulkConfirmIgnore' => __( 'Ignore the selected files? They will be hidden from future scan results until you remove them from the ignored list.', 'vigilante' ),
1703 1903 'bulkConfirmUnignore' => __( 'Remove the selected files from the ignored list? They will appear in scan results again.', 'vigilante' ),
1704 1904 'bulkProcessing' => __( 'Processing...', 'vigilante' ),
@@ -1703,11 +1903,11 @@
1703 1903 'bulkConfirmUnignore' => __( 'Remove the selected files from the ignored list? They will appear in scan results again.', 'vigilante' ),
1704 1904 'bulkProcessing' => __( 'Processing...', 'vigilante' ),
1705 1905 /* translators: %d: number of files selected for bulk action. */
1706 1906 'bulkSelectedCount' => __( '%d selected', 'vigilante' ),
1707 - 'allClear' => __( 'All files verified - no issues found!', 'vigilante' ),
1907 + 'allClear' => __( 'Good job! Nothing to report in the files that were checked.', 'vigilante' ),
1708 1908 'criticalConfigTitle' => __( 'Critical config files modified', 'vigilante' ),
1709 - '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' ),
1710 1910 'approve' => __( 'Approve', 'vigilante' ),
1711 1911 'approving' => __( 'Approving...', 'vigilante' ),
1712 1912 'approvalLockedNotice' => $this->critical_approval_notice(),
1713 1913 'criticalApproved' => __( 'Change approved. Next scan will use the current state as baseline.', 'vigilante' ),
@@ -1800,13 +2000,10 @@
1800 2000 'noLogsToExport' => __( 'No logs to export', 'vigilante' ),
1801 2001 'exportFailed' => __( 'Export failed', 'vigilante' ),
1802 2002 'exportLogs' => __( 'Export Logs', 'vigilante' ),
1803 2003 // Backup strings
1804 - 'backupCreated' => __( 'Backup created successfully.', 'vigilante' ),
1805 2004 'createBackupNow' => __( 'Download Backup', 'vigilante' ),
1806 2005 'downloadBackup' => __( 'Download Backup (.zip)', 'vigilante' ),
1807 - /* translators: %d: number of tables */
1808 - 'tablesCount' => __( '%d tables', 'vigilante' ),
1809 2006 /* translators: 1: table count, 2: human-readable size */
1810 2007 'dbTablesTotal' => __( '%1$d tables total (%2$s)', 'vigilante' ),
1811 2008 /* translators: 1: selected count, 2: human-readable size */
1812 2009 'dbTablesSelected' => __( '%1$d tables selected (%2$s)', 'vigilante' ),
@@ -1813,15 +2010,12 @@
1813 2010 // Settings search strings
1814 2011 'searchNoResults' => __( 'No matching settings found.', 'vigilante' ),
1815 2012 /* translators: %d: number of results that did not fit in the list. */
1816 2013 'searchMoreResults' => __( '%d more results. Refine the search to see them.', 'vigilante' ),
1817 - 'searchInTab' => __( 'in', 'vigilante' ),
1818 - // Modules string
1819 - /* translators: 1: enabled count, 2: total count */
1820 - 'modulesEnabled' => __( '%1$d / %2$d modules enabled', 'vigilante' ),
1821 2014 // Activity log label maps for JS rendering
1822 2015 'eventTypeLabels' => array(
1823 2016 'login' => __( 'Login', 'vigilante' ),
2017 + '2fa' => __( 'Two-factor', 'vigilante' ),
1824 2018 'user' => __( 'User', 'vigilante' ),
1825 2019 'content' => __( 'Content', 'vigilante' ),
1826 2020 'plugin' => __( 'Plugin', 'vigilante' ),
1827 2021 'theme' => __( 'Theme', 'vigilante' ),
@@ -1887,10 +2081,8 @@
1887 2081 /* translators: 1: first item number, 2: last item number, 3: total items */
1888 2082 'paginationOf' => __( '%1$d–%2$d of %3$d', 'vigilante' ),
1889 2083 'paginationEmpty' => __( '0 items', 'vigilante' ),
1890 2084 // Security Analyzer strings
1891 - 'analyzerScanNow' => __( 'Scan now', 'vigilante' ),
1892 - 'analyzerScanning' => __( 'Scanning…', 'vigilante' ),
1893 2085 'analyzerFastPhase' => __( 'Running fast checks…', 'vigilante' ),
1894 2086 'analyzerSlowPhase' => __( 'Running remote checks…', 'vigilante' ),
1895 2087 'analyzerScanComplete' => __( 'Security scan complete.', 'vigilante' ),
1896 2088 'analyzerScanFailed' => __( 'Security scan failed.', 'vigilante' ),
@@ -1896,11 +2088,8 @@
1896 2088 'analyzerScanFailed' => __( 'Security scan failed.', 'vigilante' ),
1897 2089 'analyzerShowDetails' => __( 'Show detailed breakdown', 'vigilante' ),
1898 2090 'analyzerHideDetails' => __( 'Hide detailed breakdown', 'vigilante' ),
1899 2091 'analyzerGoToSetting' => __( 'Go to setting', 'vigilante' ),
1900 - 'analyzerNoData' => __( 'No data yet — run a scan to populate this category.', 'vigilante' ),
1901 - 'analyzerJustNow' => __( 'just now', 'vigilante' ),
1902 - 'analyzerAgo' => __( 'ago', 'vigilante' ),
1903 2092 'analyzerSettingsSaved' => __( 'Analyzer settings saved.', 'vigilante' ),
1904 2093 'analyzerLastScanJustNow' => __( 'Last scan just now', 'vigilante' ),
1905 2094 'analyzerQualityExcellent' => __( 'Excellent', 'vigilante' ),
1906 2095 'analyzerQualityGood' => __( 'Good', 'vigilante' ),
@@ -1906,9 +2095,9 @@
1906 2095 'analyzerQualityGood' => __( 'Good', 'vigilante' ),
1907 2096 'analyzerQualityFair' => __( 'Fair', 'vigilante' ),
1908 2097 'analyzerQualityPoor' => __( 'Poor', 'vigilante' ),
1909 2098 'analyzerQualityCritical' => __( 'Critical', 'vigilante' ),
1910 - 'analyzerPts' => __( 'pts', 'vigilante' ),
2099 + 'analyzerNotMeasured' => __( 'Not measured', 'vigilante' ),
1911 2100 'analyzerLearnMore' => __( 'Learn more', 'vigilante' ),
1912 2101 'analyzerInfoAllClear' => __( 'All clear', 'vigilante' ),
1913 2102 /* translators: %d: number of findings in an info-only category */
1914 2103 'analyzerInfoFindings' => __( '%d findings', 'vigilante' ),
@@ -1934,9 +2123,97 @@
1934 2123
1935 2124 /**
1936 2125 * Show admin notices
1937 2126 */
2127 + /**
2128 + * Self-protection notice.
2129 + *
2130 + * A change to Vigilant own files is shown on every admin screen, because
2131 + * waiting for someone to open the plugin is exactly what an attacker who
2132 + * patched it would want. A warning is shown only on Vigilant screens, so
2133 + * the notice that matters is not diluted. Neither is dismissible, both are
2134 + * for administrators only, and on a network the steps depend on whether
2135 + * the person can install plugins at all.
2136 + */
2137 + public function maybe_show_self_protection_notice() {
2138 + if ( ! current_user_can( 'manage_options' ) ) {
2139 + return;
2140 + }
2141 + $summary = $this->self_integrity_summary();
2142 + $tone = $summary['tone'];
2143 + if ( ! in_array( $tone, array( 'critical', 'off', 'warning' ), true ) ) {
2144 + return;
2145 + }
2146 +
2147 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the screen slug to decide where a notice is shown; no action taken.
2148 + $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
2149 + $on_vigilante = ( 0 === strpos( $page, 'vigilante' ) );
2150 + if ( 'warning' === $tone && ! $on_vigilante ) {
2151 + return;
2152 + }
2153 + $alarma = in_array( $tone, array( 'critical', 'off' ), true );
2154 +
2155 + $link = admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' );
2156 + $network = is_multisite() && ! current_user_can( 'update_plugins' );
2157 + ?>
2158 + <div class="notice notice-<?php echo $alarma ? 'error' : 'warning'; ?> vigilante-self-notice">
2159 + <p class="vigilante-self-notice-title">
2160 + <span class="dashicons dashicons-shield" aria-hidden="true"></span>
2161 + <strong>
2162 + <?php
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 ) {
2166 + esc_html_e( 'Vigilant detected changes in its own files', 'vigilante' );
2167 + } elseif ( 'off' === $tone ) {
2168 + esc_html_e( 'Vigilant self-protection is switched off by code', 'vigilante' );
2169 + } else {
2170 + echo esc_html( $this->self_integrity_headline( $tone, $summary['state'] ) );
2171 + }
2172 + ?>
2173 + </strong>
2174 + </p>
2175 + <p class="vigilante-self-notice-text">
2176 + <span>
2177 + <?php
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 ) {
2183 + esc_html_e( 'Your security plugin may have been tampered with, and while that is true nothing it reports can be trusted.', 'vigilante' );
2184 + } elseif ( 'off' === $tone ) {
2185 + esc_html_e( 'Nothing is checking that Vigilant own files are intact. File Integrity says which file switches it off.', 'vigilante' );
2186 + } else {
2187 + esc_html_e( 'File Integrity says what was found, what it means and what to do about it.', 'vigilante' );
2188 + }
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'] ) ) {
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' );
2193 + }
2194 + ?>
2195 + </span>
2196 + <?php
2197 + /*
2198 + * One button, on the same line as the text it belongs to, and it
2199 + * leads to the whole story. Repairing from a notice, without
2200 + * seeing which files, what it means and what it will do, is
2201 + * asking someone to fix what they have not read: the repair
2202 + * button lives in the card, after all that.
2203 + */
2204 + ?>
2205 + <a class="button button-primary button-small" href="<?php echo esc_url( $link ); ?>">
2206 + <?php esc_html_e( 'See what to do', 'vigilante' ); ?>
2207 + </a>
2208 + </p>
2209 + </div>
2210 + <?php
2211 + }
2212 +
1938 2213 public function show_admin_notices() {
2214 + $this->maybe_show_self_protection_notice();
2215 +
1939 2216 // Activation notice
1940 2217 if ( get_transient( 'vigilante_activated' ) ) {
1941 2218 ?>
1942 2219 <div class="notice notice-success is-dismissible vigilante-activation-notice">
@@ -2414,10 +2691,10 @@
2414 2691
2415 2692 /**
2416 2693 * Render the Security Analyzer (Security Check) widget + expandable full report.
2417 2694 *
2418 - * Lives in the Dashboard tab between the Configuration Score status card and
2419 - * 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
2420 2697 * first paint shows real data; "Scan now" runs the 2-phase AJAX to refresh.
2421 2698 *
2422 2699 * @param array $last_scan Result of Vigilante_Security_Analyzer::get_last_scan().
2423 2700 * @param array $history Score history (oldest first).
@@ -2439,9 +2716,43 @@
2439 2716 $categories = isset( $last_scan['categories'] ) && is_array( $last_scan['categories'] ) ? $last_scan['categories'] : array();
2440 2717 $weekly_enabled = ! isset( $analyzer_settings['weekly_scan_enabled'] ) || ! empty( $analyzer_settings['weekly_scan_enabled'] );
2441 2718 $email_enabled = ! empty( $analyzer_settings['email_on_regression'] );
2442 2719
2443 - $quality = self::analyzer_quality_tag( $score );
2720 + /*
2721 + * Self-protection caps the score, and it does so here and not only in
2722 + * the stored report: the report is reused until the next Security
2723 + * Check, so tampering found after it ran would otherwise be shown next
2724 + * to the score the site earned before it happened.
2725 + */
2726 + $self_summary = $this->self_integrity_summary();
2727 + $self_capped = in_array( $self_summary['tone'], array( 'critical', 'off' ), true )
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 + }
2746 + if ( $self_capped && $has_data && $score > Vigilante_Security_Analyzer::SCORE_CAP_ON_TAMPER ) {
2747 + $score = Vigilante_Security_Analyzer::SCORE_CAP_ON_TAMPER;
2748 + }
2749 + if ( $has_data && '' !== $grade ) {
2750 + $grade = Vigilante_SA_Helpers::grade_for_score( $score );
2751 + }
2752 +
2753 + $quality = self::analyzer_quality_tag( $score );
2754 + $not_measured = isset( $counts['skip'] ) ? (int) $counts['skip'] : 0;
2444 2755 ?>
2445 2756 <div class="vigilante-analyzer" id="vigilante-analyzer"
2446 2757 data-has-data="<?php echo $has_data ? '1' : '0'; ?>">
2447 2758 <div class="vigilante-analyzer-header">
@@ -2450,9 +2761,9 @@
2450 2761 <span class="dashicons dashicons-shield-alt" aria-hidden="true"></span>
2451 2762 <?php esc_html_e( 'Security Check', 'vigilante' ); ?>
2452 2763 </h2>
2453 2764 <p class="description">
2454 - <?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' ); ?>
2455 2766 </p>
2456 2767 <p class="vigilante-analyzer-last-scan" data-role="ran-at">
2457 2768 <span class="dashicons dashicons-clock" aria-hidden="true"></span>
2458 2769 <?php echo esc_html( $ran_at_human ); ?>
@@ -2465,8 +2776,29 @@
2465 2776 </button>
2466 2777 </div>
2467 2778 </div>
2468 2779
2780 + <?php if ( $self_capped ) : ?>
2781 + <p class="vigilante-analyzer-capped" data-why="<?php echo esc_attr( $self_capped_why ); ?>">
2782 + <span class="dashicons dashicons-shield" aria-hidden="true"></span>
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 + ?>
2795 + <a href="<?php echo esc_url( admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' ) ); ?>">
2796 + <?php esc_html_e( 'See what to do', 'vigilante' ); ?>
2797 + </a>
2798 + </p>
2799 + <?php endif; ?>
2800 +
2469 2801 <div class="vigilante-analyzer-summary">
2470 2802 <div class="vigilante-analyzer-score-card">
2471 2803 <?php if ( $has_data && $grade ) : ?>
2472 2804 <div class="vigilante-score-circle vigilante-grade-<?php echo esc_attr( strtolower( $grade ) ); ?>">
@@ -2505,8 +2837,20 @@
2505 2837 <span class="dashicons dashicons-dismiss" aria-hidden="true"></span>
2506 2838 <strong data-role="fail"><?php echo esc_html( isset( $counts['fail'] ) ? $counts['fail'] : 0 ); ?></strong>
2507 2839 <span class="vigilante-analyzer-count-label"><?php esc_html_e( 'Failing', 'vigilante' ); ?></span>
2508 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>
2509 2853 </div>
2510 2854
2511 2855 <?php
2512 2856 // Sparkline of recent scores. Require at least 3 data points so the trend
@@ -2584,18 +2928,29 @@
2584 2928 <div class="vigilante-analyzer-categories" data-role="categories">
2585 2929 <?php foreach ( $categories_def as $slug => $meta ) :
2586 2930 $cat = isset( $categories[ $slug ] ) ? $categories[ $slug ] : array();
2587 2931 $earned = isset( $cat['earned'] ) ? (int) $cat['earned'] : 0;
2588 - // Always use the declared meta as the source of truth for the maximum.
2589 - // The cached scan may carry an old max if a check was added/removed
2590 - // between releases (see 2.6.1: closed_plugins raised internal from 22 to 28
2591 - // but cached scans still reported max=22 until reset).
2592 - $cat_max = (int) $meta['max'];
2932 + $checks = isset( $cat['checks'] ) ? (array) $cat['checks'] : array();
2593 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'];
2594 2942 $cat_pct = $cat_max > 0 ? (int) round( ( $earned / $cat_max ) * 100 ) : 0;
2595 - $checks = isset( $cat['checks'] ) ? (array) $cat['checks'] : array();
2596 2943 $cat_counts = isset( $cat['counts'] ) && is_array( $cat['counts'] ) ? $cat['counts'] : array( 'pass' => 0, 'warn' => 0, 'fail' => 0, 'info' => 0 );
2597 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 + }
2598 2953 $info_count = isset( $cat_counts['info'] ) ? (int) $cat_counts['info'] : 0;
2599 2954 ?>
2600 2955 <details class="vigilante-analyzer-category<?php echo $info_only ? ' vigilante-analyzer-category--info' : ''; ?>"
2601 2956 data-category="<?php echo esc_attr( $slug ); ?>"
@@ -2644,9 +2999,9 @@
2644 2999 </span>
2645 3000 <?php endif; ?>
2646 3001 <?php if ( ! $info_only ) : ?>
2647 3002 <span class="vigilante-analyzer-category-score">
2648 - <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>
2649 3004 <span class="vigilante-analyzer-category-score-unit"><?php esc_html_e( 'pts', 'vigilante' ); ?></span>
2650 3005 </span>
2651 3006 <span class="vigilante-analyzer-category-bar" aria-hidden="true">
2652 3007 <span class="vigilante-analyzer-category-bar-fill vigilante-analyzer-category-bar-fill--<?php echo esc_attr( $cat_quality['slug'] ); ?>"
@@ -2723,23 +3078,27 @@
2723 3078 * @param int $pct 0..100.
2724 3079 * @return array{label:string,slug:string}
2725 3080 */
2726 3081 public static function analyzer_quality_tag( $pct ) {
2727 - $pct = max( 0, min( 100, (int) $pct ) );
2728 - // "Excellent" is reserved for a perfect score — a single missing point drops to Good.
2729 - if ( 100 === $pct ) {
2730 - 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';
2731 3084 }
2732 - if ( $pct >= 70 ) {
2733 - return array( 'label' => __( 'Good', 'vigilante' ), 'slug' => 'b' );
2734 - }
2735 - if ( $pct >= 50 ) {
2736 - return array( 'label' => __( 'Fair', 'vigilante' ), 'slug' => 'c' );
2737 - }
2738 - if ( $pct >= 30 ) {
2739 - return array( 'label' => __( 'Poor', 'vigilante' ), 'slug' => 'd' );
2740 - }
2741 - 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 + );
2742 3101 }
2743 3102
2744 3103 /**
2745 3104 * Render a single analyzer check row (used both server-side and via JS template).
@@ -2840,53 +3199,43 @@
2840 3199
2841 3200 /**
2842 3201 * Render dashboard tab
2843 3202 */
2844 - private function render_tab_dashboard() {
2845 - $options = $this->settings->get_all_options();
2846 - $module_labels = $this->settings->get_module_labels();
2847 - $module_descriptions = $this->settings->get_module_descriptions();
2848 - $presets = $this->settings->get_presets();
2849 - $active_preset = get_option( 'vigilante_active_preset', '' );
2850 -
2851 - // Calculate security score with more factors
2852 - $security_score = $this->calculate_security_score( $options );
2853 -
2854 - // Security Analyzer (v2.1.0) — hydrate widget with the last persisted scan, if any.
2855 - if ( ! class_exists( 'Vigilante_Security_Analyzer' ) ) {
2856 - require_once VIGILANTE_INCLUDES_DIR . 'class-security-analyzer.php';
2857 - }
2858 - $analyzer_instance = new Vigilante_Security_Analyzer( $this->settings, $this->activity_log );
2859 - $analyzer_last_scan = $analyzer_instance->get_last_scan();
2860 - $analyzer_history = $analyzer_instance->get_score_history();
2861 - $analyzer_categories_def = Vigilante_Security_Analyzer::get_categories();
2862 - $analyzer_settings = isset( $options['security_analyzer'] ) ? $options['security_analyzer'] : array();
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 ) );
2863 3225 ?>
2864 - <div class="vigilante-dashboard">
2865 - <div class="vigilante-status-card">
2866 - <h2><?php esc_html_e( 'Configuration Score', 'vigilante' ); ?></h2>
3226 + <div class="vigilante-status-card vigilante-configuration-card">
3227 + <h2><?php esc_html_e( 'Vigilant configuration', 'vigilante' ); ?></h2>
2867 3228 <p class="vigilante-score-kind description">
2868 - <?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' ); ?>
3229 + <?php esc_html_e( 'How much of what Vigilant offers is switched on here.', 'vigilante' ); ?>
2869 3230 </p>
2870 - <div class="vigilante-security-score">
2871 - <?php
2872 - // Grade thresholds: A (90+), B (70-89), C (50-69), D (30-49), E (0-29)
2873 - if ( $security_score >= 90 ) {
2874 - $grade = 'A';
2875 - } elseif ( $security_score >= 70 ) {
2876 - $grade = 'B';
2877 - } elseif ( $security_score >= 50 ) {
2878 - $grade = 'C';
2879 - } elseif ( $security_score >= 30 ) {
2880 - $grade = 'D';
2881 - } else {
2882 - $grade = 'E';
2883 - }
2884 - ?>
2885 - <div class="vigilante-score-circle vigilante-grade-<?php echo esc_attr( strtolower( $grade ) ); ?>">
2886 - <span class="vigilante-grade"><?php echo esc_html( $grade ); ?></span>
2887 - <span class="vigilante-score-text"><?php echo esc_html( $security_score ); ?>%</span>
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>
2888 3236 </div>
3237 + <strong class="vigilante-configuration-percent"><?php echo esc_html( $level ); ?>%</strong>
2889 3238 <div class="vigilante-config-status">
2890 3239 <?php if ( $active_preset && isset( $presets[ $active_preset ] ) ) : ?>
2891 3240 <span class="vigilante-preset-badge vigilante-preset-<?php echo esc_attr( $active_preset ); ?>">
2892 3241 <?php echo esc_html( $presets[ $active_preset ]['name'] ); ?>
@@ -2897,9 +3246,21 @@
2897 3246 </span>
2898 3247 <?php endif; ?>
2899 3248 </div>
2900 3249 </div>
2901 -
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 +
2902 3263 <?php
2903 3264 $recommendations = $this->get_security_recommendations( $options );
2904 3265 if ( ! empty( $recommendations ) ) :
2905 3266 ?>
@@ -2918,11 +3279,87 @@
2918 3279 </ul>
2919 3280 </div>
2920 3281 <?php endif; ?>
2921 3282 </div>
3283 + <?php
3284 + }
2922 3285
3286 + /**
3287 + * One line strip at the top of the Dashboard tab: the state of Vigilant own
3288 + * files, above the Security Check and the configuration card, because
3289 + * nothing else on this screen means much while it is red.
3290 + */
3291 + private function render_self_protection_strip() {
3292 + $summary = $this->self_integrity_summary();
3293 + $tone = $summary['tone'];
3294 + $state = $summary['state'];
3295 + $count = count( $summary['findings'] );
3296 + $link = admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' );
3297 + ?>
3298 + <div class="vigilante-self-strip vigilante-self-strip--<?php echo esc_attr( $tone ); ?>">
3299 + <span class="dashicons dashicons-shield" aria-hidden="true"></span>
3300 + <strong><?php esc_html_e( 'Vigilant self-protection', 'vigilante' ); ?></strong>
3301 + <span class="vigilante-self-strip-state"><?php echo esc_html( $this->self_integrity_headline( $tone, $state ) ); ?></span>
3302 + <?php if ( $count > 0 ) : ?>
3303 + <span class="vigilante-self-strip-count">
3304 + <?php
3305 + printf(
3306 + /* translators: %d: number of findings about Vigilant own files */
3307 + esc_html( _n( '%d finding', '%d findings', $count, 'vigilante' ) ),
3308 + (int) $count
3309 + );
3310 + ?>
3311 + </span>
3312 + <?php elseif ( ! empty( $state['last_check'] ) && 'off' !== $tone ) : ?>
3313 + <span class="vigilante-self-strip-count">
3314 + <?php
3315 + printf(
3316 + /* translators: %s: human time difference, like "2 hours" */
3317 + esc_html__( 'checked %s ago', 'vigilante' ),
3318 + esc_html( human_time_diff( (int) $state['last_check'], time() ) )
3319 + );
3320 + ?>
3321 + </span>
3322 + <?php endif; ?>
3323 + <a href="<?php echo esc_url( $link ); ?>">
3324 + <?php
3325 + if ( in_array( $tone, array( 'critical', 'off', 'warning' ), true ) ) {
3326 + esc_html_e( 'See what to do', 'vigilante' );
3327 + } else {
3328 + esc_html_e( 'See details', 'vigilante' );
3329 + }
3330 + ?>
3331 + </a>
3332 + </div>
3333 + <?php
3334 + }
3335 +
3336 + private function render_tab_dashboard() {
3337 + $options = $this->settings->get_all_options();
3338 + $module_labels = $this->settings->get_module_labels();
3339 + $module_descriptions = $this->settings->get_module_descriptions();
3340 + $presets = $this->settings->get_presets();
3341 + $active_preset = get_option( 'vigilante_active_preset', '' );
3342 +
3343 + // Calculate security score with more factors
3344 + $security_score = $this->calculate_security_score( $options );
3345 +
3346 + // Security Analyzer (v2.1.0) — hydrate widget with the last persisted scan, if any.
3347 + if ( ! class_exists( 'Vigilante_Security_Analyzer' ) ) {
3348 + require_once VIGILANTE_INCLUDES_DIR . 'class-security-analyzer.php';
3349 + }
3350 + $analyzer_instance = new Vigilante_Security_Analyzer( $this->settings, $this->activity_log );
3351 + $analyzer_last_scan = $analyzer_instance->get_last_scan();
3352 + $analyzer_history = $analyzer_instance->get_score_history();
3353 + $analyzer_categories_def = Vigilante_Security_Analyzer::get_categories();
3354 + $analyzer_settings = isset( $options['security_analyzer'] ) ? $options['security_analyzer'] : array();
3355 + ?>
3356 + <div class="vigilante-dashboard">
3357 + <?php $this->render_self_protection_strip(); ?>
2923 3358 <?php $this->render_analyzer_widget( $analyzer_last_scan, $analyzer_history, $analyzer_categories_def, $analyzer_settings ); ?>
2924 3359
3360 + <?php $this->render_configuration_card( $security_score, $options, $active_preset, $presets ); ?>
3361 +
2925 3362 <div class="vigilante-modules-grid">
2926 3363 <h2 id="vigilante-section-dashboard-modules"><?php esc_html_e( 'Security Modules', 'vigilante' ); ?></h2>
2927 3364 <p class="description"><?php esc_html_e( 'Enable or disable security modules. Each module controls a tab with detailed settings.', 'vigilante' ); ?></p>
2928 3365 <div class="vigilante-modules-list">
@@ -3406,8 +3843,9 @@
3406 3843 <label>
3407 3844 <input type="checkbox" name="firewall[block_bad_bots]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( ! empty( $options['block_bad_bots'] ) ); ?>>
3408 3845 <?php esc_html_e( 'Block known malicious bots and scanners', 'vigilante' ); ?>
3409 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>
3410 3848 </td>
3411 3849 </tr>
3412 3850 </table>
3413 3851
@@ -3720,9 +4158,9 @@
3720 4158 <th scope="row"><?php esc_html_e( 'Limit HTTP Methods', 'vigilante' ); ?></th>
3721 4159 <td>
3722 4160 <label>
3723 4161 <input type="checkbox" name="firewall[limit_http_methods]" value="1" <?php checked( ! empty( $options['limit_http_methods'] ) ); ?>>
3724 - <?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' ); ?>
3725 4163 </label>
3726 4164 </td>
3727 4165 </tr>
3728 4166 </table>
@@ -4122,8 +4560,9 @@
4122 4560 <tr>
4123 4561 <th scope="row"><?php esc_html_e( 'Enforce for roles', 'vigilante' ); ?></th>
4124 4562 <td>
4125 4563 <div class="vigilante-2fa-roles">
4564 + <?php $this->list_sentinel( 'login_security[two_factor][enforced_roles]' ); ?>
4126 4565 <?php foreach ( $all_roles as $role_slug => $role_data ) :
4127 4566 $user_count = count( get_users( array( 'role' => $role_slug, 'fields' => 'ID' ) ) );
4128 4567 ?>
4129 4568 <label>
@@ -4151,8 +4590,9 @@
4151 4590 autocomplete="off">
4152 4591 <div class="vigilante-2fa-search-results"></div>
4153 4592 </div>
4154 4593 <div class="vigilante-2fa-excluded-users">
4594 + <?php $this->list_sentinel( 'login_security[two_factor][excluded_users]' ); ?>
4155 4595 <?php
4156 4596 foreach ( $excluded as $user_id ) :
4157 4597 $user = get_user_by( 'ID', $user_id );
4158 4598 if ( ! $user ) continue;
@@ -4456,8 +4896,9 @@
4456 4896 <label>
4457 4897 <input type="checkbox" name="security_headers[csp][enabled]" value="1" <?php checked( ! empty( $options['csp']['enabled'] ) ); ?>>
4458 4898 <?php esc_html_e( 'Enable Content Security Policy', 'vigilante' ); ?>
4459 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>
4460 4901 </td>
4461 4902 </tr>
4462 4903 <tr>
4463 4904 <th scope="row"><?php esc_html_e( 'Report Only Mode', 'vigilante' ); ?></th>
@@ -4808,8 +5249,9 @@
4808 5249 </tr>
4809 5250 <tr>
4810 5251 <th scope="row"><?php esc_html_e( 'Apply Password Rules To', 'vigilante' ); ?></th>
4811 5252 <td>
5253 + <?php $this->list_sentinel( 'user_security[password_policy][affected_roles]' ); ?>
4812 5254 <?php foreach ( wp_roles()->get_names() as $role_slug => $role_name ) : ?>
4813 5255 <label style="display:block;margin-bottom:5px;">
4814 5256 <input type="checkbox" name="user_security[password_policy][affected_roles][]" value="<?php echo esc_attr( $role_slug ); ?>" <?php checked( empty( $pw_policy_roles ) || in_array( $role_slug, $pw_policy_roles, true ) ); ?>>
4815 5257 <?php echo esc_html( translate_user_role( $role_name ) ); ?>
@@ -5049,8 +5491,9 @@
5049 5491 <td>
5050 5492 <?php
5051 5493 $affected_roles = $password_exp['affected_roles'] ?? array( 'administrator', 'editor' );
5052 5494 $all_roles = wp_roles()->get_names();
5495 + $this->list_sentinel( 'user_security[password_expiration][affected_roles]' );
5053 5496 foreach ( $all_roles as $role_slug => $role_name ) :
5054 5497 ?>
5055 5498 <label style="display: block; margin-bottom: 5px;">
5056 5499 <input type="checkbox" name="user_security[password_expiration][affected_roles][]" value="<?php echo esc_attr( $role_slug ); ?>" <?php checked( in_array( $role_slug, $affected_roles, true ) ); ?>>
@@ -5072,8 +5515,9 @@
5072 5515 autocomplete="off">
5073 5516 <div class="vigilante-pwexp-search-results"></div>
5074 5517 </div>
5075 5518 <div class="vigilante-pwexp-excluded-users">
5519 + <?php $this->list_sentinel( 'user_security[password_expiration][excluded_users]' ); ?>
5076 5520 <?php
5077 5521 foreach ( $pwexp_excluded as $pwexp_excluded_id ) :
5078 5522 $excluded_user = get_user_by( 'ID', $pwexp_excluded_id );
5079 5523 if ( ! $excluded_user ) {
@@ -5997,9 +6441,9 @@
5997 6441 <select id="vigilante-f-audit-alerts-immediate-min-severity" name="audit_alerts[immediate][min_severity]">
5998 6442 <option value="critical" <?php selected( $alert_severity, 'critical' ); ?>><?php esc_html_e( 'Critical only (recommended)', 'vigilante' ); ?></option>
5999 6443 <option value="warning" <?php selected( $alert_severity, 'warning' ); ?>><?php esc_html_e( 'Warning and Critical', 'vigilante' ); ?></option>
6000 6444 </select>
6001 - <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>
6002 6446 </td>
6003 6447 </tr>
6004 6448 <tr id="field-audit-alerts-threshold">
6005 6449 <th scope="row"><?php esc_html_e( 'Threshold alerts', 'vigilante' ); ?></th>
@@ -6092,8 +6536,9 @@
6092 6536
6093 6537 // Label maps for translated display
6094 6538 $type_labels = array(
6095 6539 'login' => __( 'Login', 'vigilante' ),
6540 + '2fa' => __( 'Two-factor', 'vigilante' ),
6096 6541 'user' => __( 'User', 'vigilante' ),
6097 6542 'content' => __( 'Content', 'vigilante' ),
6098 6543 'plugin' => __( 'Plugin', 'vigilante' ),
6099 6544 'theme' => __( 'Theme', 'vigilante' ),
@@ -6122,8 +6567,9 @@
6122 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">
6123 6568 <select id="vigilante-log-type-filter" aria-label="<?php esc_attr_e( 'Filter the log by event type', 'vigilante' ); ?>">
6124 6569 <option value=""><?php esc_html_e( 'All Types', 'vigilante' ); ?></option>
6125 6570 <option value="login"><?php esc_html_e( 'Login', 'vigilante' ); ?></option>
6571 + <option value="2fa"><?php esc_html_e( 'Two-factor', 'vigilante' ); ?></option>
6126 6572 <option value="user"><?php esc_html_e( 'User', 'vigilante' ); ?></option>
6127 6573 <option value="content"><?php esc_html_e( 'Content', 'vigilante' ); ?></option>
6128 6574 <option value="plugin"><?php esc_html_e( 'Plugin', 'vigilante' ); ?></option>
6129 6575 <option value="theme"><?php esc_html_e( 'Theme', 'vigilante' ); ?></option>
@@ -6217,8 +6663,19 @@
6217 6663 'is_ip_blacklisted' => ( '' !== $ip_val && in_array( $ip_val, $ip_blacklist, true ) ),
6218 6664 'is_ua_whitelisted' => ( '' !== $ua_val && in_array( $ua_val, $ua_whitelist, true ) ),
6219 6665 'is_ua_blacklisted' => ( '' !== $ua_val && in_array( $ua_val, $ua_blacklist, true ) ),
6220 6666 );
6667 + // Self-protection entries carry what happened, what
6668 + // it means and what to do, from the same catalogue
6669 + // the File Integrity box uses.
6670 + $vg_self_event = Vigilante_Self_Integrity_Guidance::for_log_event(
6671 + (string) ( $log->event_action ?? '' ),
6672 + $log->extra_data ?? '',
6673 + (string) ( $log->severity ?? 'info' )
6674 + );
6675 + if ( null !== $vg_self_event ) {
6676 + $details['self'] = $vg_self_event;
6677 + }
6221 6678 $display_type = isset( $type_labels[ $log->event_type ] ) ? $type_labels[ $log->event_type ] : $log->event_type;
6222 6679 $display_severity = isset( $severity_labels[ $log->severity ] ) ? $severity_labels[ $log->severity ] : $log->severity;
6223 6680 ?>
6224 6681 <tr class="vigilante-severity-<?php echo esc_attr( $log->severity ); ?>">
@@ -6261,8 +6718,498 @@
6261 6718
6262 6719 /**
6263 6720 * Render File Integrity tab
6264 6721 */
6722 + /**
6723 + * Findings of the self-check grouped by the case that explains them, worst
6724 + * first: ten modified files are one case with ten paths, not ten copies of
6725 + * the same explanation.
6726 + *
6727 + * @param array $findings Findings from the state.
6728 + * @return array
6729 + */
6730 + private function self_findings_by_case( $findings ) {
6731 + $groups = array();
6732 + foreach ( (array) $findings as $finding ) {
6733 + if ( ! is_array( $finding ) || 'info' === ( $finding['severity'] ?? '' ) ) {
6734 + continue;
6735 + }
6736 + $guidance = Vigilante_Self_Integrity_Guidance::for_finding( $finding );
6737 + $key = $guidance['key'];
6738 + if ( ! isset( $groups[ $key ] ) ) {
6739 + $guidance['files'] = array();
6740 + $guidance['severity'] = 'warning';
6741 + $groups[ $key ] = $guidance;
6742 + }
6743 + $file = isset( $finding['file'] ) ? (string) $finding['file'] : '';
6744 + if ( '' !== $file && ! in_array( $file, $groups[ $key ]['files'], true ) ) {
6745 + $groups[ $key ]['files'][] = $file;
6746 + }
6747 + if ( 'critical' === ( $finding['severity'] ?? '' ) ) {
6748 + $groups[ $key ]['severity'] = 'critical';
6749 + }
6750 + }
6751 + uasort(
6752 + $groups,
6753 + function ( $a, $b ) {
6754 + $rank = array( 'critical' => 0, 'warning' => 1 );
6755 + $ra = isset( $rank[ $a['severity'] ] ) ? $rank[ $a['severity'] ] : 2;
6756 + $rb = isset( $rank[ $b['severity'] ] ) ? $rank[ $b['severity'] ] : 2;
6757 + return $ra - $rb;
6758 + }
6759 + );
6760 + return $groups;
6761 + }
6762 +
6763 + /**
6764 + * Human label for the context that ran the last self-check.
6765 + *
6766 + * @param string $context Stored context.
6767 + * @return string
6768 + */
6769 + private function self_context_label( $context ) {
6770 + switch ( (string) $context ) {
6771 + case 'scan':
6772 + return __( 'during a file integrity scan', 'vigilante' );
6773 + case 'upgrader':
6774 + return __( 'right after updating Vigilant', 'vigilante' );
6775 + case 'version_change':
6776 + return __( 'after a version change made outside the updater', 'vigilante' );
6777 + case 'migration':
6778 + return __( 'while updating to this version', 'vigilante' );
6779 + case 'activation':
6780 + return __( 'when Vigilant was activated', 'vigilante' );
6781 + case 'watchdog':
6782 + return __( 'from the scheduled task watchdog', 'vigilante' );
6783 + }
6784 + return '';
6785 + }
6786 +
6787 + /**
6788 + * Short line for the badge of the box, the notices and the Dashboard strip.
6789 + *
6790 + * @param string $tone Tone from Vigilante_Self_Integrity::tone().
6791 + * @param array $state State.
6792 + * @return string
6793 + */
6794 + private function self_integrity_headline( $tone, $state ) {
6795 + $files = isset( $state['files_checked'] ) ? (int) $state['files_checked'] : 0;
6796 + $anchors = ( isset( $state['anchors'] ) && is_array( $state['anchors'] ) ) ? $state['anchors'] : array();
6797 + switch ( $tone ) {
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 + }
6806 + return __( 'Changes detected in Vigilant own files', 'vigilante' );
6807 + case 'warning':
6808 + return ( $files < 1 )
6809 + ? __( 'Vigilant could not check its own files', 'vigilante' )
6810 + : __( 'Vigilant self-protection needs your attention', 'vigilante' );
6811 + case 'off':
6812 + return __( 'Self-protection is switched off by code', 'vigilante' );
6813 + case 'none':
6814 + return __( 'Vigilant has not checked its own files yet', 'vigilante' );
6815 + }
6816 + return sprintf(
6817 + /* translators: 1: number of files verified, 2: number of references available, out of three */
6818 + __( 'Verified: %1$d files, %2$d of 3 references', 'vigilante' ),
6819 + $files,
6820 + count( array_filter( $anchors ) )
6821 + );
6822 + }
6823 +
6824 + /**
6825 + * Vigilant self-protection box: the first block of the File Integrity
6826 + * results, with its own colour by severity, what each finding means and
6827 + * how to fix it.
6828 + *
6829 + * It renders whether or not a scan has been stored, because the self-check
6830 + * also runs after every update and once a day: before 3.0.0 this lived in
6831 + * a line above the numeric cards of the last scan, where it was invisible
6832 + * and, without a stored scan, absent.
6833 + *
6834 + * @param array $fi_options File Integrity settings section.
6835 + */
6836 + /**
6837 + * Everything the screens need to say about self-protection, read once:
6838 + * the state, its findings grouped by case, and the tone that colours the
6839 + * box, the menu counter and the notices.
6840 + *
6841 + * @param array|null $fi_options File Integrity settings, read if not given.
6842 + * @return array { state, findings, groups, tone, enabled }
6843 + */
6844 + private function self_integrity_summary( $fi_options = null ) {
6845 + // Four to six screens ask for this in the same page load (menu, notice,
6846 + // box, strip, both scores). The option is cached by the core options
6847 + // layer, but the grouping and the tone are not: memoize per request.
6848 + static $cached = null;
6849 + if ( null !== $cached && ! is_array( $fi_options ) ) {
6850 + return $cached;
6851 + }
6852 + if ( ! is_array( $fi_options ) ) {
6853 + $fi_options = (array) $this->settings->get_section( 'file_integrity' );
6854 + }
6855 + $enabled = Vigilante_Self_Integrity::is_on();
6856 + $state = Vigilante_Self_Integrity::display_state();
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 + }
6870 + $summary = array(
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 ),
6881 + );
6882 + $cached = $summary;
6883 + return $summary;
6884 + }
6885 +
6886 + private function render_self_protection_box( $fi_options ) {
6887 + $summary = $this->self_integrity_summary( $fi_options );
6888 + $enabled = $summary['enabled'];
6889 + $state = $summary['state'];
6890 + $findings = $summary['findings'];
6891 + $groups = $summary['groups'];
6892 + $tone = $summary['tone'];
6893 + $total = isset( $state['last_findings_total'] ) ? (int) $state['last_findings_total'] : count( $findings );
6894 + $status = array(
6895 + 'status' => isset( $state['last_status'] ) ? (string) $state['last_status'] : '',
6896 + 'files' => isset( $state['files_checked'] ) ? (int) $state['files_checked'] : 0,
6897 + 'anchors' => ( isset( $state['anchors'] ) && is_array( $state['anchors'] ) ) ? $state['anchors'] : array(),
6898 + 'enabled' => $enabled,
6899 + 'has_run' => ! empty( $state['last_check'] ),
6900 + );
6901 +
6902 + /*
6903 + * Nothing to do, nothing to open: one line. A card with a big icon and
6904 + * folded sections for "everything is fine" is furniture, and furniture
6905 + * is what made the previous version of this invisible.
6906 + */
6907 + if ( empty( $groups ) ) {
6908 + $this->render_self_line( $tone, $state, $status );
6909 + return;
6910 + }
6911 +
6912 + $datetime_format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' );
6913 + $context_label = $this->self_context_label( isset( $state['last_context'] ) ? $state['last_context'] : '' );
6914 + $icon = ( 'critical' === $tone ) ? 'dashicons-shield' : 'dashicons-warning';
6915 + ?>
6916 + <div id="vigilante-section-fi-self" class="vigilante-settings-section vigilante-self-box vigilante-self-box--<?php echo esc_attr( $tone ); ?>">
6917 + <div class="vigilante-self-card">
6918 + <div class="vigilante-self-head">
6919 + <span class="vigilante-self-icon"><span class="dashicons <?php echo esc_attr( $icon ); ?>" aria-hidden="true"></span></span>
6920 + <div class="vigilante-self-head-text">
6921 + <h2><?php echo esc_html( $this->self_integrity_headline( $tone, $state ) ); ?></h2>
6922 + <p class="vigilante-self-meta">
6923 + <?php
6924 + printf(
6925 + /* translators: %s: date and time of the last self-check */
6926 + esc_html__( 'Checked on %s', 'vigilante' ),
6927 + esc_html( wp_date( $datetime_format, (int) $state['last_check'] ) )
6928 + );
6929 + if ( '' !== $context_label ) {
6930 + echo ', ' . esc_html( $context_label );
6931 + }
6932 + if ( $status['files'] > 0 ) {
6933 + echo ', ';
6934 + printf(
6935 + /* translators: 1: number of files checked, 2: number of references available, out of three */
6936 + esc_html__( '%1$d files against %2$d of 3 references', 'vigilante' ),
6937 + (int) $status['files'],
6938 + (int) count( array_filter( $status['anchors'] ) )
6939 + );
6940 + }
6941 + ?>
6942 + </p>
6943 + </div>
6944 + </div>
6945 +
6946 + <div class="vigilante-self-cases">
6947 + <?php
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;
6960 + foreach ( $groups as $group ) {
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 );
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 + }
6979 + }
6980 +
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 ) ) {
6983 + $can_repair = class_exists( 'Vigilante_Self_Repair' ) && Vigilante_Self_Repair::can_repair();
6984 + if ( ! $can_repair ) {
6985 + if ( class_exists( 'Vigilante_Self_Repair' ) && ! Vigilante_Self_Repair::folder_is_the_distributed_one() ) {
6986 + $vg_reason = 'renamed';
6987 + } elseif ( ! wp_is_file_mod_allowed( 'capability_update_core' ) ) {
6988 + // With DISALLOW_FILE_MODS nobody can do it from the admin,
6989 + // not even a network administrator: telling a subsite admin
6990 + // to ask theirs would send them to someone equally blocked.
6991 + $vg_reason = 'no_caps';
6992 + } elseif ( is_multisite() && ! current_user_can( 'update_plugins' ) ) {
6993 + $vg_reason = 'network';
6994 + } else {
6995 + $vg_reason = 'no_caps';
6996 + }
6997 + $this->render_self_case(
6998 + array(
6999 + 'title' => __( 'How to repair it here', 'vigilante' ),
7000 + 'meaning' => __( 'This site does not let Vigilant replace its own files from this screen.', 'vigilante' ),
7001 + 'steps' => Vigilante_Self_Integrity_Guidance::manual_steps( $vg_reason ),
7002 + ),
7003 + array(),
7004 + '',
7005 + false
7006 + );
7007 + }
7008 + }
7009 + ?>
7010 + </div>
7011 +
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 ) ) : ?>
7014 + <p class="description">
7015 + <?php
7016 + printf(
7017 + /* translators: 1: findings shown, 2: findings found in total */
7018 + esc_html__( 'Showing %1$d of %2$d findings: the rest are of the same kind.', 'vigilante' ),
7019 + (int) count( $findings ),
7020 + (int) $total
7021 + );
7022 + ?>
7023 + </p>
7024 + <?php endif; ?>
7025 +
7026 + <p class="vigilante-self-actions">
7027 + <?php if ( $vg_offer_repair && class_exists( 'Vigilante_Self_Repair' ) && Vigilante_Self_Repair::can_repair() ) : ?>
7028 + <a class="button button-primary" href="<?php echo esc_url( Vigilante_Self_Repair::action_url() ); ?>">
7029 + <?php esc_html_e( 'Repair Vigilant', 'vigilante' ); ?>
7030 + </a>
7031 + <span class="description">
7032 + <?php esc_html_e( 'It downloads a clean copy from WordPress.org, asks before changing anything, and keeps your settings and log.', 'vigilante' ); ?>
7033 + </span>
7034 + <?php elseif ( $vg_scan_helps ) : ?>
7035 + <span class="description">
7036 + <?php esc_html_e( 'After fixing it, run a new scan with the Run Scan Now button above.', 'vigilante' ); ?>
7037 + </span>
7038 + <?php endif; ?>
7039 + </p>
7040 + </div>
7041 + </div>
7042 + <?php
7043 + }
7044 +
7045 + /**
7046 + * The same state when there is nothing to act on: one line inside the same
7047 + * card, so the first block of the results is always the same object.
7048 + *
7049 + * @param string $tone Tone from Vigilante_Self_Integrity::tone().
7050 + * @param array $state State.
7051 + * @param array $status Arguments for the guidance catalogue.
7052 + */
7053 + private function render_self_line( $tone, $state, $status ) {
7054 + $guidance = Vigilante_Self_Integrity_Guidance::for_status( $status );
7055 + $icon = ( 'ok' === $tone ) ? 'dashicons-yes-alt' : 'dashicons-shield';
7056 + ?>
7057 + <div id="vigilante-section-fi-self" class="vigilante-settings-section vigilante-self-box vigilante-self-box--<?php echo esc_attr( $tone ); ?> vigilante-self-box--quiet">
7058 + <div class="vigilante-self-card vigilante-self-card--quiet">
7059 + <p class="vigilante-self-line">
7060 + <span class="dashicons <?php echo esc_attr( $icon ); ?>" aria-hidden="true"></span>
7061 + <strong><?php esc_html_e( 'Vigilant self-protection:', 'vigilante' ); ?></strong>
7062 + <span class="vigilante-self-line-state"><?php echo esc_html( $this->self_integrity_headline( $tone, $state ) ); ?></span>
7063 + <?php if ( ! empty( $state['last_check'] ) ) : ?>
7064 + <span class="vigilante-self-line-meta">
7065 + <?php
7066 + printf(
7067 + /* translators: %s: human time difference, like "2 hours" */
7068 + esc_html__( 'checked %s ago', 'vigilante' ),
7069 + esc_html( human_time_diff( (int) $state['last_check'], time() ) )
7070 + );
7071 + ?>
7072 + </span>
7073 + <?php endif; ?>
7074 + </p>
7075 + <?php if ( ! empty( $guidance['steps'] ) && 'none' === $tone ) : ?>
7076 + <p class="description vigilante-self-line-help"><?php echo esc_html( $guidance['steps'][0] ); ?></p>
7077 + <?php endif; ?>
7078 + </div>
7079 + </div>
7080 + <?php
7081 + }
7082 +
7083 + /**
7084 + * One case of the self-protection box, as a row that opens: what it is, how
7085 + * many files, its severity, and inside, the files, what it means and what to
7086 + * do. Plain HTML details, so there is no JavaScript between a finding about
7087 + * the security plugin and the person reading it.
7088 + *
7089 + * @param array $guidance Guidance entry (title, meaning, steps).
7090 + * @param array $files Paths this case was found in.
7091 + * @param string $severity critical|warning, empty for the how-to-repair row.
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.
7096 + */
7097 + private function render_self_case( $guidance, $files = array(), $severity = '', $open = false, $unit = 'file' ) {
7098 + $shown = array_slice( (array) $files, 0, 20 );
7099 + $hidden = count( (array) $files ) - count( $shown );
7100 + ?>
7101 + <details class="vigilante-self-case vigilante-self-case--<?php echo esc_attr( '' !== $severity ? $severity : 'plain' ); ?>"<?php echo $open ? ' open' : ''; ?>>
7102 + <summary>
7103 + <span class="vigilante-self-case-title"><?php echo esc_html( $guidance['title'] ); ?></span>
7104 + <?php if ( ! empty( $files ) ) : ?>
7105 + <span class="vigilante-self-case-count">
7106 + <?php
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 + }
7126 + ?>
7127 + </span>
7128 + <?php endif; ?>
7129 + <?php if ( '' !== $severity ) : ?>
7130 + <span class="vigilante-self-case-severity">
7131 + <?php echo esc_html( 'critical' === $severity ? __( 'critical', 'vigilante' ) : __( 'warning', 'vigilante' ) ); ?>
7132 + </span>
7133 + <?php endif; ?>
7134 + </summary>
7135 + <div class="vigilante-self-case-body">
7136 + <?php if ( ! empty( $shown ) ) : ?>
7137 + <ul class="vigilante-self-paths">
7138 + <?php foreach ( $shown as $file ) : ?>
7139 + <li><code><?php echo esc_html( $file ); ?></code></li>
7140 + <?php endforeach; ?>
7141 + <?php if ( $hidden > 0 ) : ?>
7142 + <li>
7143 + <?php
7144 + printf(
7145 + /* translators: %d: number of additional files */
7146 + esc_html__( 'and %d more', 'vigilante' ),
7147 + (int) $hidden
7148 + );
7149 + ?>
7150 + </li>
7151 + <?php endif; ?>
7152 + </ul>
7153 + <?php endif; ?>
7154 + <p class="vigilante-self-meaning">
7155 + <strong><?php esc_html_e( 'What it means:', 'vigilante' ); ?></strong>
7156 + <?php echo esc_html( $guidance['meaning'] ); ?>
7157 + </p>
7158 + <?php if ( ! empty( $guidance['steps'] ) ) : ?>
7159 + <p class="vigilante-self-todo"><strong><?php esc_html_e( 'What to do:', 'vigilante' ); ?></strong></p>
7160 + <ol class="vigilante-self-steps">
7161 + <?php foreach ( $guidance['steps'] as $step ) : ?>
7162 + <li><?php echo esc_html( $step ); ?></li>
7163 + <?php endforeach; ?>
7164 + </ol>
7165 + <?php endif; ?>
7166 + </div>
7167 + </details>
7168 + <?php
7169 + }
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 +
6265 7212 private function render_tab_file_integrity() {
6266 7213 $is_disabled = $this->render_module_disabled_notice( 'file_integrity' );
6267 7214 $options = $this->settings->get_section( 'file_integrity' );
6268 7215 // On the main site of a network the critical-file scan is the network's
@@ -6273,8 +7220,32 @@
6273 7220 $last_scan = get_option( 'vigilante_last_integrity_scan' );
6274 7221 $last_results = get_option( 'vigilante_last_integrity_results' );
6275 7222 $ignored_files = get_option( 'vigilante_ignored_files', array() );
6276 7223
7224 + /*
7225 + * Vigilant own findings are shown in their own box, the first of the
7226 + * results, with what each one means and how to fix it. They are taken
7227 + * out of the generic tables here (and out of the counters above them)
7228 + * so the same finding is not reported twice and so no row of the
7229 + * security plugin's own files offers an Ignore button. The stored
7230 + * results keep them: the scan email reads its own section from there.
7231 + */
7232 + if ( is_array( $last_results ) ) {
7233 + foreach ( array( 'modified', 'suspicious', 'extra', 'missing' ) as $vg_bucket ) {
7234 + if ( empty( $last_results[ $vg_bucket ] ) || ! is_array( $last_results[ $vg_bucket ] ) ) {
7235 + continue;
7236 + }
7237 + $last_results[ $vg_bucket ] = array_values(
7238 + array_filter(
7239 + $last_results[ $vg_bucket ],
7240 + function ( $vg_item ) {
7241 + return ! ( is_array( $vg_item ) && 'vigilante_self' === ( $vg_item['type'] ?? '' ) );
7242 + }
7243 + )
7244 + );
7245 + }
7246 + }
7247 +
6277 7248 // Backward compat: convert old notify_on_changes to notify_level
6278 7249 $notify_level = $options['notify_level'] ?? '';
6279 7250 if ( empty( $notify_level ) ) {
6280 7251 $notify_level = ! empty( $options['notify_on_changes'] ) ? 'all' : 'disabled';
@@ -6310,9 +7281,10 @@
6310 7281 <h2>
6311 7282 <?php esc_html_e( 'File Integrity Monitoring', 'vigilante' ); ?>
6312 7283 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
6313 7284 </h2>
6314 - <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>
6315 7287
6316 7288 <table class="form-table">
6317 7289 <tr>
6318 7290 <th scope="row"><?php esc_html_e( 'Automatic Scans', 'vigilante' ); ?></th>
@@ -6335,13 +7307,13 @@
6335 7307 <tr>
6336 7308 <th scope="row"><label for="vigilante-f-file-integrity-notify-level"><?php esc_html_e( 'Email Notifications', 'vigilante' ); ?></label></th>
6337 7309 <td>
6338 7310 <select id="vigilante-f-file-integrity-notify-level" name="file_integrity[notify_level]">
6339 - <option value="all" <?php selected( $notify_level, 'all' ); ?>><?php esc_html_e( 'All issues (modified + suspicious)', 'vigilante' ); ?></option>
6340 - <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>
6341 7313 <option value="disabled" <?php selected( $notify_level, 'disabled' ); ?>><?php esc_html_e( 'Disabled', 'vigilante' ); ?></option>
6342 7314 </select>
6343 - <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>
6344 7316 </td>
6345 7317 </tr>
6346 7318 <tr>
6347 7319 <th scope="row"><?php esc_html_e( 'Instant Alert', 'vigilante' ); ?></th>
@@ -6347,11 +7319,11 @@
6347 7319 <th scope="row"><?php esc_html_e( 'Instant Alert', 'vigilante' ); ?></th>
6348 7320 <td>
6349 7321 <label>
6350 7322 <input type="checkbox" name="file_integrity[instant_alert]" value="1" <?php checked( ! empty( $options['instant_alert'] ) ); ?>>
6351 - <?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' ); ?>
6352 7324 </label>
6353 - <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>
6354 7326 <p class="description">
6355 7327 <?php
6356 7328 printf(
6357 7329 /* translators: %s: Link to notification settings */
@@ -6377,29 +7349,29 @@
6377 7349 <td>
6378 7350 <fieldset>
6379 7351 <label>
6380 7352 <input type="checkbox" name="file_integrity[scan_core]" value="1" <?php checked( $options['scan_core'] ?? true ); ?>>
6381 - <?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' ); ?>
6382 7354 </label>
6383 7355 <br>
6384 7356 <label>
6385 7357 <input type="checkbox" name="file_integrity[scan_plugins]" value="1" <?php checked( $options['scan_plugins'] ?? true ); ?>>
6386 - <?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' ); ?>
6387 7359 </label>
6388 7360 <br>
6389 7361 <label>
6390 7362 <input type="checkbox" name="file_integrity[scan_themes]" value="1" <?php checked( $options['scan_themes'] ?? true ); ?>>
6391 - <?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' ); ?>
6392 7364 </label>
6393 7365 <br>
6394 7366 <label>
6395 7367 <input type="checkbox" name="file_integrity[scan_uploads]" value="1" <?php checked( $options['scan_uploads'] ?? true ); ?>>
6396 - <?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' ); ?>
6397 7369 </label>
6398 7370 <br>
6399 7371 <label>
6400 7372 <input type="checkbox" name="file_integrity[scan_critical_config]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( $options['scan_critical_config'] ?? true ); ?>>
6401 - <?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' ); ?>
6402 7374 <?php if ( $vg_main_locked ) : ?>
6403 7375 <span class="description" style="display:block;margin-left:24px;"><?php echo esc_html( Vigilante_Settings::get_shared_files_notice() ); ?></span>
6404 7376 <?php endif; ?>
6405 7377 </label>
@@ -6413,10 +7385,10 @@
6413 7385 </tr>
6414 7386 <tr>
6415 7387 <th scope="row"><label for="vigilante-f-file-integrity-excluded-paths"><?php esc_html_e( 'Excluded Paths', 'vigilante' ); ?></label></th>
6416 7388 <td>
6417 - <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>
6418 - <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>
6419 7391 </td>
6420 7392 </tr>
6421 7393 <tr>
6422 7394 <th scope="row"><label for="vigilante-f-file-integrity-excluded-extensions"><?php esc_html_e( 'Excluded Extensions', 'vigilante' ); ?></label></th>
@@ -6457,11 +7429,51 @@
6457 7429 </form>
6458 7430
6459 7431 <div id="vigilante-scan-results" class="vigilante-settings-section" style="display:none;"></div>
6460 7432
7433 + <?php $this->render_self_protection_box( $options ); ?>
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 +
6461 7462 <?php if ( $last_scan || $has_closed || $closed_last_check > 0 ) : ?>
6462 7463 <div id="vigilante-section-fi-last-scan" class="vigilante-settings-section">
6463 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; ?>
6464 7476 <?php if ( $last_scan ) : ?>
6465 7477 <p>
6466 7478 <?php
6467 7479 // Build the "X files scanned" hint inline with the date so it doesn't
@@ -6544,12 +7556,112 @@
6544 7556 </div>
6545 7557 <?php endif; ?>
6546 7558 </div>
6547 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 +
6548 7659 <?php if ( ! empty( $last_results['suspicious'] ) ) : ?>
6549 7660 <div class="vigilante-file-list vigilante-suspicious-files vigilante-paginated-section" data-bulk-mode="ignore">
6550 7661 <h3 style="color: #d63638;"><?php esc_html_e( 'Suspicious Files', 'vigilante' ); ?></h3>
6551 - <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'] ); ?>
6552 7664 <div class="vigilante-fi-bulk-bar">
6553 7665 <button type="button" class="button vigilante-bulk-ignore" disabled><?php esc_html_e( 'Ignore selected', 'vigilante' ); ?></button>
6554 7666 <span class="vigilante-fi-bulk-count" aria-live="polite"></span>
6555 7667 </div>
@@ -6583,8 +7695,14 @@
6583 7695 }
6584 7696 } else {
6585 7697 $file_path = (string) $item;
6586 7698 }
7699 + if ( 'vigilante_self' === $file_type ) {
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'];
7704 + }
6587 7705 ?>
6588 7706 <tr>
6589 7707 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
6590 7708 <td><code style="color: #d63638;"><?php echo esc_html( $file_path ); ?></code></td>
@@ -6602,9 +7720,10 @@
6602 7720
6603 7721 <?php if ( ! empty( $last_results['extra'] ) ) : ?>
6604 7722 <div class="vigilante-file-list vigilante-extra-files vigilante-paginated-section" data-bulk-mode="ignore">
6605 7723 <h3 style="color: #b32d2e;"><?php esc_html_e( 'Extra Files', 'vigilante' ); ?></h3>
6606 - <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'] ); ?>
6607 7726 <div class="vigilante-fi-bulk-bar">
6608 7727 <button type="button" class="button vigilante-bulk-ignore" disabled><?php esc_html_e( 'Ignore selected', 'vigilante' ); ?></button>
6609 7728 <span class="vigilante-fi-bulk-count" aria-live="polite"></span>
6610 7729 </div>
@@ -6624,8 +7743,14 @@
6624 7743 foreach ( $last_results['extra'] as $item ) {
6625 7744 $file_path = is_array( $item ) ? ( $item['file'] ?? '' ) : (string) $item;
6626 7745 $file_reason = is_array( $item ) ? ( $item['reason'] ?? __( 'Unknown', 'vigilante' ) ) : __( 'Unknown', 'vigilante' );
6627 7746 $file_type = is_array( $item ) ? ( $item['type'] ?? 'unknown' ) : 'unknown';
7747 + if ( 'vigilante_self' === $file_type ) {
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'];
7752 + }
6628 7753 ?>
6629 7754 <tr>
6630 7755 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
6631 7756 <td><code style="color: #b32d2e;"><?php echo esc_html( $file_path ); ?></code></td>
@@ -6656,8 +7781,19 @@
6656 7781 $regular_modified[] = $item;
6657 7782 }
6658 7783 }
6659 7784 }
7785 + // A missing file of Vigilant is critical, and the missing
7786 + // files of the generic scan have no table: it is listed with
7787 + // the modified files, or the tab said "All files passed" with
7788 + // a module deleted.
7789 + if ( $last_results && ! empty( $last_results['missing'] ) && is_array( $last_results['missing'] ) ) {
7790 + foreach ( $last_results['missing'] as $item ) {
7791 + if ( is_array( $item ) && 'vigilante_self' === ( $item['type'] ?? '' ) ) {
7792 + $regular_modified[] = $item;
7793 + }
7794 + }
7795 + }
6660 7796 ?>
6661 7797
6662 7798 <?php if ( ! empty( $critical_modified ) ) : ?>
6663 7799 <div class="vigilante-file-list vigilante-critical-config-files">
@@ -6662,9 +7798,9 @@
6662 7798 <?php if ( ! empty( $critical_modified ) ) : ?>
6663 7799 <div class="vigilante-file-list vigilante-critical-config-files">
6664 7800 <h3 style="color: #e36210;"><?php esc_html_e( 'Critical config files modified', 'vigilante' ); ?></h3>
6665 7801 <p class="description">
6666 - <?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' ); ?>
6667 7803 </p>
6668 7804 <table class="wp-list-table widefat fixed striped">
6669 7805 <thead>
6670 7806 <tr>
@@ -6825,9 +7961,10 @@
6825 7961
6826 7962 <?php if ( ! empty( $regular_modified ) ) : ?>
6827 7963 <div class="vigilante-file-list vigilante-paginated-section" data-bulk-mode="ignore">
6828 7964 <h3><?php esc_html_e( 'Modified Files', 'vigilante' ); ?></h3>
6829 - <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 ); ?>
6830 7967 <div class="vigilante-fi-bulk-bar">
6831 7968 <button type="button" class="button vigilante-bulk-ignore" disabled><?php esc_html_e( 'Ignore selected', 'vigilante' ); ?></button>
6832 7969 <span class="vigilante-fi-bulk-count" aria-live="polite"></span>
6833 7970 </div>
@@ -6856,8 +7993,14 @@
6856 7993 }
6857 7994 } else {
6858 7995 $file_path = (string) $item;
6859 7996 }
7997 + if ( 'vigilante_self' === $file_type ) {
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'];
8002 + }
6860 8003 ?>
6861 8004 <tr>
6862 8005 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
6863 8006 <td><code><?php echo esc_html( $file_path ); ?></code></td>
@@ -6871,11 +8014,18 @@
6871 8014 </table>
6872 8015 </div>
6873 8016 <?php endif; ?>
6874 8017
6875 - <?php if ( $last_results && empty( $last_results['modified'] ) && empty( $last_results['suspicious'] ) && empty( $last_results['extra'] ) && ! $has_closed ) : ?>
8018 + <?php
8019 + // "All files passed" is about every file, and Vigilant's own
8020 + // are files too: with the self-protection box in red or amber
8021 + // above, this line contradicted it (its findings no longer
8022 + // travel in the tables below).
8023 + $vg_self_alarm = in_array( $this->self_integrity_summary( $options )['tone'], array( 'critical', 'off', 'warning' ), true );
8024 + ?>
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 ) ) : ?>
6876 8026 <p class="vigilante-all-clear" style="color: #00a32a; font-weight: bold;">
6877 - <?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' ); ?>
6878 8028 </p>
6879 8029 <?php endif; ?>
6880 8030 <?php endif; ?>
6881 8031 </div>
@@ -6924,9 +8074,9 @@
6924 8074
6925 8075 <?php if ( ! empty( $ignored_files ) ) : ?>
6926 8076 <div id="vigilante-section-fi-ignored" class="vigilante-settings-section">
6927 8077 <h2><?php esc_html_e( 'Ignored Files', 'vigilante' ); ?></h2>
6928 - <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>
6929 8079 <div class="vigilante-paginated-section" data-bulk-mode="unignore">
6930 8080 <div class="vigilante-fi-bulk-bar">
6931 8081 <button type="button" class="button vigilante-bulk-unignore" disabled><?php esc_html_e( 'Stop ignoring selected', 'vigilante' ); ?></button>
6932 8082 <span class="vigilante-fi-bulk-count" aria-live="polite"></span>
@@ -7015,9 +8165,9 @@
7015 8165 } elseif ( isset( $_POST['data'] ) ) {
7016 8166 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
7017 8167 $raw_data = wp_unslash( $_POST['data'] );
7018 8168 parse_str( $raw_data, $data );
7019 - $data = map_deep( $data, 'sanitize_textarea_field' );
8169 + $data = $this->sanitize_posted_settings( $data );
7020 8170 } else {
7021 8171 $data = array();
7022 8172 }
7023 8173
@@ -7407,8 +8557,48 @@
7407 8557 return array( 'sent' => $sent, 'failed' => $failed );
7408 8558 }
7409 8559
7410 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 + /**
7411 8601 * Process section data maintaining proper types from defaults
7412 8602 *
7413 8603 * @param array $submitted_data Data submitted from form.
7414 8604 * @param array $defaults Default values for this section.
@@ -7416,16 +8606,51 @@
7416 8606 * @param string $section_name Section name for special handling.
7417 8607 * @return array Processed data.
7418 8608 */
7419 8609 private function process_section_data( $submitted_data, $defaults, $current, $section_name = '' ) {
7420 - // Start with defaults, then merge current saved values
8610 + // Start with defaults, then merge current saved values. Lists are put
8611 + // back whole afterwards: array_replace_recursive() merges them index by
8612 + // index, so a saved list shorter than the default kept the default's
8613 + // tail (see below, and Vigilante_Settings::array_merge_deep()).
7421 8614 $result = array_replace_recursive( $defaults, $current );
7422 -
8615 + $result = $this->restore_lists( $result, $current );
8616 +
7423 8617 // Process each submitted value
7424 8618 foreach ( $submitted_data as $key => $value ) {
7425 8619 $key = sanitize_key( $key );
7426 -
7427 - if ( is_array( $value ) ) {
8620 +
8621 + if ( is_array( $value ) && $this->is_list_field( $key, $value, $defaults, $current ) ) {
8622 + /*
8623 + * A list of values, not a group of settings: the roles of a
8624 + * checkbox group, the excluded user ids. What the form sent IS
8625 + * the list, so it replaces the stored one outright.
8626 + *
8627 + * Until 3.0.1 this went through the recursion below, which
8628 + * writes position by position and left every stored entry the
8629 + * new list was too short to overwrite. Unchecking Editor in
8630 + * "Enforce for roles" saved administrator over position 0 and
8631 + * kept editor in position 1, so the box came back ticked
8632 + * (reported on the support forum for 3.0.0, and the same bug
8633 + * merge_preset() was fixed for in 2.9.8).
8634 + *
8635 + * Keys are dropped on purpose: a list is data, so anything the
8636 + * request tried to name is not carried over. The empty string
8637 + * is dropped too, which is what the hidden field that marks an
8638 + * empty group sends.
8639 + */
8640 + $list = array();
8641 + foreach ( $value as $entry ) {
8642 + if ( ! is_scalar( $entry ) ) {
8643 + continue;
8644 + }
8645 + $entry = sanitize_text_field( (string) $entry );
8646 + if ( '' === $entry ) {
8647 + continue;
8648 + }
8649 + $list[] = $entry;
8650 + }
8651 + $result[ $key ] = $list;
8652 + } elseif ( is_array( $value ) ) {
7428 8653 // Nested array (like rate_limiting)
7429 8654 $nested_defaults = isset( $defaults[ $key ] ) && is_array( $defaults[ $key ] ) ? $defaults[ $key ] : array();
7430 8655 $nested_current = isset( $result[ $key ] ) && is_array( $result[ $key ] ) ? $result[ $key ] : array();
7431 8656 $result[ $key ] = $this->process_section_data( $value, $nested_defaults, $nested_current, $key );
@@ -7511,10 +8736,26 @@
7511 8736 }
7512 8737 }
7513 8738
7514 8739 if ( $is_value_list ) {
7515 - // All items removed - reset to empty array
7516 - $result[ $key ] = array();
8740 + /*
8741 + * A list whose key did not travel is left as it was. Until
8742 + * 3.0.1 it was blanked here, which read "the user unticked
8743 + * everything" into something the form never asked about:
8744 + * saving the Firewall tab blanked allowed_http_methods,
8745 + * which has no field on any screen, and saving Users blanked
8746 + * insecure_usernames. Both survived only because reading the
8747 + * option put the defaults back on top, and that is exactly
8748 + * the merge being removed in this release: with lists
8749 + * replaced whole, blanking here would have left a site
8750 + * answering 403 to every HTTP method.
8751 + *
8752 + * Emptying a group is still possible, and is now said out
8753 + * loud: each checkbox group prints a hidden field with an
8754 + * empty value, so an empty group arrives as an empty list
8755 + * instead of as silence. See list_sentinel().
8756 + */
8757 + continue;
7517 8758 } else {
7518 8759 // Nested settings group - handle boolean children
7519 8760 foreach ( $default_value as $nested_key => $nested_default ) {
7520 8761 if ( is_bool( $nested_default ) && isset( $result[ $key ] ) && is_array( $result[ $key ] ) ) {
@@ -7533,8 +8774,106 @@
7533 8774 return $result;
7534 8775 }
7535 8776
7536 8777 /**
8778 + * Whether a submitted value is a list of values rather than a group of settings
8779 + *
8780 + * The difference decides whether what arrived replaces the stored value or
8781 + * is merged into it key by key, so it is answered from what the setting is
8782 + * (its default, or what is stored today), not from the shape of the
8783 + * request. A request that sends named keys where a list belongs gets them
8784 + * dropped, which is one way this is narrower than what it replaces.
8785 + *
8786 + * @since 3.0.1
8787 + *
8788 + * @param string $key Setting key, already through sanitize_key().
8789 + * @param array $value Value as submitted.
8790 + * @param array $defaults Defaults of the section being processed.
8791 + * @param array $current Stored values of the section being processed.
8792 + * @return bool
8793 + */
8794 + private function is_list_field( $key, $value, $defaults, $current ) {
8795 + $default_known = array_key_exists( $key, $defaults ) && is_array( $defaults[ $key ] );
8796 +
8797 + // The default has the first and the last word, the same rule that
8798 + // array_merge_deep() and restore_lists() follow. Letting the stored
8799 + // value decide, as this did until the fourth cross review of 3.0.1,
8800 + // disagrees with them where it matters: an option corrupted into
8801 + // two_factor = array( 'a', 'b' ) would have been treated as a list and
8802 + // saving the tab would have replaced the whole group of settings with
8803 + // the two values that arrived.
8804 + if ( $default_known && array() !== $defaults[ $key ] ) {
8805 + return Vigilante_Settings::is_list( $defaults[ $key ] );
8806 + }
8807 +
8808 + // Nothing declares it either way (an empty default, or a key with no
8809 + // default at all), so what is stored, and then what was submitted, is
8810 + // all there is to go on.
8811 + if ( isset( $current[ $key ] ) && is_array( $current[ $key ] ) && array() !== $current[ $key ] && Vigilante_Settings::is_list( $current[ $key ] ) ) {
8812 + return true;
8813 + }
8814 +
8815 + return Vigilante_Settings::is_list( $value );
8816 + }
8817 +
8818 + /**
8819 + * Put stored lists back after array_replace_recursive()
8820 + *
8821 + * array_replace_recursive() merges a list by position, so a stored list
8822 + * shorter than the default comes out with the default's tail attached:
8823 + * ['administrator'] over ['administrator','editor'] gives both back. Every
8824 + * list that is stored is therefore restored whole before anything else is
8825 + * processed, and only associative arrays are walked into.
8826 + *
8827 + * @since 3.0.1
8828 + *
8829 + * @param array $result Defaults with the stored values merged over them.
8830 + * @param array $current Stored values.
8831 + * @return array
8832 + */
8833 + private function restore_lists( $result, $current ) {
8834 + if ( ! is_array( $current ) ) {
8835 + return $result;
8836 + }
8837 +
8838 + foreach ( $current as $key => $value ) {
8839 + if ( ! is_array( $value ) || ! isset( $result[ $key ] ) || ! is_array( $result[ $key ] ) ) {
8840 + continue;
8841 + }
8842 +
8843 + // Decided by what is in $result, which comes from the defaults, and
8844 + // never by the stored value: array() is a list by any definition, so
8845 + // a section stored empty would have replaced a whole group of
8846 + // settings with nothing. Same reasoning as in array_merge_deep().
8847 + if ( Vigilante_Settings::is_list( $result[ $key ] ) ) {
8848 + $result[ $key ] = $value;
8849 + continue;
8850 + }
8851 +
8852 + $result[ $key ] = $this->restore_lists( $result[ $key ], $value );
8853 + }
8854 +
8855 + return $result;
8856 + }
8857 +
8858 + /**
8859 + * Print the hidden field that lets a checkbox group arrive empty
8860 + *
8861 + * A checkbox group sends nothing at all when every box is unticked, which
8862 + * is indistinguishable from a form that never had that field. Since 3.0.1
8863 + * the save path leaves an absent list alone, so the difference has to be
8864 + * stated: this empty entry always travels, and the save path drops empty
8865 + * strings, so an untouched group arrives as an empty list.
8866 + *
8867 + * @since 3.0.1
8868 + *
8869 + * @param string $name Field name without the trailing [].
8870 + */
8871 + private function list_sentinel( $name ) {
8872 + printf( '<input type="hidden" name="%s[]" value="">', esc_attr( $name ) );
8873 + }
8874 +
8875 + /**
7537 8876 * AJAX: Export settings
7538 8877 */
7539 8878 public function ajax_export_settings() {
7540 8879 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
@@ -7586,9 +8925,9 @@
7586 8925 wp_send_json_error( __( 'Invalid JSON format.', 'vigilante' ) );
7587 8926 }
7588 8927
7589 8928 // Sanitize imported data recursively
7590 - $imported = map_deep( $imported, 'sanitize_text_field' );
8929 + $imported = $this->sanitize_imported_settings( $imported );
7591 8930
7592 8931 // Validate structure: only sections and keys of the schema survive, and
7593 8932 // every value takes the type of its default. Until 2.11.0 this was an
7594 8933 // array_replace_recursive() of the file over the defaults, so any key in
@@ -7971,8 +9310,12 @@
7971 9310
7972 9311 delete_option( 'vigilante_last_integrity_results' );
7973 9312 delete_option( 'vigilante_last_integrity_scan' );
7974 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 +
7975 9318 /*
7976 9319 * A pending change to wp-config.php or the root .htaccess is closed by
7977 9320 * approving it, which takes the network. Clearing the results was one
7978 9321 * more way to close it without, until the next scan: the ignore list was
@@ -7979,22 +9322,27 @@
7979 9322 * shut in 2.11.8 and this button was left open, found by the cross
7980 9323 * review of 2.11.8. So for somebody who cannot approve, those entries
7981 9324 * stay and everything else goes.
7982 9325 */
7983 - if ( $this->critical_approval_locked() && is_array( $results ) && ! empty( $results['modified'] ) && is_array( $results['modified'] ) ) {
7984 - $critical = array_values(
7985 - array_filter(
7986 - $results['modified'],
7987 - function ( $item ) {
7988 - return is_array( $item ) && 'critical_config' === ( $item['type'] ?? '' );
7989 - }
7990 - )
7991 - );
9326 + // The findings about Vigilant's own files stay too: they report for the
9327 + // whole network, and ignoring them already takes network rights.
9328 + if ( $this->critical_approval_locked() && is_array( $results ) ) {
9329 + $kept = array();
9330 + $found = false;
9331 + foreach ( array( 'modified', 'missing', 'suspicious', 'extra' ) as $bucket ) {
9332 + $kept[ $bucket ] = array_values(
9333 + array_filter(
9334 + isset( $results[ $bucket ] ) && is_array( $results[ $bucket ] ) ? $results[ $bucket ] : array(),
9335 + function ( $item ) {
9336 + return is_array( $item ) && in_array( $item['type'] ?? '', array( 'critical_config', 'vigilante_self' ), true );
9337 + }
9338 + )
9339 + );
9340 + $found = $found || ! empty( $kept[ $bucket ] );
9341 + }
7992 9342
7993 - if ( $critical ) {
7994 - $results['modified'] = $critical;
7995 - $results['suspicious'] = array();
7996 - $results['extra'] = array();
9343 + if ( $found ) {
9344 + $results = array_merge( $results, $kept );
7997 9345 update_option( 'vigilante_last_integrity_results', $results );
7998 9346 update_option( 'vigilante_last_integrity_scan', $scanned_at ? $scanned_at : time() );
7999 9347 }
8000 9348 }
@@ -8019,9 +9367,29 @@
8019 9367 if ( ! current_user_can( 'manage_options' ) ) {
8020 9368 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
8021 9369 }
8022 9370
8023 - $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'] ) ) : '';
8024 9392
8025 9393 if ( empty( $file ) ) {
8026 9394 wp_send_json_error( __( 'No file specified.', 'vigilante' ) );
8027 9395 }
@@ -8031,8 +9399,17 @@
8031 9399 if ( $this->critical_approval_locked() && in_array( $file, array( 'wp-config.php', '.htaccess' ), true ) ) {
8032 9400 wp_send_json_error( $this->critical_approval_notice() );
8033 9401 }
8034 9402
9403 + // Vigilant's own files are never ignored, on any site: silencing the
9404 + // check that says the security plugin was changed is the one button
9405 + // an attacker would want on this screen. Since 3.0.0 those findings
9406 + // are not rows of these tables either, so nothing in the interface
9407 + // sends them here; this is the door, not the label.
9408 + if ( Vigilante_Self_Integrity::is_own_file_path( $file ) ) {
9409 + wp_send_json_error( __( 'Findings about Vigilant own files cannot be ignored. File Integrity explains what each one means and how to repair it.', 'vigilante' ) );
9410 + }
9411 +
8035 9412 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database );
8036 9413 $file_integrity->ignore_file( $file );
8037 9414
8038 9415 // Also remove the file from stored scan results so UI updates
@@ -8065,9 +9442,9 @@
8065 9442 if ( ! current_user_can( 'manage_options' ) ) {
8066 9443 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
8067 9444 }
8068 9445
8069 - $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'] ) ) : '';
8070 9447
8071 9448 if ( empty( $file ) ) {
8072 9449 wp_send_json_error( __( 'No file specified.', 'vigilante' ) );
8073 9450 }
@@ -8097,13 +9474,15 @@
8097 9474 wp_send_json_error( __( 'Invalid request.', 'vigilante' ) );
8098 9475 }
8099 9476
8100 9477 $files = array();
8101 - $shared = $this->critical_approval_locked() ? array( 'wp-config.php', '.htaccess' ) : array();
9478 + $locked = $this->critical_approval_locked();
9479 + $shared = $locked ? array( 'wp-config.php', '.htaccess' ) : array();
8102 9480 foreach ( $raw_files as $f ) {
8103 - $clean = sanitize_text_field( $f );
8104 - // Same rule as ajax_ignore_file() for the two shared files.
8105 - if ( '' !== $clean && ! in_array( $clean, $shared, true ) ) {
9481 + $clean = is_string( $f ) ? wp_strip_all_tags( $f ) : '';
9482 + // Same rule as ajax_ignore_file(): the two shared files when the
9483 + // network locks them, and Vigilant's own files always.
9484 + if ( '' !== $clean && ! in_array( $clean, $shared, true ) && ! Vigilante_Self_Integrity::is_own_file_path( $clean ) ) {
8106 9485 $files[] = $clean;
8107 9486 }
8108 9487 }
8109 9488
@@ -8167,9 +9546,9 @@
8167 9546 }
8168 9547
8169 9548 $files = array();
8170 9549 foreach ( $raw_files as $f ) {
8171 - $clean = sanitize_text_field( $f );
9550 + $clean = is_string( $f ) ? wp_strip_all_tags( $f ) : '';
8172 9551 if ( '' !== $clean ) {
8173 9552 $files[] = $clean;
8174 9553 }
8175 9554 }
@@ -8291,13 +9670,99 @@
8291 9670 if ( ! current_user_can( 'manage_options' ) ) {
8292 9671 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
8293 9672 }
8294 9673
8295 - // Get security grade from settings (not from actual HTTP request)
8296 - $security_headers = new Vigilante_Security_Headers( $this->settings );
8297 - $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 + }
8298 9678
8299 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 + );
8300 9765 }
8301 9766
8302 9767 /**
8303 9768 * Sanitize section data (kept for compatibility)