PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.8
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.8
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 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
← All changes | admin/class-admin.php +296 -35 2.11.22.11.8 View file →
@@ -273,9 +273,29 @@
273 273 if ( ! class_exists( 'Vigilante_File_Integrity' ) ) {
274 274 require_once VIGILANTE_INCLUDES_DIR . 'class-file-integrity.php';
275 275 }
276 276 $fi = new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
277 - $fi->regenerate_all_baselines();
277 +
278 + /*
279 + * Only when there is nothing on record. This migration exists to
280 + * create the baseline that did not exist, never to discard the one
281 + * the owner approved: rebuilding it from the files takes whatever
282 + * is on disk right now as approved, so a wp-config.php modified and
283 + * awaiting review would be blessed in silence.
284 + *
285 + * And this is not theory. vigilante_db_version is written on two
286 + * different scales into the same option: this file counts in plugin
287 + * versions (2.11.0) and Vigilante_Database counts in schema
288 + * versions, currently 1.4.0 (class-database.php:322 and :380). For
289 + * version_compare, 1.4.0 is LOWER than 1.14.0, so any site whose
290 + * option was last written by the schema runs this migration again.
291 + * Measured on the Multisite install on 10 sep 2026: one of the three
292 + * sites was sitting on 1.4.0.
293 + */
294 + if ( ! $fi->get_critical_files_baseline() ) {
295 + $fi->regenerate_all_baselines();
296 + }
297 +
278 298 update_option( 'vigilante_db_version', '1.14.0' );
279 299 }
280 300
281 301 // 2.0.0: Move hide_server_signature and remove_fingerprinting_headers
@@ -1448,8 +1468,11 @@
1448 1468 'currentUserId' => get_current_user_id(),
1449 1469 'logoutUrl' => wp_logout_url( wp_login_url() ),
1450 1470 'adminUrl' => admin_url( 'admin.php?page=vigilante' ),
1451 1471 'searchIndex' => $this->get_search_index(),
1472 + // The scan repaints this table from JavaScript, so the same gate
1473 + // has to travel with it or half the screen keeps the dead button.
1474 + 'approvalLocked' => $this->critical_approval_locked(),
1452 1475 'underAttack' => array(
1453 1476 'active' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->is_active(),
1454 1477 'remaining' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->get_remaining_time(),
1455 1478 ),
@@ -1516,13 +1539,17 @@
1516 1539 'criticalConfigTitle' => __( 'Critical config files modified', 'vigilante' ),
1517 1540 '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' ),
1518 1541 'approve' => __( 'Approve', 'vigilante' ),
1519 1542 'approving' => __( 'Approving...', 'vigilante' ),
1543 + 'approvalLockedNotice' => $this->critical_approval_notice(),
1520 1544 'criticalApproved' => __( 'Change approved. Next scan will use the current state as baseline.', 'vigilante' ),
1521 1545 'reviewChanges' => __( 'Review changes', 'vigilante' ),
1522 1546 'hideChanges' => __( 'Hide changes', 'vigilante' ),
1523 1547 'changes' => __( 'Changes', 'vigilante' ),
1524 1548 'diffUnavailable' => __( 'Diff not available for this file (baseline was created before diff tracking was added). Approve to enable diff on future changes.', 'vigilante' ),
1549 + 'diffNetwork' => __( 'This file belongs to the whole network, so its line changes are only shown to network administrators, on the main site.', 'vigilante' ),
1550 + 'diffRescan' => __( 'Run a new scan to see the line changes of this file.', 'vigilante' ),
1551 + 'diffRedaction' => __( 'The line changes of this file are not shown because a value in it could not be hidden safely. The change itself is still detected.', 'vigilante' ),
1525 1552 'diffEmpty' => __( 'No line-level changes detected (may be whitespace or reordering).', 'vigilante' ),
1526 1553 'diffLines' => __( 'lines', 'vigilante' ),
1527 1554 // Under Attack mode strings
1528 1555 'underAttackConfirmActivate' => __( 'Activate Under Attack mode? All visitors will see a verification page for the next 4 hours.', 'vigilante' ),
@@ -2031,8 +2058,41 @@
2031 2058 return ! Vigilante_Settings::can_write_shared_files();
2032 2059 }
2033 2060
2034 2061 /**
2062 + * Whether this is the main site and the user cannot change what it builds the shared files from
2063 + *
2064 + * See Vigilante_Settings::get_main_site_file_settings(). On a subsite those
2065 + * settings only act on that site, so they are never locked there.
2066 + *
2067 + * @since 2.11.6
2068 + *
2069 + * @return bool
2070 + */
2071 + private function main_site_files_locked() {
2072 + return $this->shared_files_locked() && Vigilante_Settings::owns_shared_files();
2073 + }
2074 +
2075 + /**
2076 + * Sentence added to a bulk change when some settings were left as they were
2077 + *
2078 + * Importing a file, applying a preset and restoring the defaults touch every
2079 + * section at once, so the user is told that the shared file settings did
2080 + * not move.
2081 + *
2082 + * @since 2.11.6
2083 + *
2084 + * @return string Empty when the user can change every setting.
2085 + */
2086 + private function locked_file_settings_message() {
2087 + if ( ! Vigilante_Settings::get_locked_file_settings() ) {
2088 + return '';
2089 + }
2090 +
2091 + return ' ' . __( 'The settings that end up in wp-config.php or .htaccess were left as they were.', 'vigilante' ) . ' ' . Vigilante_Settings::get_shared_files_notice();
2092 + }
2093 +
2094 + /**
2035 2095 * Print the shared-files notice for a section that cannot be edited here
2036 2096 *
2037 2097 * @since 2.9.8
2038 2098 */
@@ -2058,10 +2118,46 @@
2058 2118 *
2059 2119 * @since 2.10.4
2060 2120 * @return bool
2061 2121 */
2122 + private function forwarded_chain_readings() {
2123 + // Shown, not decided on: the firewall resolves the address elsewhere.
2124 + $chain = Vigilante_IP_Utils::trusted_forwarded_for();
2125 +
2126 + if ( '' === $chain ) {
2127 + return array();
2128 + }
2129 +
2130 + $public = array();
2131 +
2132 + foreach ( explode( ',', $chain ) as $entry ) {
2133 + $address = Vigilante_IP_Utils::unmap_ipv4( trim( $entry ) );
2134 +
2135 + if ( filter_var( $address, FILTER_VALIDATE_IP ) && ! Vigilante_IP_Utils::is_own_network( $address ) ) {
2136 + $public[] = $address;
2137 + }
2138 + }
2139 +
2140 + if ( count( $public ) < 2 ) {
2141 + return array();
2142 + }
2143 +
2144 + return array(
2145 + 'now' => Vigilante_IP_Utils::client_from_chain( $chain ),
2146 + 'before' => $public[0],
2147 + );
2148 + }
2149 +
2150 + /**
2151 + * Whether the user tools of this screen are out of reach for this user
2152 + *
2153 + * @return bool
2154 + */
2062 2155 private function user_actions_locked() {
2063 - return is_multisite() && ! current_user_can( 'manage_network_users' );
2156 + // On a single site edit_user maps to edit_users, which a custom role with
2157 + // manage_options may lack: since 2.11.8 approving and rejecting a pending
2158 + // registration ask for it, so the buttons have to say so there too.
2159 + return is_multisite() ? ! current_user_can( 'manage_network_users' ) : ! current_user_can( 'edit_users' );
2064 2160 }
2065 2161
2066 2162 /**
2067 2163 * Print the notice for user tools that cannot be used from this site
@@ -2073,14 +2169,47 @@
2073 2169 return;
2074 2170 }
2075 2171 ?>
2076 2172 <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;">
2173 + <?php if ( is_multisite() ) : ?>
2077 2174 <p style="margin:0;"><?php esc_html_e( 'These tools act on user accounts, which on a network belong to the whole network rather than to one site. WordPress reserves that to network administrators, so they are managed from the network admin.', 'vigilante' ); ?></p>
2175 + <?php else : ?>
2176 + <p style="margin:0;"><?php esc_html_e( 'These tools act on other user accounts, and your role cannot edit users, so they are not available to you.', 'vigilante' ); ?></p>
2177 + <?php endif; ?>
2078 2178 </div>
2079 2179 <?php
2080 2180 }
2081 2181
2082 2182 /**
2183 + * Approving a change to the shared config files needs the network
2184 + *
2185 + * Since 2.11.3 the handler behind the Approve button asks for
2186 + * manage_network_options, because the two files it approves, wp-config.php
2187 + * and the root .htaccess, belong to the installation, and so does the
2188 + * record of them. The button, though, went on being painted for everybody,
2189 + * so the administrator of a subsite saw the warning, saw the button,
2190 + * pressed it and got "Permission denied" with no explanation. That is
2191 + * exactly what user_actions_locked() above exists to avoid, one release
2192 + * later and one screen over. Flagged by @calzbert.
2193 + *
2194 + * @since 2.11.4
2195 + * @return bool
2196 + */
2197 + private function critical_approval_locked() {
2198 + return is_multisite() && ! current_user_can( 'manage_network_options' );
2199 + }
2200 +
2201 + /**
2202 + * The line that replaces the Approve button where it cannot be used
2203 + *
2204 + * @since 2.11.4
2205 + * @return string
2206 + */
2207 + private function critical_approval_notice() {
2208 + return __( 'These files belong to the whole network rather than to this site, so a change to them is approved from the network admin.', 'vigilante' );
2209 + }
2210 +
2211 + /**
2083 2212 * Check if module is disabled and render warning
2084 2213 *
2085 2214 * @param string $module_key Module key.
2086 2215 * @return bool True if disabled.
@@ -2630,8 +2759,9 @@
2630 2759 <div class="vigilante-modules-list">
2631 2760 <?php foreach ( $options['modules'] as $module => $enabled ) :
2632 2761 $label = isset( $module_labels[ $module ] ) ? $module_labels[ $module ] : ucwords( str_replace( '_', ' ', $module ) );
2633 2762 $description = isset( $module_descriptions[ $module ] ) ? $module_descriptions[ $module ] : '';
2763 + $vg_module_locked = $this->main_site_files_locked() && in_array( $module, Vigilante_Settings::get_main_site_file_settings()['modules'], true );
2634 2764 ?>
2635 2765 <div class="vigilante-module-item <?php echo $enabled ? 'enabled' : 'disabled'; ?>">
2636 2766 <div class="vigilante-module-header">
2637 2767 <span class="vigilante-module-status"></span>
@@ -2644,8 +2774,9 @@
2644 2774 <input type="checkbox"
2645 2775 name="modules[<?php echo esc_attr( $module ); ?>]"
2646 2776 value="1"
2647 2777 <?php checked( $enabled ); ?>
2778 + <?php disabled( $vg_module_locked ); ?>
2648 2779 aria-label="<?php echo esc_attr( $toggle_label ); ?>"
2649 2780 data-module="<?php echo esc_attr( $module ); ?>">
2650 2781 <span class="vigilante-toggle-slider"></span>
2651 2782 </label>
@@ -2652,8 +2783,11 @@
2652 2783 </div>
2653 2784 <?php if ( $description ) : ?>
2654 2785 <p class="vigilante-module-desc"><?php echo esc_html( $description ); ?></p>
2655 2786 <?php endif; ?>
2787 + <?php if ( $vg_module_locked ) : ?>
2788 + <p class="vigilante-module-desc"><?php esc_html_e( 'On the main site of a network this module also writes files every site shares, so only a network administrator can switch it.', 'vigilante' ); ?></p>
2789 + <?php endif; ?>
2656 2790 </div>
2657 2791 <?php endforeach; ?>
2658 2792 </div>
2659 2793 </div>
@@ -3043,14 +3177,21 @@
3043 3177 <?php esc_html_e( 'Full page caching systems that serve cached pages before PHP executes (Varnish, LiteSpeed Cache, NGINX FastCGI Cache, Cloudflare APO) may bypass PHP-level firewall rules for cached requests. The .htaccess rules will still apply on Apache/LiteSpeed servers.', 'vigilante' ); ?>
3044 3178 </p>
3045 3179 </div>
3046 3180
3181 + <?php $vg_main_locked = $this->main_site_files_locked(); ?>
3182 + <?php if ( $vg_main_locked ) : ?>
3183 + <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;">
3184 + <p style="margin:0;"><?php esc_html_e( 'On the main site of a network, blocking bad bots and bad query strings, the visitor IP detection and the two whitelists also build the .htaccess rules every site shares, so only a network administrator can change them.', 'vigilante' ); ?></p>
3185 + </div>
3186 + <?php endif; ?>
3187 +
3047 3188 <table class="form-table">
3048 3189 <tr>
3049 3190 <th scope="row"><?php esc_html_e( 'Block Bad Query Strings', 'vigilante' ); ?></th>
3050 3191 <td>
3051 3192 <label>
3052 - <input type="checkbox" name="firewall[block_bad_query_strings]" value="1" <?php checked( ! empty( $options['block_bad_query_strings'] ) ); ?>>
3193 + <input type="checkbox" name="firewall[block_bad_query_strings]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( ! empty( $options['block_bad_query_strings'] ) ); ?>>
3053 3194 <?php esc_html_e( 'Block malicious query string patterns', 'vigilante' ); ?>
3054 3195 </label>
3055 3196 </td>
3056 3197 </tr>
@@ -3093,9 +3234,9 @@
3093 3234 <tr>
3094 3235 <th scope="row"><?php esc_html_e( 'Block Bad Bots', 'vigilante' ); ?></th>
3095 3236 <td>
3096 3237 <label>
3097 - <input type="checkbox" name="firewall[block_bad_bots]" value="1" <?php checked( ! empty( $options['block_bad_bots'] ) ); ?>>
3238 + <input type="checkbox" name="firewall[block_bad_bots]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( ! empty( $options['block_bad_bots'] ) ); ?>>
3098 3239 <?php esc_html_e( 'Block known malicious bots and scanners', 'vigilante' ); ?>
3099 3240 </label>
3100 3241 </td>
3101 3242 </tr>
@@ -3209,8 +3350,29 @@
3209 3350 </table>
3210 3351 </div>
3211 3352 <?php endif; ?>
3212 3353
3354 + <?php
3355 + // Since 2.11.8 X-Forwarded-For is read from its end, where the proxy
3356 + // writes. The administrator's own request shows whether that end is
3357 + // a CDN or a balancer for everybody here. Cross review of 2.11.8.
3358 + $xff_readings = $this->forwarded_chain_readings();
3359 + if ( $xff_readings ) :
3360 + ?>
3361 + <div id="vigilante-xff-chain-notice" class="notice notice-warning inline" style="margin:10px 0 16px;padding:8px 12px;">
3362 + <p style="margin:0;">
3363 + <?php
3364 + printf(
3365 + /* translators: 1: address Vigilant reads now, 2: address earlier versions read */
3366 + 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' ),
3367 + esc_html( $xff_readings['now'] ),
3368 + esc_html( $xff_readings['before'] )
3369 + );
3370 + ?>
3371 + </p>
3372 + </div>
3373 + <?php endif; ?>
3374 +
3213 3375 <h3><?php esc_html_e( 'IP Lists', 'vigilante' ); ?></h3>
3214 3376 <p class="description">
3215 3377 <?php
3216 3378 printf(
@@ -3224,9 +3386,9 @@
3224 3386 <tr>
3225 3387 <th scope="row"><label for="vigilante-f-firewall-trusted-proxy-header"><?php esc_html_e( 'Visitor IP detection', 'vigilante' ); ?></label></th>
3226 3388 <td>
3227 3389 <?php $proxy_header = $options['trusted_proxy_header'] ?? ''; ?>
3228 - <select id="vigilante-f-firewall-trusted-proxy-header" name="firewall[trusted_proxy_header]">
3390 + <select id="vigilante-f-firewall-trusted-proxy-header" name="firewall[trusted_proxy_header]" <?php disabled( $vg_main_locked ); ?>>
3229 3391 <option value="" <?php selected( $proxy_header, '' ); ?>><?php esc_html_e( 'Direct connection, only REMOTE_ADDR (recommended)', 'vigilante' ); ?></option>
3230 3392 <option value="cf-connecting-ip" <?php selected( $proxy_header, 'cf-connecting-ip' ); ?>><?php esc_html_e( 'Behind Cloudflare (CF-Connecting-IP)', 'vigilante' ); ?></option>
3231 3393 <option value="x-forwarded-for" <?php selected( $proxy_header, 'x-forwarded-for' ); ?>><?php esc_html_e( 'Behind a reverse proxy or load balancer (X-Forwarded-For)', 'vigilante' ); ?></option>
3232 3394 <option value="x-real-ip" <?php selected( $proxy_header, 'x-real-ip' ); ?>><?php esc_html_e( 'Behind an nginx proxy (X-Real-IP)', 'vigilante' ); ?></option>
@@ -3238,9 +3400,9 @@
3238 3400 </tr>
3239 3401 <tr>
3240 3402 <th scope="row"><label for="vigilante-f-firewall-ip-whitelist"><?php esc_html_e( 'IP Whitelist', 'vigilante' ); ?></label></th>
3241 3403 <td>
3242 - <textarea id="vigilante-f-firewall-ip-whitelist" name="firewall[ip_whitelist]" rows="4" class="large-text code" placeholder="192.168.1.50&#10;192.168.1.0/24&#10;192.168.1.*"><?php echo esc_textarea( implode( "\n", $options['ip_whitelist'] ?? array() ) ); ?></textarea>
3404 + <textarea id="vigilante-f-firewall-ip-whitelist" name="firewall[ip_whitelist]" <?php disabled( $vg_main_locked ); ?> rows="4" class="large-text code" placeholder="192.168.1.50&#10;192.168.1.0/24&#10;192.168.1.*"><?php echo esc_textarea( implode( "\n", $options['ip_whitelist'] ?? array() ) ); ?></textarea>
3243 3405 <p class="description">
3244 3406 <?php esc_html_e( 'One IP per line. These IPs bypass the firewall checks, and they also reach wp-admin when the login URL is hidden, so remote managers such as MainWP or ManageWP are not turned away with a 404. The hidden login form itself stays hidden for every IP, this one included.', 'vigilante' ); ?>
3245 3407 <br>
3246 3408 <?php
@@ -3279,9 +3441,9 @@
3279 3441 <table class="form-table">
3280 3442 <tr>
3281 3443 <th scope="row"><label for="vigilante-f-firewall-ua-whitelist"><?php esc_html_e( 'User-Agent Whitelist', 'vigilante' ); ?></label></th>
3282 3444 <td>
3283 - <textarea id="vigilante-f-firewall-ua-whitelist" name="firewall[ua_whitelist]" rows="4" class="large-text code"><?php echo esc_textarea( implode( "\n", $options['ua_whitelist'] ?? array() ) ); ?></textarea>
3445 + <textarea id="vigilante-f-firewall-ua-whitelist" name="firewall[ua_whitelist]" <?php disabled( $vg_main_locked ); ?> rows="4" class="large-text code"><?php echo esc_textarea( implode( "\n", $options['ua_whitelist'] ?? array() ) ); ?></textarea>
3284 3446 <p class="description"><?php esc_html_e( 'One User-Agent per line. These will bypass all firewall checks. Example: ManageWP, MainWP, UptimeRobot.', 'vigilante' ); ?></p>
3285 3447 </td>
3286 3448 </tr>
3287 3449 <tr>
@@ -4964,8 +5126,9 @@
4964 5126 <span class="dashicons dashicons-yes-alt"></span>
4965 5127 <p><?php esc_html_e( 'No pending registrations.', 'vigilante' ); ?></p>
4966 5128 </div>
4967 5129 <?php else : ?>
5130 + <?php $this->render_user_actions_notice(); ?>
4968 5131 <table class="wp-list-table widefat fixed striped vigilante-pending-users-table">
4969 5132 <thead>
4970 5133 <tr>
4971 5134 <th><?php esc_html_e( 'User', 'vigilante' ); ?></th>
@@ -4994,12 +5157,12 @@
4994 5157 }
4995 5158 ?>
4996 5159 </td>
4997 5160 <td>
4998 - <button type="button" class="button button-small vigilante-approve-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>">
5161 + <button type="button" class="button button-small vigilante-approve-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>" <?php disabled( $this->user_actions_locked() ); ?>>
4999 5162 <?php esc_html_e( 'Approve', 'vigilante' ); ?>
5000 5163 </button>
5001 - <button type="button" class="button button-small vigilante-reject-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>" style="color: #d63638;">
5164 + <button type="button" class="button button-small vigilante-reject-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>" style="color: #d63638;" <?php disabled( $this->user_actions_locked() ); ?>>
5002 5165 <?php esc_html_e( 'Reject', 'vigilante' ); ?>
5003 5166 </button>
5004 5167 </td>
5005 5168 </tr>
@@ -5893,8 +6056,13 @@
5893 6056 */
5894 6057 private function render_tab_file_integrity() {
5895 6058 $is_disabled = $this->render_module_disabled_notice( 'file_integrity' );
5896 6059 $options = $this->settings->get_section( 'file_integrity' );
6060 + // On the main site of a network the critical-file scan is the network's
6061 + // canary for a change to wp-config.php or the root .htaccess, so a
6062 + // main-site admin without network rights cannot turn it off. Since
6063 + // 2.11.8; see Vigilante_Settings::get_main_site_file_settings().
6064 + $vg_main_locked = $this->main_site_files_locked();
5897 6065 $last_scan = get_option( 'vigilante_last_integrity_scan' );
5898 6066 $last_results = get_option( 'vigilante_last_integrity_results' );
5899 6067 $ignored_files = get_option( 'vigilante_ignored_files', array() );
5900 6068
@@ -6020,10 +6188,13 @@
6020 6188 <?php esc_html_e( 'Uploads directory (detect PHP files, double extensions, .htaccess)', 'vigilante' ); ?>
6021 6189 </label>
6022 6190 <br>
6023 6191 <label>
6024 - <input type="checkbox" name="file_integrity[scan_critical_config]" value="1" <?php checked( $options['scan_critical_config'] ?? true ); ?>>
6192 + <input type="checkbox" name="file_integrity[scan_critical_config]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( $options['scan_critical_config'] ?? true ); ?>>
6025 6193 <?php esc_html_e( 'Critical config files (wp-config.php, .htaccess baseline monitoring)', 'vigilante' ); ?>
6194 + <?php if ( $vg_main_locked ) : ?>
6195 + <span class="description" style="display:block;margin-left:24px;"><?php echo esc_html( Vigilante_Settings::get_shared_files_notice() ); ?></span>
6196 + <?php endif; ?>
6026 6197 </label>
6027 6198 <br>
6028 6199 <label>
6029 6200 <input type="checkbox" name="file_integrity[check_closed_plugins]" value="1" <?php checked( $options['check_closed_plugins'] ?? true ); ?>>
@@ -6302,9 +6473,15 @@
6302 6473 $crit_diff = $crit_item['diff'] ?? array();
6303 6474 $crit_id = sanitize_html_class( $crit_file );
6304 6475 $added_count = is_array( $crit_diff ) ? count( $crit_diff['added'] ?? array() ) : 0;
6305 6476 $removed_count = is_array( $crit_diff ) ? count( $crit_diff['removed'] ?? array() ) : 0;
6306 - $diff_unavailable = is_array( $crit_diff ) && ! empty( $crit_diff['unavailable'] );
6477 + // The lines of a shared file are for whoever approves it. Results
6478 + // stored before 2.11.8 on the main site still carry them, so the
6479 + // screen asks too, not only the scan that wrote them.
6480 + $diff_network = ( is_array( $crit_diff ) && ! empty( $crit_diff['network'] ) ) || $this->critical_approval_locked();
6481 + $diff_rescan = is_array( $crit_diff ) && ! empty( $crit_diff['rescan'] );
6482 + $diff_redaction = is_array( $crit_diff ) && ! empty( $crit_diff['redaction'] );
6483 + $diff_unavailable = $diff_network || ( is_array( $crit_diff ) && ! empty( $crit_diff['unavailable'] ) );
6307 6484 ?>
6308 6485 <tr>
6309 6486 <td><code style="color: #e36210;"><?php echo esc_html( $crit_file ); ?></code></td>
6310 6487 <td>
@@ -6327,18 +6504,36 @@
6327 6504 <td>
6328 6505 <button type="button" class="button button-small vigilante-toggle-critical-content" data-target="vigilante-critical-content-<?php echo esc_attr( $crit_id ); ?>" data-label-show="<?php esc_attr_e( 'Review changes', 'vigilante' ); ?>" data-label-hide="<?php esc_attr_e( 'Hide changes', 'vigilante' ); ?>">
6329 6506 <?php esc_html_e( 'Review changes', 'vigilante' ); ?>
6330 6507 </button>
6331 - <button type="button" class="button button-small button-primary vigilante-approve-critical-file" data-file="<?php echo esc_attr( $crit_file ); ?>">
6332 - <?php esc_html_e( 'Approve', 'vigilante' ); ?>
6333 - </button>
6508 + <?php if ( $this->critical_approval_locked() ) : ?>
6509 + <span class="description" style="display:block;margin-top:4px;">
6510 + <?php echo esc_html( $this->critical_approval_notice() ); ?>
6511 + </span>
6512 + <?php else : ?>
6513 + <button type="button" class="button button-small button-primary vigilante-approve-critical-file" data-file="<?php echo esc_attr( $crit_file ); ?>">
6514 + <?php esc_html_e( 'Approve', 'vigilante' ); ?>
6515 + </button>
6516 + <?php endif; ?>
6334 6517 </td>
6335 6518 </tr>
6336 6519 <tr id="vigilante-critical-content-<?php echo esc_attr( $crit_id ); ?>" class="vigilante-critical-content-row" style="display:none;">
6337 6520 <td colspan="3" style="padding: 0;">
6338 6521 <div class="vigilante-critical-content" style="max-height: 400px; overflow: auto; background: #fff; padding: 10px; font-size: 12px; line-height: 1.5; font-family: Consolas, Monaco, monospace; border-top: 1px solid #c3c4c7;">
6339 - <?php if ( $diff_unavailable ) : ?>
6522 + <?php if ( $diff_network ) : ?>
6340 6523 <p style="color: #50575e; font-style: italic; margin: 0;">
6524 + <?php esc_html_e( 'This file belongs to the whole network, so its line changes are only shown to network administrators, on the main site.', 'vigilante' ); ?>
6525 + </p>
6526 + <?php elseif ( $diff_rescan ) : ?>
6527 + <p style="color: #50575e; font-style: italic; margin: 0;">
6528 + <?php esc_html_e( 'Run a new scan to see the line changes of this file.', 'vigilante' ); ?>
6529 + </p>
6530 + <?php elseif ( $diff_redaction ) : ?>
6531 + <p style="color: #50575e; font-style: italic; margin: 0;">
6532 + <?php esc_html_e( 'The line changes of this file are not shown because a value in it could not be hidden safely. The change itself is still detected.', 'vigilante' ); ?>
6533 + </p>
6534 + <?php elseif ( $diff_unavailable ) : ?>
6535 + <p style="color: #50575e; font-style: italic; margin: 0;">
6341 6536 <?php esc_html_e( 'Diff not available for this file (baseline was created before diff tracking was added). Approve to enable diff on future changes.', 'vigilante' ); ?>
6342 6537 </p>
6343 6538 <?php elseif ( empty( $crit_diff['added'] ) && empty( $crit_diff['removed'] ) ) : ?>
6344 6539 <p style="color: #50575e; font-style: italic; margin: 0;">
@@ -6674,8 +6869,27 @@
6674 6869
6675 6870 // Read ONLY saved options from database (not merged with defaults)
6676 6871 $saved_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
6677 6872
6873 + // What is stored before this request changes anything: the shared file
6874 + // settings this user may not change are put back from here (2.11.6).
6875 + $stored_options = $saved_options;
6876 + $locked = Vigilante_Settings::get_locked_file_settings();
6877 +
6878 + if ( isset( $locked[ $section ] ) && true === $locked[ $section ] ) {
6879 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
6880 + }
6881 +
6882 + // A module switch is a single key, so refusing says more than a success
6883 + // that changed nothing, and the dashboard puts the toggle back.
6884 + if ( 'modules' === $section && isset( $locked['modules'], $data['modules'] ) && is_array( $locked['modules'] ) && is_array( $data['modules'] ) ) {
6885 + foreach ( array_keys( $data['modules'] ) as $vg_module ) {
6886 + if ( in_array( sanitize_key( $vg_module ), $locked['modules'], true ) ) {
6887 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
6888 + }
6889 + }
6890 + }
6891 +
6678 6892 $rejected_ips = array();
6679 6893
6680 6894 // Handle modules
6681 6895 if ( 'modules' === $section && isset( $data['modules'] ) ) {
@@ -6714,8 +6928,10 @@
6714 6928
6715 6929 // Clear cache before saving
6716 6930 wp_cache_delete( Vigilante_Settings::OPTION_NAME, 'options' );
6717 6931
6932 + $saved_options = Vigilante_Settings::keep_locked_file_settings( $saved_options, $stored_options );
6933 +
6718 6934 // Save to database
6719 6935 update_option( Vigilante_Settings::OPTION_NAME, $saved_options );
6720 6936
6721 6937 // Clear the settings cache
@@ -7156,8 +7372,9 @@
7156 7372 }
7157 7373 }
7158 7374
7159 7375 // Save
7376 + $merged = Vigilante_Settings::keep_locked_file_settings( $merged, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7160 7377 update_option( Vigilante_Settings::OPTION_NAME, $merged );
7161 7378 $this->settings->clear_cache();
7162 7379
7163 7380 // Re-evaluate the active preset marker. The imported config may match
@@ -7180,9 +7397,9 @@
7180 7397 if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
7181 7398 wp_schedule_single_event( time() + 5, 'vigilante_under_attack_post_scan' );
7182 7399 }
7183 7400
7184 - wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) );
7401 + wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
7185 7402 }
7186 7403
7187 7404 /**
7188 7405 * Detect whether a vigilante_options array matches a known preset.
@@ -7285,9 +7502,11 @@
7285 7502 $preset = isset( $_POST['preset'] ) ? sanitize_key( $_POST['preset'] ) : '';
7286 7503
7287 7504 // Handle reset to defaults
7288 7505 if ( 'reset' === $preset ) {
7289 - $defaults = Vigilante_Settings::get_defaults_preserving_user_data( get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7506 + $stored_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
7507 + $defaults = Vigilante_Settings::get_defaults_preserving_user_data( $stored_options );
7508 + $defaults = Vigilante_Settings::keep_locked_file_settings( $defaults, $stored_options );
7290 7509 update_option( Vigilante_Settings::OPTION_NAME, $defaults );
7291 7510 $this->settings->clear_cache();
7292 7511
7293 7512 // Clear active preset
@@ -7295,9 +7514,9 @@
7295 7514
7296 7515 // Apply file changes after reset
7297 7516 $this->apply_all_file_changes( $defaults );
7298 7517
7299 - wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) );
7518 + wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) . $this->locked_file_settings_message() );
7300 7519 return;
7301 7520 }
7302 7521
7303 7522 $presets = $this->settings->get_presets();
@@ -7323,8 +7542,9 @@
7323 7542 // invent keys that are missing on both sides.
7324 7543 $current = Vigilante_Settings::merge_preset( $this->settings->get_default_options(), $current );
7325 7544
7326 7545 $merged = Vigilante_Settings::merge_preset( $current, $preset_options );
7546 + $merged = Vigilante_Settings::keep_locked_file_settings( $merged, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7327 7547
7328 7548 update_option( Vigilante_Settings::OPTION_NAME, $merged );
7329 7549 $this->settings->clear_cache();
7330 7550
@@ -7333,9 +7553,9 @@
7333 7553
7334 7554 // Apply file changes after preset
7335 7555 $this->apply_all_file_changes( $merged );
7336 7556
7337 - wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) );
7557 + wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
7338 7558 }
7339 7559
7340 7560 /**
7341 7561 * AJAX: Reset a specific section to defaults
@@ -7370,27 +7590,19 @@
7370 7590 * On a subsite, the settings written to wp-config.php and .htaccess are
7371 7591 * the main site's business. Resetting the local copy of those would only
7372 7592 * make this screen disagree with the file, so they are carried over
7373 7593 * untouched, and a section that is nothing but shared settings is not
7374 - * reset at all.
7594 + * reset at all. On the main site, a user without network rights keeps
7595 + * the ones the shared files are built from as well (2.11.6).
7375 7596 */
7376 - if ( ! Vigilante_Settings::can_write_shared_files() ) {
7377 - $shared = Vigilante_Settings::get_shared_file_settings();
7597 + $locked = Vigilante_Settings::get_locked_file_settings();
7378 7598
7379 - if ( isset( $shared[ $section ] ) ) {
7380 - if ( true === $shared[ $section ] ) {
7381 - wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7382 - }
7383 -
7384 - foreach ( $shared[ $section ] as $shared_key ) {
7385 - if ( array_key_exists( $shared_key, (array) $current_options[ $section ] ) ) {
7386 - $new_values[ $shared_key ] = $current_options[ $section ][ $shared_key ];
7387 - }
7388 - }
7389 - }
7599 + if ( isset( $locked[ $section ] ) && true === $locked[ $section ] ) {
7600 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7390 7601 }
7391 7602
7392 7603 $current_options[ $section ] = $new_values;
7604 + $current_options = Vigilante_Settings::keep_locked_file_settings( $current_options, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7393 7605
7394 7606 // Save
7395 7607 update_option( Vigilante_Settings::OPTION_NAME, $current_options );
7396 7608 $this->settings->clear_cache();
@@ -7473,8 +7685,19 @@
7473 7685 // Save new results
7474 7686 update_option( 'vigilante_last_integrity_scan', time() );
7475 7687 update_option( 'vigilante_last_integrity_results', $results );
7476 7688
7689 + // On the main site the scan does compute the lines of wp-config.php and
7690 + // .htaccess, for the network administrator. Somebody without network
7691 + // rights gets the change and its sizes, not the lines.
7692 + if ( $this->critical_approval_locked() && ! empty( $results['modified'] ) && is_array( $results['modified'] ) ) {
7693 + foreach ( $results['modified'] as $index => $item ) {
7694 + if ( is_array( $item ) && 'critical_config' === ( $item['type'] ?? '' ) ) {
7695 + $results['modified'][ $index ]['diff'] = Vigilante_File_Integrity::network_only_diff();
7696 + }
7697 + }
7698 + }
7699 +
7477 7700 wp_send_json_success( array(
7478 7701 'message' => __( 'Scan completed.', 'vigilante' ),
7479 7702 'results' => $results,
7480 7703 'ignored_count' => count( get_option( 'vigilante_ignored_files', array() ) ),
@@ -7508,11 +7731,41 @@
7508 7731 if ( ! current_user_can( 'manage_options' ) ) {
7509 7732 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
7510 7733 }
7511 7734
7735 + $results = get_option( 'vigilante_last_integrity_results' );
7736 + $scanned_at = get_option( 'vigilante_last_integrity_scan' );
7737 +
7512 7738 delete_option( 'vigilante_last_integrity_results' );
7513 7739 delete_option( 'vigilante_last_integrity_scan' );
7514 7740
7741 + /*
7742 + * A pending change to wp-config.php or the root .htaccess is closed by
7743 + * approving it, which takes the network. Clearing the results was one
7744 + * more way to close it without, until the next scan: the ignore list was
7745 + * shut in 2.11.8 and this button was left open, found by the cross
7746 + * review of 2.11.8. So for somebody who cannot approve, those entries
7747 + * stay and everything else goes.
7748 + */
7749 + if ( $this->critical_approval_locked() && is_array( $results ) && ! empty( $results['modified'] ) && is_array( $results['modified'] ) ) {
7750 + $critical = array_values(
7751 + array_filter(
7752 + $results['modified'],
7753 + function ( $item ) {
7754 + return is_array( $item ) && 'critical_config' === ( $item['type'] ?? '' );
7755 + }
7756 + )
7757 + );
7758 +
7759 + if ( $critical ) {
7760 + $results['modified'] = $critical;
7761 + $results['suspicious'] = array();
7762 + $results['extra'] = array();
7763 + update_option( 'vigilante_last_integrity_results', $results );
7764 + update_option( 'vigilante_last_integrity_scan', $scanned_at ? $scanned_at : time() );
7765 + }
7766 + }
7767 +
7515 7768 if ( $this->database ) {
7516 7769 $this->database->clear_file_hashes();
7517 7770 }
7518 7771
@@ -7538,8 +7791,14 @@
7538 7791 if ( empty( $file ) ) {
7539 7792 wp_send_json_error( __( 'No file specified.', 'vigilante' ) );
7540 7793 }
7541 7794
7795 + // A change to a shared file is closed by approving it, and approving it
7796 + // takes the network. Ignoring it would close the same warning without.
7797 + if ( $this->critical_approval_locked() && in_array( $file, array( 'wp-config.php', '.htaccess' ), true ) ) {
7798 + wp_send_json_error( $this->critical_approval_notice() );
7799 + }
7800 +
7542 7801 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database );
7543 7802 $file_integrity->ignore_file( $file );
7544 7803
7545 7804 // Also remove the file from stored scan results so UI updates
@@ -7603,12 +7862,14 @@
7603 7862 if ( ! is_array( $raw_files ) ) {
7604 7863 wp_send_json_error( __( 'Invalid request.', 'vigilante' ) );
7605 7864 }
7606 7865
7607 - $files = array();
7866 + $files = array();
7867 + $shared = $this->critical_approval_locked() ? array( 'wp-config.php', '.htaccess' ) : array();
7608 7868 foreach ( $raw_files as $f ) {
7609 7869 $clean = sanitize_text_field( $f );
7610 - if ( '' !== $clean ) {
7870 + // Same rule as ajax_ignore_file() for the two shared files.
7871 + if ( '' !== $clean && ! in_array( $clean, $shared, true ) ) {
7611 7872 $files[] = $clean;
7612 7873 }
7613 7874 }
7614 7875