PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.2
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.2
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 +1069 -44 2.11.10 → 3.0.2 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,8 +1008,28 @@
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 *
930 1034 * @param array $options Plugin options.
931 1035 * @return int Security score.
@@ -983,10 +1087,10 @@
983 1087 // Max attempts configured
984 1088 if ( isset( $login['max_attempts'] ) && $login['max_attempts'] <= 5 ) {
985 1089 $score += 3;
986 1090 }
987 - // XML-RPC disabled
988 - if ( ! empty( $login['disable_xmlrpc'] ) ) {
1091 + // XML-RPC blocked, whole or its pingback methods
1092 + if ( $this->is_xmlrpc_protected( $options ) ) {
989 1093 $score += 3;
990 1094 }
991 1095 // 2FA enabled
992 1096 if ( ! empty( $login['two_factor']['enabled'] ) ) {
@@ -1045,8 +1149,28 @@
1045 1149 $score += 3;
1046 1150 }
1047 1151 }
1048 1152
1153 + /*
1154 + * Self-protection (15 points). Having it on is configuration, which is
1155 + * what this card measures; the state of the last check caps the card,
1156 + * because a plugin whose own files were changed is not well configured
1157 + * in any useful sense, whatever the rest of the settings say. The
1158 + * environment block below already mixes measured state into this score
1159 + * (WP_DEBUG, insecure usernames), so the card keeps its meaning.
1160 + */
1161 + $max_score += 15;
1162 + $self_enabled = Vigilante_Self_Integrity::is_on();
1163 + $self_tone = Vigilante_Self_Integrity::tone( Vigilante_Self_Integrity::display_state(), $self_enabled );
1164 + if ( $self_enabled ) {
1165 + $score += 10;
1166 + // The five points are for a check that ran and came out clean: a
1167 + // site that has never checked itself has not earned them.
1168 + if ( in_array( $self_tone, array( 'ok', 'info' ), true ) ) {
1169 + $score += 5;
1170 + }
1171 + }
1172 +
1049 1173 // Environment checks (8 points) - penalize insecure server configuration
1050 1174 $max_score += 8;
1051 1175 $env_score = 8;
1052 1176
@@ -1062,9 +1186,14 @@
1062 1186 }
1063 1187
1064 1188 $score += max( 0, $env_score );
1065 1189
1066 - return $max_score > 0 ? round( ( $score / $max_score ) * 100 ) : 0;
1190 + $percent = $max_score > 0 ? (int) round( ( $score / $max_score ) * 100 ) : 0;
1191 + if ( in_array( $self_tone, array( 'critical', 'off' ), true ) ) {
1192 + // Same cap and same reason as the Security Check score.
1193 + $percent = min( $percent, Vigilante_Security_Analyzer::SCORE_CAP_ON_TAMPER );
1194 + }
1195 + return $percent;
1067 1196 }
1068 1197
1069 1198 /**
1070 1199 * Get security recommendations based on current settings
@@ -1074,8 +1203,51 @@
1074 1203 */
1075 1204 private function get_security_recommendations( $options ) {
1076 1205 $recommendations = array();
1077 1206
1207 + // Self-protection first: if Vigilant itself cannot be trusted, nothing
1208 + // else on this card means much.
1209 + $vg_self_enabled = Vigilante_Self_Integrity::is_on();
1210 + $vg_self_tone = Vigilante_Self_Integrity::tone( Vigilante_Self_Integrity::display_state(), $vg_self_enabled );
1211 + if ( ! $vg_self_enabled ) {
1212 + $recommendations[] = array(
1213 + 'icon' => 'shield',
1214 + 'priority' => 'high',
1215 + 'tab' => 'file-integrity',
1216 + 'message' => __( 'Turn Vigilant self-protection on, so a change to Vigilant own files does not go unnoticed.', 'vigilante' ),
1217 + );
1218 + } elseif ( 'off' === $vg_self_tone ) {
1219 + $recommendations[] = array(
1220 + 'icon' => 'warning',
1221 + 'priority' => 'critical',
1222 + 'tab' => 'file-integrity',
1223 + 'message' => __( 'Something on this site switched Vigilant self-protection off: File Integrity says which file does it.', 'vigilante' ),
1224 + );
1225 + } elseif ( 'critical' === $vg_self_tone ) {
1226 + $recommendations[] = array(
1227 + 'icon' => 'warning',
1228 + 'priority' => 'critical',
1229 + 'tab' => 'file-integrity',
1230 + 'message' => __( 'Vigilant own files have been changed: repair Vigilant from File Integrity before anything else.', 'vigilante' ),
1231 + );
1232 + } elseif ( 'warning' === $vg_self_tone ) {
1233 + $recommendations[] = array(
1234 + 'icon' => 'shield',
1235 + 'priority' => 'high',
1236 + 'tab' => 'file-integrity',
1237 + 'message' => __( 'Vigilant self-protection needs your attention: File Integrity says what it found and what to do.', 'vigilante' ),
1238 + );
1239 + } elseif ( 'none' === $vg_self_tone ) {
1240 + // The score holds back the points of a check that has not run yet,
1241 + // so the card has to say why instead of just showing a lower number.
1242 + $recommendations[] = array(
1243 + 'icon' => 'shield',
1244 + 'priority' => 'high',
1245 + 'tab' => 'file-integrity',
1246 + 'message' => __( 'Vigilant has not checked its own files yet: run a scan from File Integrity.', 'vigilante' ),
1247 + );
1248 + }
1249 +
1078 1250 // Critical: Firewall disabled
1079 1251 if ( empty( $options['modules']['firewall'] ) ) {
1080 1252 $recommendations[] = array(
1081 1253 'icon' => 'warning',
@@ -1158,14 +1330,14 @@
1158 1330 );
1159 1331 }
1160 1332
1161 1333 // Low: XML-RPC enabled (only if login security is active)
1162 - if ( ! empty( $options['modules']['login_security'] ) && empty( $login['disable_xmlrpc'] ) ) {
1334 + if ( ! empty( $options['modules']['login_security'] ) && ! $this->is_xmlrpc_protected( $options ) ) {
1163 1335 $recommendations[] = array(
1164 1336 'icon' => 'info',
1165 1337 'priority' => 'low',
1166 1338 'message' => __( 'Disable XML-RPC if not needed (reduces attack surface).', 'vigilante' ),
1167 - 'tab' => 'login',
1339 + 'tab' => 'wp-hardening',
1168 1340 );
1169 1341 }
1170 1342
1171 1343 // Low: Strong passwords not enforced (only if user security is active)
@@ -1478,8 +1650,9 @@
1478 1650 // File Integrity
1479 1651 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 1652 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 1653 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' ) ),
1654 + 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 1655 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 1656 // Security Audit
1484 1657 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 1658 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 +1805,9 @@
1632 1805 );
1633 1806
1634 1807 wp_localize_script( 'vigilante-admin', 'vigilanteAdmin', array(
1635 1808 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
1809 + 'selfBoxUrl' => esc_url( admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' ) ),
1636 1810 'nonce' => wp_create_nonce( 'vigilante_admin_nonce' ),
1637 1811 'currentUserId' => get_current_user_id(),
1638 1812 'logoutUrl' => wp_logout_url( wp_login_url() ),
1639 1813 'adminUrl' => admin_url( 'admin.php?page=vigilante' ),
@@ -1681,8 +1855,13 @@
1681 1855 'file' => __( 'File', 'vigilante' ),
1682 1856 'reason' => __( 'Reason', 'vigilante' ),
1683 1857 'type' => __( 'Type', 'vigilante' ),
1684 1858 'unknown' => __( 'Unknown', 'vigilante' ),
1859 + 'selfType' => __( 'Vigilant (self)', 'vigilante' ),
1860 + 'logWhatHappened' => __( 'What happened', 'vigilante' ),
1861 + 'logWhatItMeans' => __( 'What it means', 'vigilante' ),
1862 + 'logWhatToDo' => __( 'What to do', 'vigilante' ),
1863 + 'logSelfSeeDetails' => __( 'Open File Integrity for the full detail', 'vigilante' ),
1685 1864 'modifiedFiles' => __( 'Modified Files', 'vigilante' ),
1686 1865 'modifiedDescription' => __( 'These files (apparently) differ from the original WordPress or plugin versions.', 'vigilante' ),
1687 1866 'extraFiles' => __( 'Extra Files', 'vigilante' ),
1688 1867 'extra' => __( 'Extra', 'vigilante' ),
@@ -1934,9 +2113,89 @@
1934 2113
1935 2114 /**
1936 2115 * Show admin notices
1937 2116 */
2117 + /**
2118 + * Self-protection notice.
2119 + *
2120 + * A change to Vigilant own files is shown on every admin screen, because
2121 + * waiting for someone to open the plugin is exactly what an attacker who
2122 + * patched it would want. A warning is shown only on Vigilant screens, so
2123 + * the notice that matters is not diluted. Neither is dismissible, both are
2124 + * for administrators only, and on a network the steps depend on whether
2125 + * the person can install plugins at all.
2126 + */
2127 + public function maybe_show_self_protection_notice() {
2128 + if ( ! current_user_can( 'manage_options' ) ) {
2129 + return;
2130 + }
2131 + $summary = $this->self_integrity_summary();
2132 + $tone = $summary['tone'];
2133 + if ( ! in_array( $tone, array( 'critical', 'off', 'warning' ), true ) ) {
2134 + return;
2135 + }
2136 +
2137 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the screen slug to decide where a notice is shown; no action taken.
2138 + $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
2139 + $on_vigilante = ( 0 === strpos( $page, 'vigilante' ) );
2140 + if ( 'warning' === $tone && ! $on_vigilante ) {
2141 + return;
2142 + }
2143 + $alarma = in_array( $tone, array( 'critical', 'off' ), true );
2144 +
2145 + $link = admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' );
2146 + $network = is_multisite() && ! current_user_can( 'update_plugins' );
2147 + ?>
2148 + <div class="notice notice-<?php echo $alarma ? 'error' : 'warning'; ?> vigilante-self-notice">
2149 + <p class="vigilante-self-notice-title">
2150 + <span class="dashicons dashicons-shield" aria-hidden="true"></span>
2151 + <strong>
2152 + <?php
2153 + if ( 'critical' === $tone ) {
2154 + esc_html_e( 'Vigilant detected changes in its own files', 'vigilante' );
2155 + } elseif ( 'off' === $tone ) {
2156 + esc_html_e( 'Vigilant self-protection is switched off by code', 'vigilante' );
2157 + } else {
2158 + echo esc_html( $this->self_integrity_headline( $tone, $summary['state'] ) );
2159 + }
2160 + ?>
2161 + </strong>
2162 + </p>
2163 + <p class="vigilante-self-notice-text">
2164 + <span>
2165 + <?php
2166 + if ( 'critical' === $tone ) {
2167 + esc_html_e( 'Your security plugin may have been tampered with, and while that is true nothing it reports can be trusted.', 'vigilante' );
2168 + } elseif ( 'off' === $tone ) {
2169 + esc_html_e( 'Nothing is checking that Vigilant own files are intact. File Integrity says which file switches it off.', 'vigilante' );
2170 + } else {
2171 + esc_html_e( 'File Integrity says what was found, what it means and what to do about it.', 'vigilante' );
2172 + }
2173 + if ( $network ) {
2174 + 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' );
2175 + }
2176 + ?>
2177 + </span>
2178 + <?php
2179 + /*
2180 + * One button, on the same line as the text it belongs to, and it
2181 + * leads to the whole story. Repairing from a notice, without
2182 + * seeing which files, what it means and what it will do, is
2183 + * asking someone to fix what they have not read: the repair
2184 + * button lives in the card, after all that.
2185 + */
2186 + ?>
2187 + <a class="button button-primary button-small" href="<?php echo esc_url( $link ); ?>">
2188 + <?php esc_html_e( 'See what to do', 'vigilante' ); ?>
2189 + </a>
2190 + </p>
2191 + </div>
2192 + <?php
2193 + }
2194 +
1938 2195 public function show_admin_notices() {
2196 + $this->maybe_show_self_protection_notice();
2197 +
1939 2198 // Activation notice
1940 2199 if ( get_transient( 'vigilante_activated' ) ) {
1941 2200 ?>
1942 2201 <div class="notice notice-success is-dismissible vigilante-activation-notice">
@@ -2439,8 +2698,22 @@
2439 2698 $categories = isset( $last_scan['categories'] ) && is_array( $last_scan['categories'] ) ? $last_scan['categories'] : array();
2440 2699 $weekly_enabled = ! isset( $analyzer_settings['weekly_scan_enabled'] ) || ! empty( $analyzer_settings['weekly_scan_enabled'] );
2441 2700 $email_enabled = ! empty( $analyzer_settings['email_on_regression'] );
2442 2701
2702 + /*
2703 + * Self-protection caps the score, and it does so here and not only in
2704 + * the stored report: the report is reused until the next Security
2705 + * Check, so tampering found after it ran would otherwise be shown next
2706 + * to the score the site earned before it happened.
2707 + */
2708 + $self_summary = $this->self_integrity_summary();
2709 + $self_capped = in_array( $self_summary['tone'], array( 'critical', 'off' ), true )
2710 + || ( 'self_integrity' === ( isset( $last_scan['capped_by'] ) ? $last_scan['capped_by'] : '' ) );
2711 + if ( $self_capped && $has_data && $score > Vigilante_Security_Analyzer::SCORE_CAP_ON_TAMPER ) {
2712 + $score = Vigilante_Security_Analyzer::SCORE_CAP_ON_TAMPER;
2713 + $grade = 'E';
2714 + }
2715 +
2443 2716 $quality = self::analyzer_quality_tag( $score );
2444 2717 ?>
2445 2718 <div class="vigilante-analyzer" id="vigilante-analyzer"
2446 2719 data-has-data="<?php echo $has_data ? '1' : '0'; ?>">
@@ -2450,9 +2723,9 @@
2450 2723 <span class="dashicons dashicons-shield-alt" aria-hidden="true"></span>
2451 2724 <?php esc_html_e( 'Security Check', 'vigilante' ); ?>
2452 2725 </h2>
2453 2726 <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' ); ?>
2727 + <?php esc_html_e( 'On-demand audit of what an attacker would see right now, plus internal checks impossible from the outside.', 'vigilante' ); ?>
2455 2728 </p>
2456 2729 <p class="vigilante-analyzer-last-scan" data-role="ran-at">
2457 2730 <span class="dashicons dashicons-clock" aria-hidden="true"></span>
2458 2731 <?php echo esc_html( $ran_at_human ); ?>
@@ -2465,8 +2738,19 @@
2465 2738 </button>
2466 2739 </div>
2467 2740 </div>
2468 2741
2742 + <?php if ( $self_capped ) : ?>
2743 + <p class="vigilante-analyzer-capped">
2744 + <span class="dashicons dashicons-shield" aria-hidden="true"></span>
2745 + <strong><?php esc_html_e( 'This score is not reliable right now.', 'vigilante' ); ?></strong>
2746 + <?php esc_html_e( 'Vigilant own files have been changed, and every other result on this page is produced by that same code. The score is held at the bottom of the scale until the files verify clean again.', 'vigilante' ); ?>
2747 + <a href="<?php echo esc_url( admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' ) ); ?>">
2748 + <?php esc_html_e( 'See what to do', 'vigilante' ); ?>
2749 + </a>
2750 + </p>
2751 + <?php endif; ?>
2752 +
2469 2753 <div class="vigilante-analyzer-summary">
2470 2754 <div class="vigilante-analyzer-score-card">
2471 2755 <?php if ( $has_data && $grade ) : ?>
2472 2756 <div class="vigilante-score-circle vigilante-grade-<?php echo esc_attr( strtolower( $grade ) ); ?>">
@@ -2840,8 +3124,58 @@
2840 3124
2841 3125 /**
2842 3126 * Render dashboard tab
2843 3127 */
3128 + /**
3129 + * One line strip at the top of the Dashboard tab: the state of Vigilant own
3130 + * files, above the Configuration Score and the Security Check, because
3131 + * nothing else on this screen means much while it is red.
3132 + */
3133 + private function render_self_protection_strip() {
3134 + $summary = $this->self_integrity_summary();
3135 + $tone = $summary['tone'];
3136 + $state = $summary['state'];
3137 + $count = count( $summary['findings'] );
3138 + $link = admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' );
3139 + ?>
3140 + <div class="vigilante-self-strip vigilante-self-strip--<?php echo esc_attr( $tone ); ?>">
3141 + <span class="dashicons dashicons-shield" aria-hidden="true"></span>
3142 + <strong><?php esc_html_e( 'Vigilant self-protection', 'vigilante' ); ?></strong>
3143 + <span class="vigilante-self-strip-state"><?php echo esc_html( $this->self_integrity_headline( $tone, $state ) ); ?></span>
3144 + <?php if ( $count > 0 ) : ?>
3145 + <span class="vigilante-self-strip-count">
3146 + <?php
3147 + printf(
3148 + /* translators: %d: number of findings about Vigilant own files */
3149 + esc_html( _n( '%d finding', '%d findings', $count, 'vigilante' ) ),
3150 + (int) $count
3151 + );
3152 + ?>
3153 + </span>
3154 + <?php elseif ( ! empty( $state['last_check'] ) && 'off' !== $tone ) : ?>
3155 + <span class="vigilante-self-strip-count">
3156 + <?php
3157 + printf(
3158 + /* translators: %s: human time difference, like "2 hours" */
3159 + esc_html__( 'checked %s ago', 'vigilante' ),
3160 + esc_html( human_time_diff( (int) $state['last_check'], time() ) )
3161 + );
3162 + ?>
3163 + </span>
3164 + <?php endif; ?>
3165 + <a href="<?php echo esc_url( $link ); ?>">
3166 + <?php
3167 + if ( in_array( $tone, array( 'critical', 'off', 'warning' ), true ) ) {
3168 + esc_html_e( 'See what to do', 'vigilante' );
3169 + } else {
3170 + esc_html_e( 'See details', 'vigilante' );
3171 + }
3172 + ?>
3173 + </a>
3174 + </div>
3175 + <?php
3176 + }
3177 +
2844 3178 private function render_tab_dashboard() {
2845 3179 $options = $this->settings->get_all_options();
2846 3180 $module_labels = $this->settings->get_module_labels();
2847 3181 $module_descriptions = $this->settings->get_module_descriptions();
@@ -2861,8 +3195,9 @@
2861 3195 $analyzer_categories_def = Vigilante_Security_Analyzer::get_categories();
2862 3196 $analyzer_settings = isset( $options['security_analyzer'] ) ? $options['security_analyzer'] : array();
2863 3197 ?>
2864 3198 <div class="vigilante-dashboard">
3199 + <?php $this->render_self_protection_strip(); ?>
2865 3200 <div class="vigilante-status-card">
2866 3201 <h2><?php esc_html_e( 'Configuration Score', 'vigilante' ); ?></h2>
2867 3202 <p class="vigilante-score-kind description">
2868 3203 <?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' ); ?>
@@ -3530,10 +3865,10 @@
3530 3865 <div id="vigilante-xff-chain-notice" class="notice notice-warning inline" style="margin:10px 0 16px;padding:8px 12px;">
3531 3866 <p style="margin:0;">
3532 3867 <?php
3533 3868 printf(
3534 - /* translators: 1: address Vigilant reads now, 2: address earlier versions read */
3535 - esc_html__( 'Your own request reaches the site with more than one public address in X-Forwarded-For. Vigilant reads the last one, %1$s, which is the one your proxy added; up to version 2.11.7 it read the first one, %2$s, which a visitor can write. If %1$s belongs to a CDN or a load balancer rather than to you, every visitor shares it for rate limiting, login lockouts and the IP lists: choose the header of that CDN in Visitor IP detection, such as CF-Connecting-IP for Cloudflare.', 'vigilante' ),
3869 + /* translators: 1: last address in the header, the one Vigilant reads, 2: first address in the header, which a visitor can write */
3870 + esc_html__( 'Your own request reaches the site with more than one public address in X-Forwarded-For. Vigilant reads the last one, %1$s, which is the one your proxy added, and not the first one, %2$s, which a visitor can write. If %1$s belongs to a CDN or a load balancer rather than to you, every visitor shares it for rate limiting, login lockouts and the IP lists: choose the header of that CDN in Visitor IP detection, such as CF-Connecting-IP for Cloudflare.', 'vigilante' ),
3536 3871 esc_html( $xff_readings['now'] ),
3537 3872 esc_html( $xff_readings['before'] )
3538 3873 );
3539 3874 ?>
@@ -4122,8 +4457,9 @@
4122 4457 <tr>
4123 4458 <th scope="row"><?php esc_html_e( 'Enforce for roles', 'vigilante' ); ?></th>
4124 4459 <td>
4125 4460 <div class="vigilante-2fa-roles">
4461 + <?php $this->list_sentinel( 'login_security[two_factor][enforced_roles]' ); ?>
4126 4462 <?php foreach ( $all_roles as $role_slug => $role_data ) :
4127 4463 $user_count = count( get_users( array( 'role' => $role_slug, 'fields' => 'ID' ) ) );
4128 4464 ?>
4129 4465 <label>
@@ -4151,8 +4487,9 @@
4151 4487 autocomplete="off">
4152 4488 <div class="vigilante-2fa-search-results"></div>
4153 4489 </div>
4154 4490 <div class="vigilante-2fa-excluded-users">
4491 + <?php $this->list_sentinel( 'login_security[two_factor][excluded_users]' ); ?>
4155 4492 <?php
4156 4493 foreach ( $excluded as $user_id ) :
4157 4494 $user = get_user_by( 'ID', $user_id );
4158 4495 if ( ! $user ) continue;
@@ -4333,9 +4670,9 @@
4333 4670 ?>
4334 4671 <div class="vigilante-settings-section" id="vigilante-headers-recovery">
4335 4672 <h2><?php esc_html_e( 'Recover your previous header settings', 'vigilante' ); ?></h2>
4336 4673 <p>
4337 - <?php esc_html_e( 'Updating to 2.9.8 reset this tab to factory values: the migration replaced the whole section instead of merging into it. Your server kept sending the right headers, because the .htaccess had not been rewritten yet, so Vigilant saved a copy of that file before touching it. These are the settings it found in that copy.', 'vigilante' ); ?>
4674 + <?php esc_html_e( 'An earlier update reset this tab to factory values: the migration replaced the whole section instead of merging into it. Your server kept sending the right headers, because the .htaccess had not been rewritten yet, so Vigilant saved a copy of that file before touching it. These are the settings it found in that copy.', 'vigilante' ); ?>
4338 4675 </p>
4339 4676 <?php if ( $taken ) : ?>
4340 4677 <p class="description">
4341 4678 <?php
@@ -4456,8 +4793,9 @@
4456 4793 <label>
4457 4794 <input type="checkbox" name="security_headers[csp][enabled]" value="1" <?php checked( ! empty( $options['csp']['enabled'] ) ); ?>>
4458 4795 <?php esc_html_e( 'Enable Content Security Policy', 'vigilante' ); ?>
4459 4796 </label>
4797 + <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 4798 </td>
4461 4799 </tr>
4462 4800 <tr>
4463 4801 <th scope="row"><?php esc_html_e( 'Report Only Mode', 'vigilante' ); ?></th>
@@ -4808,8 +5146,9 @@
4808 5146 </tr>
4809 5147 <tr>
4810 5148 <th scope="row"><?php esc_html_e( 'Apply Password Rules To', 'vigilante' ); ?></th>
4811 5149 <td>
5150 + <?php $this->list_sentinel( 'user_security[password_policy][affected_roles]' ); ?>
4812 5151 <?php foreach ( wp_roles()->get_names() as $role_slug => $role_name ) : ?>
4813 5152 <label style="display:block;margin-bottom:5px;">
4814 5153 <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 5154 <?php echo esc_html( translate_user_role( $role_name ) ); ?>
@@ -5049,8 +5388,9 @@
5049 5388 <td>
5050 5389 <?php
5051 5390 $affected_roles = $password_exp['affected_roles'] ?? array( 'administrator', 'editor' );
5052 5391 $all_roles = wp_roles()->get_names();
5392 + $this->list_sentinel( 'user_security[password_expiration][affected_roles]' );
5053 5393 foreach ( $all_roles as $role_slug => $role_name ) :
5054 5394 ?>
5055 5395 <label style="display: block; margin-bottom: 5px;">
5056 5396 <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 +5412,9 @@
5072 5412 autocomplete="off">
5073 5413 <div class="vigilante-pwexp-search-results"></div>
5074 5414 </div>
5075 5415 <div class="vigilante-pwexp-excluded-users">
5416 + <?php $this->list_sentinel( 'user_security[password_expiration][excluded_users]' ); ?>
5076 5417 <?php
5077 5418 foreach ( $pwexp_excluded as $pwexp_excluded_id ) :
5078 5419 $excluded_user = get_user_by( 'ID', $pwexp_excluded_id );
5079 5420 if ( ! $excluded_user ) {
@@ -6217,8 +6558,19 @@
6217 6558 'is_ip_blacklisted' => ( '' !== $ip_val && in_array( $ip_val, $ip_blacklist, true ) ),
6218 6559 'is_ua_whitelisted' => ( '' !== $ua_val && in_array( $ua_val, $ua_whitelist, true ) ),
6219 6560 'is_ua_blacklisted' => ( '' !== $ua_val && in_array( $ua_val, $ua_blacklist, true ) ),
6220 6561 );
6562 + // Self-protection entries carry what happened, what
6563 + // it means and what to do, from the same catalogue
6564 + // the File Integrity box uses.
6565 + $vg_self_event = Vigilante_Self_Integrity_Guidance::for_log_event(
6566 + (string) ( $log->event_action ?? '' ),
6567 + $log->extra_data ?? '',
6568 + (string) ( $log->severity ?? 'info' )
6569 + );
6570 + if ( null !== $vg_self_event ) {
6571 + $details['self'] = $vg_self_event;
6572 + }
6221 6573 $display_type = isset( $type_labels[ $log->event_type ] ) ? $type_labels[ $log->event_type ] : $log->event_type;
6222 6574 $display_severity = isset( $severity_labels[ $log->severity ] ) ? $severity_labels[ $log->severity ] : $log->severity;
6223 6575 ?>
6224 6576 <tr class="vigilante-severity-<?php echo esc_attr( $log->severity ); ?>">
@@ -6261,8 +6613,403 @@
6261 6613
6262 6614 /**
6263 6615 * Render File Integrity tab
6264 6616 */
6617 + /**
6618 + * Findings of the self-check grouped by the case that explains them, worst
6619 + * first: ten modified files are one case with ten paths, not ten copies of
6620 + * the same explanation.
6621 + *
6622 + * @param array $findings Findings from the state.
6623 + * @return array
6624 + */
6625 + private function self_findings_by_case( $findings ) {
6626 + $groups = array();
6627 + foreach ( (array) $findings as $finding ) {
6628 + if ( ! is_array( $finding ) || 'info' === ( $finding['severity'] ?? '' ) ) {
6629 + continue;
6630 + }
6631 + $guidance = Vigilante_Self_Integrity_Guidance::for_finding( $finding );
6632 + $key = $guidance['key'];
6633 + if ( ! isset( $groups[ $key ] ) ) {
6634 + $guidance['files'] = array();
6635 + $guidance['severity'] = 'warning';
6636 + $groups[ $key ] = $guidance;
6637 + }
6638 + $file = isset( $finding['file'] ) ? (string) $finding['file'] : '';
6639 + if ( '' !== $file && ! in_array( $file, $groups[ $key ]['files'], true ) ) {
6640 + $groups[ $key ]['files'][] = $file;
6641 + }
6642 + if ( 'critical' === ( $finding['severity'] ?? '' ) ) {
6643 + $groups[ $key ]['severity'] = 'critical';
6644 + }
6645 + }
6646 + uasort(
6647 + $groups,
6648 + function ( $a, $b ) {
6649 + $rank = array( 'critical' => 0, 'warning' => 1 );
6650 + $ra = isset( $rank[ $a['severity'] ] ) ? $rank[ $a['severity'] ] : 2;
6651 + $rb = isset( $rank[ $b['severity'] ] ) ? $rank[ $b['severity'] ] : 2;
6652 + return $ra - $rb;
6653 + }
6654 + );
6655 + return $groups;
6656 + }
6657 +
6658 + /**
6659 + * Human label for the context that ran the last self-check.
6660 + *
6661 + * @param string $context Stored context.
6662 + * @return string
6663 + */
6664 + private function self_context_label( $context ) {
6665 + switch ( (string) $context ) {
6666 + case 'scan':
6667 + return __( 'during a file integrity scan', 'vigilante' );
6668 + case 'upgrader':
6669 + return __( 'right after updating Vigilant', 'vigilante' );
6670 + case 'version_change':
6671 + return __( 'after a version change made outside the updater', 'vigilante' );
6672 + case 'migration':
6673 + return __( 'while updating to this version', 'vigilante' );
6674 + case 'activation':
6675 + return __( 'when Vigilant was activated', 'vigilante' );
6676 + case 'watchdog':
6677 + return __( 'from the scheduled task watchdog', 'vigilante' );
6678 + }
6679 + return '';
6680 + }
6681 +
6682 + /**
6683 + * Short line for the badge of the box, the notices and the Dashboard strip.
6684 + *
6685 + * @param string $tone Tone from Vigilante_Self_Integrity::tone().
6686 + * @param array $state State.
6687 + * @return string
6688 + */
6689 + private function self_integrity_headline( $tone, $state ) {
6690 + $files = isset( $state['files_checked'] ) ? (int) $state['files_checked'] : 0;
6691 + $anchors = ( isset( $state['anchors'] ) && is_array( $state['anchors'] ) ) ? $state['anchors'] : array();
6692 + switch ( $tone ) {
6693 + case 'critical':
6694 + return __( 'Changes detected in Vigilant own files', 'vigilante' );
6695 + case 'warning':
6696 + return ( $files < 1 )
6697 + ? __( 'Vigilant could not check its own files', 'vigilante' )
6698 + : __( 'Vigilant self-protection needs your attention', 'vigilante' );
6699 + case 'off':
6700 + return __( 'Self-protection is switched off by code', 'vigilante' );
6701 + case 'none':
6702 + return __( 'Vigilant has not checked its own files yet', 'vigilante' );
6703 + }
6704 + return sprintf(
6705 + /* translators: 1: number of files verified, 2: number of references available, out of three */
6706 + __( 'Verified: %1$d files, %2$d of 3 references', 'vigilante' ),
6707 + $files,
6708 + count( array_filter( $anchors ) )
6709 + );
6710 + }
6711 +
6712 + /**
6713 + * Vigilant self-protection box: the first block of the File Integrity
6714 + * results, with its own colour by severity, what each finding means and
6715 + * how to fix it.
6716 + *
6717 + * It renders whether or not a scan has been stored, because the self-check
6718 + * also runs after every update and once a day: before 3.0.0 this lived in
6719 + * a line above the numeric cards of the last scan, where it was invisible
6720 + * and, without a stored scan, absent.
6721 + *
6722 + * @param array $fi_options File Integrity settings section.
6723 + */
6724 + /**
6725 + * Everything the screens need to say about self-protection, read once:
6726 + * the state, its findings grouped by case, and the tone that colours the
6727 + * box, the menu counter and the notices.
6728 + *
6729 + * @param array|null $fi_options File Integrity settings, read if not given.
6730 + * @return array { state, findings, groups, tone, enabled }
6731 + */
6732 + private function self_integrity_summary( $fi_options = null ) {
6733 + // Four to six screens ask for this in the same page load (menu, notice,
6734 + // box, strip, both scores). The option is cached by the core options
6735 + // layer, but the grouping and the tone are not: memoize per request.
6736 + static $cached = null;
6737 + if ( null !== $cached && ! is_array( $fi_options ) ) {
6738 + return $cached;
6739 + }
6740 + if ( ! is_array( $fi_options ) ) {
6741 + $fi_options = (array) $this->settings->get_section( 'file_integrity' );
6742 + }
6743 + $enabled = Vigilante_Self_Integrity::is_on();
6744 + $state = Vigilante_Self_Integrity::display_state();
6745 + $findings = Vigilante_Self_Integrity::state_findings( $state, $enabled );
6746 + $summary = array(
6747 + 'state' => $state,
6748 + 'findings' => $findings,
6749 + 'groups' => $this->self_findings_by_case( $findings ),
6750 + 'tone' => Vigilante_Self_Integrity::tone( $state, $enabled ),
6751 + 'enabled' => $enabled,
6752 + );
6753 + $cached = $summary;
6754 + return $summary;
6755 + }
6756 +
6757 + private function render_self_protection_box( $fi_options ) {
6758 + $summary = $this->self_integrity_summary( $fi_options );
6759 + $enabled = $summary['enabled'];
6760 + $state = $summary['state'];
6761 + $findings = $summary['findings'];
6762 + $groups = $summary['groups'];
6763 + $tone = $summary['tone'];
6764 + $total = isset( $state['last_findings_total'] ) ? (int) $state['last_findings_total'] : count( $findings );
6765 + $status = array(
6766 + 'status' => isset( $state['last_status'] ) ? (string) $state['last_status'] : '',
6767 + 'files' => isset( $state['files_checked'] ) ? (int) $state['files_checked'] : 0,
6768 + 'anchors' => ( isset( $state['anchors'] ) && is_array( $state['anchors'] ) ) ? $state['anchors'] : array(),
6769 + 'enabled' => $enabled,
6770 + 'has_run' => ! empty( $state['last_check'] ),
6771 + );
6772 +
6773 + /*
6774 + * Nothing to do, nothing to open: one line. A card with a big icon and
6775 + * folded sections for "everything is fine" is furniture, and furniture
6776 + * is what made the previous version of this invisible.
6777 + */
6778 + if ( empty( $groups ) ) {
6779 + $this->render_self_line( $tone, $state, $status );
6780 + return;
6781 + }
6782 +
6783 + $datetime_format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' );
6784 + $context_label = $this->self_context_label( isset( $state['last_context'] ) ? $state['last_context'] : '' );
6785 + $icon = ( 'critical' === $tone ) ? 'dashicons-shield' : 'dashicons-warning';
6786 + ?>
6787 + <div id="vigilante-section-fi-self" class="vigilante-settings-section vigilante-self-box vigilante-self-box--<?php echo esc_attr( $tone ); ?>">
6788 + <div class="vigilante-self-card">
6789 + <div class="vigilante-self-head">
6790 + <span class="vigilante-self-icon"><span class="dashicons <?php echo esc_attr( $icon ); ?>" aria-hidden="true"></span></span>
6791 + <div class="vigilante-self-head-text">
6792 + <h2><?php echo esc_html( $this->self_integrity_headline( $tone, $state ) ); ?></h2>
6793 + <p class="vigilante-self-meta">
6794 + <?php
6795 + printf(
6796 + /* translators: %s: date and time of the last self-check */
6797 + esc_html__( 'Checked on %s', 'vigilante' ),
6798 + esc_html( wp_date( $datetime_format, (int) $state['last_check'] ) )
6799 + );
6800 + if ( '' !== $context_label ) {
6801 + echo ', ' . esc_html( $context_label );
6802 + }
6803 + if ( $status['files'] > 0 ) {
6804 + echo ', ';
6805 + printf(
6806 + /* translators: 1: number of files checked, 2: number of references available, out of three */
6807 + esc_html__( '%1$d files against %2$d of 3 references', 'vigilante' ),
6808 + (int) $status['files'],
6809 + (int) count( array_filter( $status['anchors'] ) )
6810 + );
6811 + }
6812 + ?>
6813 + </p>
6814 + </div>
6815 + </div>
6816 +
6817 + <div class="vigilante-self-cases">
6818 + <?php
6819 + $first = true;
6820 + foreach ( $groups as $group ) {
6821 + $this->render_self_case( $group, $group['files'], $group['severity'], $first );
6822 + $first = false;
6823 + }
6824 +
6825 + if ( in_array( $tone, array( 'critical', 'warning' ), true ) ) {
6826 + $can_repair = class_exists( 'Vigilante_Self_Repair' ) && Vigilante_Self_Repair::can_repair();
6827 + if ( ! $can_repair ) {
6828 + if ( class_exists( 'Vigilante_Self_Repair' ) && ! Vigilante_Self_Repair::folder_is_the_distributed_one() ) {
6829 + $vg_reason = 'renamed';
6830 + } elseif ( ! wp_is_file_mod_allowed( 'capability_update_core' ) ) {
6831 + // With DISALLOW_FILE_MODS nobody can do it from the admin,
6832 + // not even a network administrator: telling a subsite admin
6833 + // to ask theirs would send them to someone equally blocked.
6834 + $vg_reason = 'no_caps';
6835 + } elseif ( is_multisite() && ! current_user_can( 'update_plugins' ) ) {
6836 + $vg_reason = 'network';
6837 + } else {
6838 + $vg_reason = 'no_caps';
6839 + }
6840 + $this->render_self_case(
6841 + array(
6842 + 'title' => __( 'How to repair it here', 'vigilante' ),
6843 + 'meaning' => __( 'This site does not let Vigilant replace its own files from this screen.', 'vigilante' ),
6844 + 'steps' => Vigilante_Self_Integrity_Guidance::manual_steps( $vg_reason ),
6845 + ),
6846 + array(),
6847 + '',
6848 + false
6849 + );
6850 + }
6851 + }
6852 + ?>
6853 + </div>
6854 +
6855 + <?php if ( $total > count( $findings ) ) : ?>
6856 + <p class="description">
6857 + <?php
6858 + printf(
6859 + /* translators: 1: findings shown, 2: findings found in total */
6860 + esc_html__( 'Showing %1$d of %2$d findings: the rest are of the same kind.', 'vigilante' ),
6861 + (int) count( $findings ),
6862 + (int) $total
6863 + );
6864 + ?>
6865 + </p>
6866 + <?php endif; ?>
6867 +
6868 + <?php
6869 + /*
6870 + * Reinstalling fixes files, not a filter someone wrote or a hook
6871 + * someone removed: the button only appears when at least one of the
6872 + * cases on screen is one a clean copy solves. Offering it next to
6873 + * "switched off by code" was telling the person to fix something
6874 + * else.
6875 + */
6876 + $vg_offer_repair = false;
6877 + foreach ( $groups as $vg_group ) {
6878 + if ( ! empty( $vg_group['repair'] ) ) {
6879 + $vg_offer_repair = true;
6880 + break;
6881 + }
6882 + }
6883 + ?>
6884 + <p class="vigilante-self-actions">
6885 + <?php if ( $vg_offer_repair && class_exists( 'Vigilante_Self_Repair' ) && Vigilante_Self_Repair::can_repair() ) : ?>
6886 + <a class="button button-primary" href="<?php echo esc_url( Vigilante_Self_Repair::action_url() ); ?>">
6887 + <?php esc_html_e( 'Repair Vigilant', 'vigilante' ); ?>
6888 + </a>
6889 + <span class="description">
6890 + <?php esc_html_e( 'It downloads a clean copy from WordPress.org, asks before changing anything, and keeps your settings and log.', 'vigilante' ); ?>
6891 + </span>
6892 + <?php else : ?>
6893 + <span class="description">
6894 + <?php esc_html_e( 'After fixing it, run a new scan with the Run Scan Now button above.', 'vigilante' ); ?>
6895 + </span>
6896 + <?php endif; ?>
6897 + </p>
6898 + </div>
6899 + </div>
6900 + <?php
6901 + }
6902 +
6903 + /**
6904 + * The same state when there is nothing to act on: one line inside the same
6905 + * card, so the first block of the results is always the same object.
6906 + *
6907 + * @param string $tone Tone from Vigilante_Self_Integrity::tone().
6908 + * @param array $state State.
6909 + * @param array $status Arguments for the guidance catalogue.
6910 + */
6911 + private function render_self_line( $tone, $state, $status ) {
6912 + $guidance = Vigilante_Self_Integrity_Guidance::for_status( $status );
6913 + $icon = ( 'ok' === $tone ) ? 'dashicons-yes-alt' : 'dashicons-shield';
6914 + ?>
6915 + <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">
6916 + <div class="vigilante-self-card vigilante-self-card--quiet">
6917 + <p class="vigilante-self-line">
6918 + <span class="dashicons <?php echo esc_attr( $icon ); ?>" aria-hidden="true"></span>
6919 + <strong><?php esc_html_e( 'Vigilant self-protection:', 'vigilante' ); ?></strong>
6920 + <span class="vigilante-self-line-state"><?php echo esc_html( $this->self_integrity_headline( $tone, $state ) ); ?></span>
6921 + <?php if ( ! empty( $state['last_check'] ) ) : ?>
6922 + <span class="vigilante-self-line-meta">
6923 + <?php
6924 + printf(
6925 + /* translators: %s: human time difference, like "2 hours" */
6926 + esc_html__( 'checked %s ago', 'vigilante' ),
6927 + esc_html( human_time_diff( (int) $state['last_check'], time() ) )
6928 + );
6929 + ?>
6930 + </span>
6931 + <?php endif; ?>
6932 + </p>
6933 + <?php if ( ! empty( $guidance['steps'] ) && 'none' === $tone ) : ?>
6934 + <p class="description vigilante-self-line-help"><?php echo esc_html( $guidance['steps'][0] ); ?></p>
6935 + <?php endif; ?>
6936 + </div>
6937 + </div>
6938 + <?php
6939 + }
6940 +
6941 + /**
6942 + * One case of the self-protection box, as a row that opens: what it is, how
6943 + * many files, its severity, and inside, the files, what it means and what to
6944 + * do. Plain HTML details, so there is no JavaScript between a finding about
6945 + * the security plugin and the person reading it.
6946 + *
6947 + * @param array $guidance Guidance entry (title, meaning, steps).
6948 + * @param array $files Paths this case was found in.
6949 + * @param string $severity critical|warning, empty for the how-to-repair row.
6950 + * @param bool $open Whether the row starts open.
6951 + */
6952 + private function render_self_case( $guidance, $files = array(), $severity = '', $open = false ) {
6953 + $shown = array_slice( (array) $files, 0, 20 );
6954 + $hidden = count( (array) $files ) - count( $shown );
6955 + ?>
6956 + <details class="vigilante-self-case vigilante-self-case--<?php echo esc_attr( '' !== $severity ? $severity : 'plain' ); ?>"<?php echo $open ? ' open' : ''; ?>>
6957 + <summary>
6958 + <span class="vigilante-self-case-title"><?php echo esc_html( $guidance['title'] ); ?></span>
6959 + <?php if ( ! empty( $files ) ) : ?>
6960 + <span class="vigilante-self-case-count">
6961 + <?php
6962 + printf(
6963 + /* translators: %d: number of files of this finding */
6964 + esc_html( _n( '%d file', '%d files', count( (array) $files ), 'vigilante' ) ),
6965 + (int) count( (array) $files )
6966 + );
6967 + ?>
6968 + </span>
6969 + <?php endif; ?>
6970 + <?php if ( '' !== $severity ) : ?>
6971 + <span class="vigilante-self-case-severity">
6972 + <?php echo esc_html( 'critical' === $severity ? __( 'critical', 'vigilante' ) : __( 'warning', 'vigilante' ) ); ?>
6973 + </span>
6974 + <?php endif; ?>
6975 + </summary>
6976 + <div class="vigilante-self-case-body">
6977 + <?php if ( ! empty( $shown ) ) : ?>
6978 + <ul class="vigilante-self-paths">
6979 + <?php foreach ( $shown as $file ) : ?>
6980 + <li><code><?php echo esc_html( $file ); ?></code></li>
6981 + <?php endforeach; ?>
6982 + <?php if ( $hidden > 0 ) : ?>
6983 + <li>
6984 + <?php
6985 + printf(
6986 + /* translators: %d: number of additional files */
6987 + esc_html__( 'and %d more', 'vigilante' ),
6988 + (int) $hidden
6989 + );
6990 + ?>
6991 + </li>
6992 + <?php endif; ?>
6993 + </ul>
6994 + <?php endif; ?>
6995 + <p class="vigilante-self-meaning">
6996 + <strong><?php esc_html_e( 'What it means:', 'vigilante' ); ?></strong>
6997 + <?php echo esc_html( $guidance['meaning'] ); ?>
6998 + </p>
6999 + <?php if ( ! empty( $guidance['steps'] ) ) : ?>
7000 + <p class="vigilante-self-todo"><strong><?php esc_html_e( 'What to do:', 'vigilante' ); ?></strong></p>
7001 + <ol class="vigilante-self-steps">
7002 + <?php foreach ( $guidance['steps'] as $step ) : ?>
7003 + <li><?php echo esc_html( $step ); ?></li>
7004 + <?php endforeach; ?>
7005 + </ol>
7006 + <?php endif; ?>
7007 + </div>
7008 + </details>
7009 + <?php
7010 + }
7011 +
6265 7012 private function render_tab_file_integrity() {
6266 7013 $is_disabled = $this->render_module_disabled_notice( 'file_integrity' );
6267 7014 $options = $this->settings->get_section( 'file_integrity' );
6268 7015 // On the main site of a network the critical-file scan is the network's
@@ -6273,8 +7020,32 @@
6273 7020 $last_scan = get_option( 'vigilante_last_integrity_scan' );
6274 7021 $last_results = get_option( 'vigilante_last_integrity_results' );
6275 7022 $ignored_files = get_option( 'vigilante_ignored_files', array() );
6276 7023
7024 + /*
7025 + * Vigilant own findings are shown in their own box, the first of the
7026 + * results, with what each one means and how to fix it. They are taken
7027 + * out of the generic tables here (and out of the counters above them)
7028 + * so the same finding is not reported twice and so no row of the
7029 + * security plugin's own files offers an Ignore button. The stored
7030 + * results keep them: the scan email reads its own section from there.
7031 + */
7032 + if ( is_array( $last_results ) ) {
7033 + foreach ( array( 'modified', 'suspicious', 'extra', 'missing' ) as $vg_bucket ) {
7034 + if ( empty( $last_results[ $vg_bucket ] ) || ! is_array( $last_results[ $vg_bucket ] ) ) {
7035 + continue;
7036 + }
7037 + $last_results[ $vg_bucket ] = array_values(
7038 + array_filter(
7039 + $last_results[ $vg_bucket ],
7040 + function ( $vg_item ) {
7041 + return ! ( is_array( $vg_item ) && 'vigilante_self' === ( $vg_item['type'] ?? '' ) );
7042 + }
7043 + )
7044 + );
7045 + }
7046 + }
7047 +
6277 7048 // Backward compat: convert old notify_on_changes to notify_level
6278 7049 $notify_level = $options['notify_level'] ?? '';
6279 7050 if ( empty( $notify_level ) ) {
6280 7051 $notify_level = ! empty( $options['notify_on_changes'] ) ? 'all' : 'disabled';
@@ -6457,8 +7228,10 @@
6457 7228 </form>
6458 7229
6459 7230 <div id="vigilante-scan-results" class="vigilante-settings-section" style="display:none;"></div>
6460 7231
7232 + <?php $this->render_self_protection_box( $options ); ?>
7233 +
6461 7234 <?php if ( $last_scan || $has_closed || $closed_last_check > 0 ) : ?>
6462 7235 <div id="vigilante-section-fi-last-scan" class="vigilante-settings-section">
6463 7236 <h2><?php esc_html_e( 'Last Scan Results', 'vigilante' ); ?></h2>
6464 7237 <?php if ( $last_scan ) : ?>
@@ -6583,8 +7356,11 @@
6583 7356 }
6584 7357 } else {
6585 7358 $file_path = (string) $item;
6586 7359 }
7360 + if ( 'vigilante_self' === $file_type ) {
7361 + $file_type = __( 'Vigilant (self)', 'vigilante' );
7362 + }
6587 7363 ?>
6588 7364 <tr>
6589 7365 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
6590 7366 <td><code style="color: #d63638;"><?php echo esc_html( $file_path ); ?></code></td>
@@ -6624,8 +7400,11 @@
6624 7400 foreach ( $last_results['extra'] as $item ) {
6625 7401 $file_path = is_array( $item ) ? ( $item['file'] ?? '' ) : (string) $item;
6626 7402 $file_reason = is_array( $item ) ? ( $item['reason'] ?? __( 'Unknown', 'vigilante' ) ) : __( 'Unknown', 'vigilante' );
6627 7403 $file_type = is_array( $item ) ? ( $item['type'] ?? 'unknown' ) : 'unknown';
7404 + if ( 'vigilante_self' === $file_type ) {
7405 + $file_type = __( 'Vigilant (self)', 'vigilante' );
7406 + }
6628 7407 ?>
6629 7408 <tr>
6630 7409 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
6631 7410 <td><code style="color: #b32d2e;"><?php echo esc_html( $file_path ); ?></code></td>
@@ -6656,8 +7435,19 @@
6656 7435 $regular_modified[] = $item;
6657 7436 }
6658 7437 }
6659 7438 }
7439 + // A missing file of Vigilant is critical, and the missing
7440 + // files of the generic scan have no table: it is listed with
7441 + // the modified files, or the tab said "All files passed" with
7442 + // a module deleted.
7443 + if ( $last_results && ! empty( $last_results['missing'] ) && is_array( $last_results['missing'] ) ) {
7444 + foreach ( $last_results['missing'] as $item ) {
7445 + if ( is_array( $item ) && 'vigilante_self' === ( $item['type'] ?? '' ) ) {
7446 + $regular_modified[] = $item;
7447 + }
7448 + }
7449 + }
6660 7450 ?>
6661 7451
6662 7452 <?php if ( ! empty( $critical_modified ) ) : ?>
6663 7453 <div class="vigilante-file-list vigilante-critical-config-files">
@@ -6856,8 +7646,11 @@
6856 7646 }
6857 7647 } else {
6858 7648 $file_path = (string) $item;
6859 7649 }
7650 + if ( 'vigilante_self' === $file_type ) {
7651 + $file_type = __( 'Vigilant (self)', 'vigilante' );
7652 + }
6860 7653 ?>
6861 7654 <tr>
6862 7655 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
6863 7656 <td><code><?php echo esc_html( $file_path ); ?></code></td>
@@ -6871,9 +7664,16 @@
6871 7664 </table>
6872 7665 </div>
6873 7666 <?php endif; ?>
6874 7667
6875 - <?php if ( $last_results && empty( $last_results['modified'] ) && empty( $last_results['suspicious'] ) && empty( $last_results['extra'] ) && ! $has_closed ) : ?>
7668 + <?php
7669 + // "All files passed" is about every file, and Vigilant's own
7670 + // are files too: with the self-protection box in red or amber
7671 + // above, this line contradicted it (its findings no longer
7672 + // travel in the tables below).
7673 + $vg_self_alarm = in_array( $this->self_integrity_summary( $options )['tone'], array( 'critical', 'off', 'warning' ), true );
7674 + ?>
7675 + <?php if ( $last_results && ! $vg_self_alarm && empty( $critical_modified ) && empty( $regular_modified ) && empty( $last_results['suspicious'] ) && empty( $last_results['extra'] ) && ! $has_closed ) : ?>
6876 7676 <p class="vigilante-all-clear" style="color: #00a32a; font-weight: bold;">
6877 7677 <?php esc_html_e( 'Good Job! All files passed integrity check. No issues found.', 'vigilante' ); ?>
6878 7678 </p>
6879 7679 <?php endif; ?>
@@ -7015,9 +7815,9 @@
7015 7815 } elseif ( isset( $_POST['data'] ) ) {
7016 7816 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
7017 7817 $raw_data = wp_unslash( $_POST['data'] );
7018 7818 parse_str( $raw_data, $data );
7019 - $data = map_deep( $data, 'sanitize_textarea_field' );
7819 + $data = $this->sanitize_posted_settings( $data );
7020 7820 } else {
7021 7821 $data = array();
7022 7822 }
7023 7823
@@ -7407,8 +8207,48 @@
7407 8207 return array( 'sent' => $sent, 'failed' => $failed );
7408 8208 }
7409 8209
7410 8210 /**
8211 + * Sanitize the settings a tab posted, once the serialized form is parsed
8212 + *
8213 + * Every value goes through sanitize_textarea_field() except the lists of
8214 + * file paths (Vigilante_Settings::path_list_keys()). That one deletes every
8215 + * %XX octet and, when it finds one, collapses repeated spaces, so an
8216 + * exclusion for a folder called foto%20x was saved as fotox and excluded
8217 + * nothing (measured on 1 oct 2026 against 3.0.1). The paths are read as
8218 + * paths before the generic sanitizer runs and put back after it.
8219 + *
8220 + * @since 3.0.2
8221 + *
8222 + * @param array $data Parsed form: section => key => value.
8223 + * @return array
8224 + */
8225 + private function sanitize_posted_settings( $data ) {
8226 + $paths = Vigilante_Settings::take_path_lists( $data );
8227 + $data = map_deep( $data, 'sanitize_textarea_field' );
8228 +
8229 + return Vigilante_Settings::restore_path_lists( $data, $paths );
8230 + }
8231 +
8232 + /**
8233 + * Sanitize an imported settings file, once it is decoded
8234 + *
8235 + * Same rule as sanitize_posted_settings(): the lists of paths keep what the
8236 + * export wrote, so an exclusion survives an export and an import.
8237 + *
8238 + * @since 3.0.2
8239 + *
8240 + * @param array $imported Decoded export.
8241 + * @return array
8242 + */
8243 + private function sanitize_imported_settings( $imported ) {
8244 + $paths = Vigilante_Settings::take_path_lists( $imported );
8245 + $imported = map_deep( $imported, 'sanitize_text_field' );
8246 +
8247 + return Vigilante_Settings::restore_path_lists( $imported, $paths );
8248 + }
8249 +
8250 + /**
7411 8251 * Process section data maintaining proper types from defaults
7412 8252 *
7413 8253 * @param array $submitted_data Data submitted from form.
7414 8254 * @param array $defaults Default values for this section.
@@ -7416,16 +8256,51 @@
7416 8256 * @param string $section_name Section name for special handling.
7417 8257 * @return array Processed data.
7418 8258 */
7419 8259 private function process_section_data( $submitted_data, $defaults, $current, $section_name = '' ) {
7420 - // Start with defaults, then merge current saved values
8260 + // Start with defaults, then merge current saved values. Lists are put
8261 + // back whole afterwards: array_replace_recursive() merges them index by
8262 + // index, so a saved list shorter than the default kept the default's
8263 + // tail (see below, and Vigilante_Settings::array_merge_deep()).
7421 8264 $result = array_replace_recursive( $defaults, $current );
7422 -
8265 + $result = $this->restore_lists( $result, $current );
8266 +
7423 8267 // Process each submitted value
7424 8268 foreach ( $submitted_data as $key => $value ) {
7425 8269 $key = sanitize_key( $key );
7426 -
7427 - if ( is_array( $value ) ) {
8270 +
8271 + if ( is_array( $value ) && $this->is_list_field( $key, $value, $defaults, $current ) ) {
8272 + /*
8273 + * A list of values, not a group of settings: the roles of a
8274 + * checkbox group, the excluded user ids. What the form sent IS
8275 + * the list, so it replaces the stored one outright.
8276 + *
8277 + * Until 3.0.1 this went through the recursion below, which
8278 + * writes position by position and left every stored entry the
8279 + * new list was too short to overwrite. Unchecking Editor in
8280 + * "Enforce for roles" saved administrator over position 0 and
8281 + * kept editor in position 1, so the box came back ticked
8282 + * (reported on the support forum for 3.0.0, and the same bug
8283 + * merge_preset() was fixed for in 2.9.8).
8284 + *
8285 + * Keys are dropped on purpose: a list is data, so anything the
8286 + * request tried to name is not carried over. The empty string
8287 + * is dropped too, which is what the hidden field that marks an
8288 + * empty group sends.
8289 + */
8290 + $list = array();
8291 + foreach ( $value as $entry ) {
8292 + if ( ! is_scalar( $entry ) ) {
8293 + continue;
8294 + }
8295 + $entry = sanitize_text_field( (string) $entry );
8296 + if ( '' === $entry ) {
8297 + continue;
8298 + }
8299 + $list[] = $entry;
8300 + }
8301 + $result[ $key ] = $list;
8302 + } elseif ( is_array( $value ) ) {
7428 8303 // Nested array (like rate_limiting)
7429 8304 $nested_defaults = isset( $defaults[ $key ] ) && is_array( $defaults[ $key ] ) ? $defaults[ $key ] : array();
7430 8305 $nested_current = isset( $result[ $key ] ) && is_array( $result[ $key ] ) ? $result[ $key ] : array();
7431 8306 $result[ $key ] = $this->process_section_data( $value, $nested_defaults, $nested_current, $key );
@@ -7511,10 +8386,26 @@
7511 8386 }
7512 8387 }
7513 8388
7514 8389 if ( $is_value_list ) {
7515 - // All items removed - reset to empty array
7516 - $result[ $key ] = array();
8390 + /*
8391 + * A list whose key did not travel is left as it was. Until
8392 + * 3.0.1 it was blanked here, which read "the user unticked
8393 + * everything" into something the form never asked about:
8394 + * saving the Firewall tab blanked allowed_http_methods,
8395 + * which has no field on any screen, and saving Users blanked
8396 + * insecure_usernames. Both survived only because reading the
8397 + * option put the defaults back on top, and that is exactly
8398 + * the merge being removed in this release: with lists
8399 + * replaced whole, blanking here would have left a site
8400 + * answering 403 to every HTTP method.
8401 + *
8402 + * Emptying a group is still possible, and is now said out
8403 + * loud: each checkbox group prints a hidden field with an
8404 + * empty value, so an empty group arrives as an empty list
8405 + * instead of as silence. See list_sentinel().
8406 + */
8407 + continue;
7517 8408 } else {
7518 8409 // Nested settings group - handle boolean children
7519 8410 foreach ( $default_value as $nested_key => $nested_default ) {
7520 8411 if ( is_bool( $nested_default ) && isset( $result[ $key ] ) && is_array( $result[ $key ] ) ) {
@@ -7533,8 +8424,106 @@
7533 8424 return $result;
7534 8425 }
7535 8426
7536 8427 /**
8428 + * Whether a submitted value is a list of values rather than a group of settings
8429 + *
8430 + * The difference decides whether what arrived replaces the stored value or
8431 + * is merged into it key by key, so it is answered from what the setting is
8432 + * (its default, or what is stored today), not from the shape of the
8433 + * request. A request that sends named keys where a list belongs gets them
8434 + * dropped, which is one way this is narrower than what it replaces.
8435 + *
8436 + * @since 3.0.1
8437 + *
8438 + * @param string $key Setting key, already through sanitize_key().
8439 + * @param array $value Value as submitted.
8440 + * @param array $defaults Defaults of the section being processed.
8441 + * @param array $current Stored values of the section being processed.
8442 + * @return bool
8443 + */
8444 + private function is_list_field( $key, $value, $defaults, $current ) {
8445 + $default_known = array_key_exists( $key, $defaults ) && is_array( $defaults[ $key ] );
8446 +
8447 + // The default has the first and the last word, the same rule that
8448 + // array_merge_deep() and restore_lists() follow. Letting the stored
8449 + // value decide, as this did until the fourth cross review of 3.0.1,
8450 + // disagrees with them where it matters: an option corrupted into
8451 + // two_factor = array( 'a', 'b' ) would have been treated as a list and
8452 + // saving the tab would have replaced the whole group of settings with
8453 + // the two values that arrived.
8454 + if ( $default_known && array() !== $defaults[ $key ] ) {
8455 + return Vigilante_Settings::is_list( $defaults[ $key ] );
8456 + }
8457 +
8458 + // Nothing declares it either way (an empty default, or a key with no
8459 + // default at all), so what is stored, and then what was submitted, is
8460 + // all there is to go on.
8461 + if ( isset( $current[ $key ] ) && is_array( $current[ $key ] ) && array() !== $current[ $key ] && Vigilante_Settings::is_list( $current[ $key ] ) ) {
8462 + return true;
8463 + }
8464 +
8465 + return Vigilante_Settings::is_list( $value );
8466 + }
8467 +
8468 + /**
8469 + * Put stored lists back after array_replace_recursive()
8470 + *
8471 + * array_replace_recursive() merges a list by position, so a stored list
8472 + * shorter than the default comes out with the default's tail attached:
8473 + * ['administrator'] over ['administrator','editor'] gives both back. Every
8474 + * list that is stored is therefore restored whole before anything else is
8475 + * processed, and only associative arrays are walked into.
8476 + *
8477 + * @since 3.0.1
8478 + *
8479 + * @param array $result Defaults with the stored values merged over them.
8480 + * @param array $current Stored values.
8481 + * @return array
8482 + */
8483 + private function restore_lists( $result, $current ) {
8484 + if ( ! is_array( $current ) ) {
8485 + return $result;
8486 + }
8487 +
8488 + foreach ( $current as $key => $value ) {
8489 + if ( ! is_array( $value ) || ! isset( $result[ $key ] ) || ! is_array( $result[ $key ] ) ) {
8490 + continue;
8491 + }
8492 +
8493 + // Decided by what is in $result, which comes from the defaults, and
8494 + // never by the stored value: array() is a list by any definition, so
8495 + // a section stored empty would have replaced a whole group of
8496 + // settings with nothing. Same reasoning as in array_merge_deep().
8497 + if ( Vigilante_Settings::is_list( $result[ $key ] ) ) {
8498 + $result[ $key ] = $value;
8499 + continue;
8500 + }
8501 +
8502 + $result[ $key ] = $this->restore_lists( $result[ $key ], $value );
8503 + }
8504 +
8505 + return $result;
8506 + }
8507 +
8508 + /**
8509 + * Print the hidden field that lets a checkbox group arrive empty
8510 + *
8511 + * A checkbox group sends nothing at all when every box is unticked, which
8512 + * is indistinguishable from a form that never had that field. Since 3.0.1
8513 + * the save path leaves an absent list alone, so the difference has to be
8514 + * stated: this empty entry always travels, and the save path drops empty
8515 + * strings, so an untouched group arrives as an empty list.
8516 + *
8517 + * @since 3.0.1
8518 + *
8519 + * @param string $name Field name without the trailing [].
8520 + */
8521 + private function list_sentinel( $name ) {
8522 + printf( '<input type="hidden" name="%s[]" value="">', esc_attr( $name ) );
8523 + }
8524 +
8525 + /**
7537 8526 * AJAX: Export settings
7538 8527 */
7539 8528 public function ajax_export_settings() {
7540 8529 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
@@ -7586,9 +8575,9 @@
7586 8575 wp_send_json_error( __( 'Invalid JSON format.', 'vigilante' ) );
7587 8576 }
7588 8577
7589 8578 // Sanitize imported data recursively
7590 - $imported = map_deep( $imported, 'sanitize_text_field' );
8579 + $imported = $this->sanitize_imported_settings( $imported );
7591 8580
7592 8581 // Validate structure: only sections and keys of the schema survive, and
7593 8582 // every value takes the type of its default. Until 2.11.0 this was an
7594 8583 // array_replace_recursive() of the file over the defaults, so any key in
@@ -7979,22 +8968,27 @@
7979 8968 * shut in 2.11.8 and this button was left open, found by the cross
7980 8969 * review of 2.11.8. So for somebody who cannot approve, those entries
7981 8970 * stay and everything else goes.
7982 8971 */
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 - );
8972 + // The findings about Vigilant's own files stay too: they report for the
8973 + // whole network, and ignoring them already takes network rights.
8974 + if ( $this->critical_approval_locked() && is_array( $results ) ) {
8975 + $kept = array();
8976 + $found = false;
8977 + foreach ( array( 'modified', 'missing', 'suspicious', 'extra' ) as $bucket ) {
8978 + $kept[ $bucket ] = array_values(
8979 + array_filter(
8980 + isset( $results[ $bucket ] ) && is_array( $results[ $bucket ] ) ? $results[ $bucket ] : array(),
8981 + function ( $item ) {
8982 + return is_array( $item ) && in_array( $item['type'] ?? '', array( 'critical_config', 'vigilante_self' ), true );
8983 + }
8984 + )
8985 + );
8986 + $found = $found || ! empty( $kept[ $bucket ] );
8987 + }
7992 8988
7993 - if ( $critical ) {
7994 - $results['modified'] = $critical;
7995 - $results['suspicious'] = array();
7996 - $results['extra'] = array();
8989 + if ( $found ) {
8990 + $results = array_merge( $results, $kept );
7997 8991 update_option( 'vigilante_last_integrity_results', $results );
7998 8992 update_option( 'vigilante_last_integrity_scan', $scanned_at ? $scanned_at : time() );
7999 8993 }
8000 8994 }
@@ -8019,9 +9013,29 @@
8019 9013 if ( ! current_user_can( 'manage_options' ) ) {
8020 9014 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
8021 9015 }
8022 9016
8023 - $file = isset( $_POST['file'] ) ? sanitize_text_field( wp_unslash( $_POST['file'] ) ) : '';
9017 + /*
9018 + * The path as the scan reported it, which is what the interface sends
9019 + * back. sanitize_text_field() is the wrong reader for it: it deletes
9020 + * every %XX octet and collapses repeated spaces, so a file named
9021 + * foto%20nueva.php was stored as fotonueva.php, the scan compared the
9022 + * list with the paths it finds one by one, never matched, and the
9023 + * warning came back (measured on 1 oct 2026 against 3.0.1). The four
9024 + * handlers of the ignore list read the path the same way.
9025 + *
9026 + * Known limits, found by the cross review, that only a file name with a
9027 + * stray < in it reaches. wp_strip_all_tags() shortens such a name at the
9028 + * < instead of refusing it, so the stored entry can match another file
9029 + * (a.php<b is stored as a.php), and the value ignore_file() returns is
9030 + * not read, so a refusal of its own is answered as success. The fix, if
9031 + * a site ever needs it, is to answer with an error when
9032 + * wp_strip_all_tags() changes what was sent and to check that return
9033 + * value. An entry the old reader stored for such a name (it wrote the
9034 + * < as &lt;) is listed but "Stop ignoring" cannot remove it; "Clear all
9035 + * ignored files" does.
9036 + */
9037 + $file = ( isset( $_POST['file'] ) && is_string( $_POST['file'] ) ) ? wp_strip_all_tags( wp_unslash( $_POST['file'] ) ) : '';
8024 9038
8025 9039 if ( empty( $file ) ) {
8026 9040 wp_send_json_error( __( 'No file specified.', 'vigilante' ) );
8027 9041 }
@@ -8031,8 +9045,17 @@
8031 9045 if ( $this->critical_approval_locked() && in_array( $file, array( 'wp-config.php', '.htaccess' ), true ) ) {
8032 9046 wp_send_json_error( $this->critical_approval_notice() );
8033 9047 }
8034 9048
9049 + // Vigilant's own files are never ignored, on any site: silencing the
9050 + // check that says the security plugin was changed is the one button
9051 + // an attacker would want on this screen. Since 3.0.0 those findings
9052 + // are not rows of these tables either, so nothing in the interface
9053 + // sends them here; this is the door, not the label.
9054 + if ( Vigilante_Self_Integrity::is_own_file_path( $file ) ) {
9055 + 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' ) );
9056 + }
9057 +
8035 9058 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database );
8036 9059 $file_integrity->ignore_file( $file );
8037 9060
8038 9061 // Also remove the file from stored scan results so UI updates
@@ -8065,9 +9088,9 @@
8065 9088 if ( ! current_user_can( 'manage_options' ) ) {
8066 9089 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
8067 9090 }
8068 9091
8069 - $file = isset( $_POST['file'] ) ? sanitize_text_field( wp_unslash( $_POST['file'] ) ) : '';
9092 + $file = ( isset( $_POST['file'] ) && is_string( $_POST['file'] ) ) ? wp_strip_all_tags( wp_unslash( $_POST['file'] ) ) : '';
8070 9093
8071 9094 if ( empty( $file ) ) {
8072 9095 wp_send_json_error( __( 'No file specified.', 'vigilante' ) );
8073 9096 }
@@ -8097,13 +9120,15 @@
8097 9120 wp_send_json_error( __( 'Invalid request.', 'vigilante' ) );
8098 9121 }
8099 9122
8100 9123 $files = array();
8101 - $shared = $this->critical_approval_locked() ? array( 'wp-config.php', '.htaccess' ) : array();
9124 + $locked = $this->critical_approval_locked();
9125 + $shared = $locked ? array( 'wp-config.php', '.htaccess' ) : array();
8102 9126 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 ) ) {
9127 + $clean = is_string( $f ) ? wp_strip_all_tags( $f ) : '';
9128 + // Same rule as ajax_ignore_file(): the two shared files when the
9129 + // network locks them, and Vigilant's own files always.
9130 + if ( '' !== $clean && ! in_array( $clean, $shared, true ) && ! Vigilante_Self_Integrity::is_own_file_path( $clean ) ) {
8106 9131 $files[] = $clean;
8107 9132 }
8108 9133 }
8109 9134
@@ -8167,9 +9192,9 @@
8167 9192 }
8168 9193
8169 9194 $files = array();
8170 9195 foreach ( $raw_files as $f ) {
8171 - $clean = sanitize_text_field( $f );
9196 + $clean = is_string( $f ) ? wp_strip_all_tags( $f ) : '';
8172 9197 if ( '' !== $clean ) {
8173 9198 $files[] = $clean;
8174 9199 }
8175 9200 }