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 +397 -47 2.10.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
@@ -425,8 +445,27 @@
425 445 }
426 446
427 447 update_option( 'vigilante_db_version', '2.9.9' );
428 448 }
449 +
450 + /*
451 + * 2.11.0: security release (audit of 28 Aug 2026). Runs here and not
452 + * from Vigilante_Database::needs_update(): this option is shared with
453 + * that class, and on any updated site it already holds a plugin version
454 + * (2.9.9 or later), so a bump of DB_VERSION would never fire.
455 + * create_tables() widens the email code column through dbDelta (varchar
456 + * 6 to 64, the code is stored hashed since 2.11.0) and purge_for_2_11_0()
457 + * does what dbDelta cannot: it empties the trusted devices, which were
458 + * identified by User-Agent until now (S1), and the pending email codes,
459 + * stored in clear until now (S11). Every remembered device asks for the
460 + * second factor once more after this update, and the changelog says so.
461 + */
462 + if ( version_compare( $db_version, '2.11.0', '<' ) ) {
463 + $this->database->create_tables();
464 + $this->database->purge_for_2_11_0();
465 +
466 + update_option( 'vigilante_db_version', '2.11.0' );
467 + }
429 468 }
430 469
431 470 /**
432 471 * Migration: Remove orphaned email fields from saved options
@@ -1429,8 +1468,11 @@
1429 1468 'currentUserId' => get_current_user_id(),
1430 1469 'logoutUrl' => wp_logout_url( wp_login_url() ),
1431 1470 'adminUrl' => admin_url( 'admin.php?page=vigilante' ),
1432 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(),
1433 1475 'underAttack' => array(
1434 1476 'active' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->is_active(),
1435 1477 'remaining' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->get_remaining_time(),
1436 1478 ),
@@ -1497,13 +1539,17 @@
1497 1539 'criticalConfigTitle' => __( 'Critical config files modified', 'vigilante' ),
1498 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' ),
1499 1541 'approve' => __( 'Approve', 'vigilante' ),
1500 1542 'approving' => __( 'Approving...', 'vigilante' ),
1543 + 'approvalLockedNotice' => $this->critical_approval_notice(),
1501 1544 'criticalApproved' => __( 'Change approved. Next scan will use the current state as baseline.', 'vigilante' ),
1502 1545 'reviewChanges' => __( 'Review changes', 'vigilante' ),
1503 1546 'hideChanges' => __( 'Hide changes', 'vigilante' ),
1504 1547 'changes' => __( 'Changes', 'vigilante' ),
1505 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' ),
1506 1552 'diffEmpty' => __( 'No line-level changes detected (may be whitespace or reordering).', 'vigilante' ),
1507 1553 'diffLines' => __( 'lines', 'vigilante' ),
1508 1554 // Under Attack mode strings
1509 1555 'underAttackConfirmActivate' => __( 'Activate Under Attack mode? All visitors will see a verification page for the next 4 hours.', 'vigilante' ),
@@ -1766,8 +1812,21 @@
1766 1812 </p>
1767 1813 <p>
1768 1814 <em><?php esc_html_e( 'Vigilant has applied the Maximum preset plus extra hardening on top of your previous configuration. Any changes you make to Vigilant settings while this mode is active will be reverted when it ends.', 'vigilante' ); ?></em>
1769 1815 </p>
1816 + <?php
1817 + // The cache-bypass rules could not be written (a host where
1818 + // WordPress cannot write files by itself, a held lock, a
1819 + // failed read-back): show them, so they can be added by hand.
1820 + $ua_instance = new Vigilante_Under_Attack( $this->settings, $this->activity_log );
1821 + if ( $ua_instance->cache_rules_missing() ) :
1822 + ?>
1823 + <p>
1824 + <strong><?php esc_html_e( 'The cache-bypass rules could not be written to your .htaccess.', 'vigilante' ); ?></strong>
1825 + <?php esc_html_e( 'Without them a page cache may keep serving stored pages during the attack. Add this block at the top of the .htaccess in your site root (the activity log records why it was not written):', 'vigilante' ); ?>
1826 + </p>
1827 + <textarea readonly rows="9" class="large-text code" onclick="this.select();"><?php echo esc_textarea( Vigilante_Under_Attack::get_cache_bypass_block() ); ?></textarea>
1828 + <?php endif; ?>
1770 1829 </div>
1771 1830 <?php
1772 1831 }
1773 1832 }
@@ -1999,8 +2058,41 @@
1999 2058 return ! Vigilante_Settings::can_write_shared_files();
2000 2059 }
2001 2060
2002 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 + /**
2003 2095 * Print the shared-files notice for a section that cannot be edited here
2004 2096 *
2005 2097 * @since 2.9.8
2006 2098 */
@@ -2015,8 +2107,109 @@
2015 2107 <?php
2016 2108 }
2017 2109
2018 2110 /**
2111 + * Acting on another user's account needs permission over that user
2112 + *
2113 + * Since 2.10.3 the handlers behind these tools ask for edit_user over the
2114 + * target, which is the rule WordPress itself applies. On a network the core
2115 + * grants edit_user only to network administrators, so for anybody else these
2116 + * controls do nothing. Better to say so than to paint a button that silently
2117 + * skips every user.
2118 + *
2119 + * @since 2.10.4
2120 + * @return bool
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 + */
2155 + private function user_actions_locked() {
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' );
2160 + }
2161 +
2162 + /**
2163 + * Print the notice for user tools that cannot be used from this site
2164 + *
2165 + * @since 2.10.4
2166 + */
2167 + private function render_user_actions_notice() {
2168 + if ( ! $this->user_actions_locked() ) {
2169 + return;
2170 + }
2171 + ?>
2172 + <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;">
2173 + <?php if ( is_multisite() ) : ?>
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; ?>
2178 + </div>
2179 + <?php
2180 + }
2181 +
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 + /**
2019 2212 * Check if module is disabled and render warning
2020 2213 *
2021 2214 * @param string $module_key Module key.
2022 2215 * @return bool True if disabled.
@@ -2560,14 +2753,15 @@
2560 2753
2561 2754 <?php $this->render_analyzer_widget( $analyzer_last_scan, $analyzer_history, $analyzer_categories_def, $analyzer_settings ); ?>
2562 2755
2563 2756 <div class="vigilante-modules-grid">
2564 - <h2><?php esc_html_e( 'Security Modules', 'vigilante' ); ?></h2>
2757 + <h2 id="vigilante-section-dashboard-modules"><?php esc_html_e( 'Security Modules', 'vigilante' ); ?></h2>
2565 2758 <p class="description"><?php esc_html_e( 'Enable or disable security modules. Each module controls a tab with detailed settings.', 'vigilante' ); ?></p>
2566 2759 <div class="vigilante-modules-list">
2567 2760 <?php foreach ( $options['modules'] as $module => $enabled ) :
2568 2761 $label = isset( $module_labels[ $module ] ) ? $module_labels[ $module ] : ucwords( str_replace( '_', ' ', $module ) );
2569 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 );
2570 2764 ?>
2571 2765 <div class="vigilante-module-item <?php echo $enabled ? 'enabled' : 'disabled'; ?>">
2572 2766 <div class="vigilante-module-header">
2573 2767 <span class="vigilante-module-status"></span>
@@ -2580,8 +2774,9 @@
2580 2774 <input type="checkbox"
2581 2775 name="modules[<?php echo esc_attr( $module ); ?>]"
2582 2776 value="1"
2583 2777 <?php checked( $enabled ); ?>
2778 + <?php disabled( $vg_module_locked ); ?>
2584 2779 aria-label="<?php echo esc_attr( $toggle_label ); ?>"
2585 2780 data-module="<?php echo esc_attr( $module ); ?>">
2586 2781 <span class="vigilante-toggle-slider"></span>
2587 2782 </label>
@@ -2588,8 +2783,11 @@
2588 2783 </div>
2589 2784 <?php if ( $description ) : ?>
2590 2785 <p class="vigilante-module-desc"><?php echo esc_html( $description ); ?></p>
2591 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; ?>
2592 2790 </div>
2593 2791 <?php endforeach; ?>
2594 2792 </div>
2595 2793 </div>
@@ -2621,9 +2819,9 @@
2621 2819 $ua_remaining_hours = floor( $ua_remaining / 3600 );
2622 2820 $ua_remaining_mins = floor( ( $ua_remaining % 3600 ) / 60 );
2623 2821 ?>
2624 2822 <div class="vigilante-preset-card vigilante-under-attack-card <?php echo $ua_active ? 'vigilante-under-attack-active' : ''; ?>">
2625 - <h3>
2823 + <h3 id="vigilante-section-dashboard-under-attack">
2626 2824 <span class="dashicons dashicons-shield"></span>
2627 2825 <?php esc_html_e( 'Under Attack', 'vigilante' ); ?>
2628 2826 </h3>
2629 2827 <p><?php esc_html_e( 'Emergency mode. JavaScript challenge for all visitors, aggressive rate limiting, and restricted access. Auto-deactivates after 4 hours.', 'vigilante' ); ?></p>
@@ -2979,14 +3177,21 @@
2979 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' ); ?>
2980 3178 </p>
2981 3179 </div>
2982 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 +
2983 3188 <table class="form-table">
2984 3189 <tr>
2985 3190 <th scope="row"><?php esc_html_e( 'Block Bad Query Strings', 'vigilante' ); ?></th>
2986 3191 <td>
2987 3192 <label>
2988 - <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'] ) ); ?>>
2989 3194 <?php esc_html_e( 'Block malicious query string patterns', 'vigilante' ); ?>
2990 3195 </label>
2991 3196 </td>
2992 3197 </tr>
@@ -3029,9 +3234,9 @@
3029 3234 <tr>
3030 3235 <th scope="row"><?php esc_html_e( 'Block Bad Bots', 'vigilante' ); ?></th>
3031 3236 <td>
3032 3237 <label>
3033 - <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'] ) ); ?>>
3034 3239 <?php esc_html_e( 'Block known malicious bots and scanners', 'vigilante' ); ?>
3035 3240 </label>
3036 3241 </td>
3037 3242 </tr>
@@ -3145,8 +3350,29 @@
3145 3350 </table>
3146 3351 </div>
3147 3352 <?php endif; ?>
3148 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 +
3149 3375 <h3><?php esc_html_e( 'IP Lists', 'vigilante' ); ?></h3>
3150 3376 <p class="description">
3151 3377 <?php
3152 3378 printf(
@@ -3160,9 +3386,9 @@
3160 3386 <tr>
3161 3387 <th scope="row"><label for="vigilante-f-firewall-trusted-proxy-header"><?php esc_html_e( 'Visitor IP detection', 'vigilante' ); ?></label></th>
3162 3388 <td>
3163 3389 <?php $proxy_header = $options['trusted_proxy_header'] ?? ''; ?>
3164 - <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 ); ?>>
3165 3391 <option value="" <?php selected( $proxy_header, '' ); ?>><?php esc_html_e( 'Direct connection, only REMOTE_ADDR (recommended)', 'vigilante' ); ?></option>
3166 3392 <option value="cf-connecting-ip" <?php selected( $proxy_header, 'cf-connecting-ip' ); ?>><?php esc_html_e( 'Behind Cloudflare (CF-Connecting-IP)', 'vigilante' ); ?></option>
3167 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>
3168 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>
@@ -3174,9 +3400,9 @@
3174 3400 </tr>
3175 3401 <tr>
3176 3402 <th scope="row"><label for="vigilante-f-firewall-ip-whitelist"><?php esc_html_e( 'IP Whitelist', 'vigilante' ); ?></label></th>
3177 3403 <td>
3178 - <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>
3179 3405 <p class="description">
3180 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' ); ?>
3181 3407 <br>
3182 3408 <?php
@@ -3215,9 +3441,9 @@
3215 3441 <table class="form-table">
3216 3442 <tr>
3217 3443 <th scope="row"><label for="vigilante-f-firewall-ua-whitelist"><?php esc_html_e( 'User-Agent Whitelist', 'vigilante' ); ?></label></th>
3218 3444 <td>
3219 - <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>
3220 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>
3221 3447 </td>
3222 3448 </tr>
3223 3449 <tr>
@@ -3505,9 +3731,9 @@
3505 3731 $two_factor = $options['two_factor'] ?? array();
3506 3732 $two_factor_enabled = ! empty( $two_factor['enabled'] );
3507 3733 ?>
3508 3734 <div class="vigilante-settings-section vigilante-lockout-section">
3509 - <h2><?php esc_html_e( 'Login Protection Status', 'vigilante' ); ?></h2>
3735 + <h2 id="vigilante-section-login-status"><?php esc_html_e( 'Login Protection Status', 'vigilante' ); ?></h2>
3510 3736
3511 3737 <table class="form-table">
3512 3738 <tr>
3513 3739 <th scope="row"><?php esc_html_e( 'Current settings', 'vigilante' ); ?></th>
@@ -3667,9 +3893,9 @@
3667 3893 $excluded = $two_factor['excluded_users'] ?? array();
3668 3894 $method = $two_factor['method'] ?? 'email';
3669 3895 $grace_days = $two_factor['grace_period_days'] ?? 3;
3670 3896 ?>
3671 - <h3>
3897 + <h3 id="vigilante-section-login-2fa">
3672 3898 <?php esc_html_e( 'Two-Factor Authentication (2FA)', 'vigilante' ); ?>
3673 3899 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
3674 3900 <span class="vigilante-method-badge database"><?php esc_html_e( 'Database', 'vigilante' ); ?></span>
3675 3901 </h3>
@@ -4040,9 +4266,9 @@
4040 4266 </td>
4041 4267 </tr>
4042 4268 </table>
4043 4269
4044 - <h3><?php esc_html_e( 'Content Security Policy', 'vigilante' ); ?></h3>
4270 + <h3 id="vigilante-section-headers-csp"><?php esc_html_e( 'Content Security Policy', 'vigilante' ); ?></h3>
4045 4271 <table class="form-table">
4046 4272 <tr>
4047 4273 <th scope="row"><?php esc_html_e( 'Enable CSP', 'vigilante' ); ?></th>
4048 4274 <td>
@@ -4062,9 +4288,9 @@
4062 4288 </td>
4063 4289 </tr>
4064 4290 </table>
4065 4291
4066 - <h3><?php esc_html_e( 'HTTPS', 'vigilante' ); ?></h3>
4292 + <h3 id="vigilante-section-headers-force-https"><?php esc_html_e( 'HTTPS', 'vigilante' ); ?></h3>
4067 4293 <p class="description"><?php esc_html_e( 'HTTPS is strongly recommended, but Vigilant will not impose it. Enable only what your site already supports.', 'vigilante' ); ?></p>
4068 4294 <table class="form-table">
4069 4295 <tr>
4070 4296 <th scope="row"><?php esc_html_e( 'Redirect HTTP to HTTPS', 'vigilante' ); ?></th>
@@ -4107,9 +4333,9 @@
4107 4333 </td>
4108 4334 </tr>
4109 4335 </table>
4110 4336
4111 - <h3><?php esc_html_e( 'HSTS (HTTP Strict Transport Security)', 'vigilante' ); ?></h3>
4337 + <h3 id="vigilante-section-headers-hsts"><?php esc_html_e( 'HSTS (HTTP Strict Transport Security)', 'vigilante' ); ?></h3>
4112 4338 <?php $vig_home_https = ( 0 === strpos( (string) get_option( 'home' ), 'https://' ) ); ?>
4113 4339 <p class="description"><?php esc_html_e( 'Tells browsers to reach this site over HTTPS and never over HTTP, for as long as the max age below.', 'vigilante' ); ?></p>
4114 4340 <?php if ( ! $vig_home_https ) : ?>
4115 4341 <p class="description" style="color:#b32d2e"><strong><?php esc_html_e( 'Unavailable: the site address still starts with http://. Enabling HSTS on a site not published over HTTPS would make it unreachable in any browser that honours it.', 'vigilante' ); ?></strong></p>
@@ -4150,9 +4376,9 @@
4150 4376 </td>
4151 4377 </tr>
4152 4378 </table>
4153 4379
4154 - <h3><?php esc_html_e( 'Server Identity', 'vigilante' ); ?></h3>
4380 + <h3 id="vigilante-section-headers-fingerprint"><?php esc_html_e( 'Server Identity', 'vigilante' ); ?></h3>
4155 4381 <p class="description"><?php esc_html_e( 'Hide identifying information that servers expose in responses.', 'vigilante' ); ?></p>
4156 4382 <table class="form-table">
4157 4383 <tr>
4158 4384 <th scope="row"><?php esc_html_e( 'Server Signature', 'vigilante' ); ?></th>
@@ -4741,8 +4967,11 @@
4741 4967 <h2 class="vigilante-tools-header">
4742 4968 <?php esc_html_e( 'User security tools', 'vigilante' ); ?>
4743 4969 </h2>
4744 4970
4971 + <?php $this->render_user_actions_notice(); ?>
4972 + <?php if ( ! $this->user_actions_locked() ) : ?>
4973 +
4745 4974 <!-- Force Password Reset -->
4746 4975 <div class="vigilante-tool-box">
4747 4976 <h3><?php esc_html_e( 'Force password reset', 'vigilante' ); ?></h3>
4748 4977 <p class="description"><?php esc_html_e( 'Force users to reset their password. Useful after a security incident. Users will receive an email with a reset link.', 'vigilante' ); ?></p>
@@ -4878,9 +5107,9 @@
4878 5107 <?php
4879 5108 $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log );
4880 5109 $pending_users = $user_security->get_pending_users();
4881 5110 ?>
4882 - <div class="vigilante-tool-box vigilante-pending-users-section">
5111 + <div id="vigilante-section-users-pending" class="vigilante-tool-box vigilante-pending-users-section">
4883 5112 <h3>
4884 5113 <?php esc_html_e( 'Pending registrations', 'vigilante' ); ?>
4885 5114 <?php if ( count( $pending_users ) > 0 ) : ?>
4886 5115 <span class="vigilante-badge vigilante-badge-warning"><?php echo esc_html( count( $pending_users ) ); ?></span>
@@ -4897,8 +5126,9 @@
4897 5126 <span class="dashicons dashicons-yes-alt"></span>
4898 5127 <p><?php esc_html_e( 'No pending registrations.', 'vigilante' ); ?></p>
4899 5128 </div>
4900 5129 <?php else : ?>
5130 + <?php $this->render_user_actions_notice(); ?>
4901 5131 <table class="wp-list-table widefat fixed striped vigilante-pending-users-table">
4902 5132 <thead>
4903 5133 <tr>
4904 5134 <th><?php esc_html_e( 'User', 'vigilante' ); ?></th>
@@ -4927,12 +5157,12 @@
4927 5157 }
4928 5158 ?>
4929 5159 </td>
4930 5160 <td>
4931 - <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() ); ?>>
4932 5162 <?php esc_html_e( 'Approve', 'vigilante' ); ?>
4933 5163 </button>
4934 - <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() ); ?>>
4935 5165 <?php esc_html_e( 'Reject', 'vigilante' ); ?>
4936 5166 </button>
4937 5167 </td>
4938 5168 </tr>
@@ -5054,8 +5284,10 @@
5054 5284 </button>
5055 5285 </p>
5056 5286 </div>
5057 5287 </div>
5288 +
5289 + <?php endif; ?>
5058 5290 </div>
5059 5291 <?php
5060 5292 }
5061 5293
@@ -5824,8 +6056,13 @@
5824 6056 */
5825 6057 private function render_tab_file_integrity() {
5826 6058 $is_disabled = $this->render_module_disabled_notice( 'file_integrity' );
5827 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();
5828 6065 $last_scan = get_option( 'vigilante_last_integrity_scan' );
5829 6066 $last_results = get_option( 'vigilante_last_integrity_results' );
5830 6067 $ignored_files = get_option( 'vigilante_ignored_files', array() );
5831 6068
@@ -5951,10 +6188,13 @@
5951 6188 <?php esc_html_e( 'Uploads directory (detect PHP files, double extensions, .htaccess)', 'vigilante' ); ?>
5952 6189 </label>
5953 6190 <br>
5954 6191 <label>
5955 - <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 ); ?>>
5956 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; ?>
5957 6197 </label>
5958 6198 <br>
5959 6199 <label>
5960 6200 <input type="checkbox" name="file_integrity[check_closed_plugins]" value="1" <?php checked( $options['check_closed_plugins'] ?? true ); ?>>
@@ -6233,9 +6473,15 @@
6233 6473 $crit_diff = $crit_item['diff'] ?? array();
6234 6474 $crit_id = sanitize_html_class( $crit_file );
6235 6475 $added_count = is_array( $crit_diff ) ? count( $crit_diff['added'] ?? array() ) : 0;
6236 6476 $removed_count = is_array( $crit_diff ) ? count( $crit_diff['removed'] ?? array() ) : 0;
6237 - $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'] ) );
6238 6484 ?>
6239 6485 <tr>
6240 6486 <td><code style="color: #e36210;"><?php echo esc_html( $crit_file ); ?></code></td>
6241 6487 <td>
@@ -6258,18 +6504,36 @@
6258 6504 <td>
6259 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' ); ?>">
6260 6506 <?php esc_html_e( 'Review changes', 'vigilante' ); ?>
6261 6507 </button>
6262 - <button type="button" class="button button-small button-primary vigilante-approve-critical-file" data-file="<?php echo esc_attr( $crit_file ); ?>">
6263 - <?php esc_html_e( 'Approve', 'vigilante' ); ?>
6264 - </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; ?>
6265 6517 </td>
6266 6518 </tr>
6267 6519 <tr id="vigilante-critical-content-<?php echo esc_attr( $crit_id ); ?>" class="vigilante-critical-content-row" style="display:none;">
6268 6520 <td colspan="3" style="padding: 0;">
6269 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;">
6270 - <?php if ( $diff_unavailable ) : ?>
6522 + <?php if ( $diff_network ) : ?>
6271 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;">
6272 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' ); ?>
6273 6537 </p>
6274 6538 <?php elseif ( empty( $crit_diff['added'] ) && empty( $crit_diff['removed'] ) ) : ?>
6275 6539 <p style="color: #50575e; font-style: italic; margin: 0;">
@@ -6297,9 +6561,9 @@
6297 6561 <?php endif; ?>
6298 6562
6299 6563 <?php if ( $has_closed ) : ?>
6300 6564 <div class="vigilante-file-list vigilante-closed-plugins">
6301 - <h3 style="color: #d63638;"><?php esc_html_e( 'Closed + Removed Plugins', 'vigilante' ); ?></h3>
6565 + <h3 id="vigilante-section-fi-closed-plugins" style="color: #d63638;"><?php esc_html_e( 'Closed + Removed Plugins', 'vigilante' ); ?></h3>
6302 6566 <p class="description" style="color: #d63638;">
6303 6567 <?php esc_html_e( '&#9888; Warning: These plugins have been closed in the WordPress.org repository. Closures usually indicate malware, security issues, guideline violations, or supply chain attacks. Uninstall and replace as soon as possible.', 'vigilante' ); ?>
6304 6568 </p>
6305 6569 <table class="wp-list-table widefat striped">
@@ -6508,8 +6772,15 @@
6508 6772 if ( ! current_user_can( 'manage_options' ) ) {
6509 6773 wp_die( esc_html__( 'Permission denied.', 'vigilante' ), 403 );
6510 6774 }
6511 6775
6776 + // The archive carries wp-config.php, which a whole network shares. On a
6777 + // network manage_options is held by every subsite administrator, so the
6778 + // same gate the writers use applies here.
6779 + if ( ! Vigilante_Settings::can_write_shared_files() ) {
6780 + wp_die( esc_html( Vigilante_Settings::get_shared_files_notice() ), 403 );
6781 + }
6782 +
6512 6783 $backup_manager = new Vigilante_Backup_Manager();
6513 6784 $result = $backup_manager->stream_files_zip();
6514 6785
6515 6786 // stream_files_zip() exits on success; only a WP_Error returns here.
@@ -6598,8 +6869,27 @@
6598 6869
6599 6870 // Read ONLY saved options from database (not merged with defaults)
6600 6871 $saved_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
6601 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 +
6602 6892 $rejected_ips = array();
6603 6893
6604 6894 // Handle modules
6605 6895 if ( 'modules' === $section && isset( $data['modules'] ) ) {
@@ -6638,8 +6928,10 @@
6638 6928
6639 6929 // Clear cache before saving
6640 6930 wp_cache_delete( Vigilante_Settings::OPTION_NAME, 'options' );
6641 6931
6932 + $saved_options = Vigilante_Settings::keep_locked_file_settings( $saved_options, $stored_options );
6933 +
6642 6934 // Save to database
6643 6935 update_option( Vigilante_Settings::OPTION_NAME, $saved_options );
6644 6936
6645 6937 // Clear the settings cache
@@ -7062,13 +7354,27 @@
7062 7354
7063 7355 // Sanitize imported data recursively
7064 7356 $imported = map_deep( $imported, 'sanitize_text_field' );
7065 7357
7066 - // Validate structure
7067 - $defaults = $this->settings->get_default_options();
7068 - $merged = array_replace_recursive( $defaults, $imported );
7358 + // Validate structure: only sections and keys of the schema survive, and
7359 + // every value takes the type of its default. Until 2.11.0 this was an
7360 + // array_replace_recursive() of the file over the defaults, so any key in
7361 + // the file, known or not, landed in vigilante_options (S7). Sections
7362 + // the file does not carry keep their defaults; a section it does carry
7363 + // replaces the default one whole, because validate_options() has
7364 + // already filled in whatever the file left out.
7365 + $defaults = $this->settings->get_default_options();
7366 + $validated = $this->settings->validate_options( $imported );
7367 + $merged = $defaults;
7069 7368
7369 + foreach ( $validated as $section => $data ) {
7370 + if ( is_array( $data ) ) {
7371 + $merged[ $section ] = $data;
7372 + }
7373 + }
7374 +
7070 7375 // Save
7376 + $merged = Vigilante_Settings::keep_locked_file_settings( $merged, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7071 7377 update_option( Vigilante_Settings::OPTION_NAME, $merged );
7072 7378 $this->settings->clear_cache();
7073 7379
7074 7380 // Re-evaluate the active preset marker. The imported config may match
@@ -7091,9 +7397,9 @@
7091 7397 if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
7092 7398 wp_schedule_single_event( time() + 5, 'vigilante_under_attack_post_scan' );
7093 7399 }
7094 7400
7095 - wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) );
7401 + wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
7096 7402 }
7097 7403
7098 7404 /**
7099 7405 * Detect whether a vigilante_options array matches a known preset.
@@ -7196,9 +7502,11 @@
7196 7502 $preset = isset( $_POST['preset'] ) ? sanitize_key( $_POST['preset'] ) : '';
7197 7503
7198 7504 // Handle reset to defaults
7199 7505 if ( 'reset' === $preset ) {
7200 - $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 );
7201 7509 update_option( Vigilante_Settings::OPTION_NAME, $defaults );
7202 7510 $this->settings->clear_cache();
7203 7511
7204 7512 // Clear active preset
@@ -7206,9 +7514,9 @@
7206 7514
7207 7515 // Apply file changes after reset
7208 7516 $this->apply_all_file_changes( $defaults );
7209 7517
7210 - wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) );
7518 + wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) . $this->locked_file_settings_message() );
7211 7519 return;
7212 7520 }
7213 7521
7214 7522 $presets = $this->settings->get_presets();
@@ -7234,8 +7542,9 @@
7234 7542 // invent keys that are missing on both sides.
7235 7543 $current = Vigilante_Settings::merge_preset( $this->settings->get_default_options(), $current );
7236 7544
7237 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() ) );
7238 7547
7239 7548 update_option( Vigilante_Settings::OPTION_NAME, $merged );
7240 7549 $this->settings->clear_cache();
7241 7550
@@ -7244,9 +7553,9 @@
7244 7553
7245 7554 // Apply file changes after preset
7246 7555 $this->apply_all_file_changes( $merged );
7247 7556
7248 - wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) );
7557 + wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
7249 7558 }
7250 7559
7251 7560 /**
7252 7561 * AJAX: Reset a specific section to defaults
@@ -7281,27 +7590,19 @@
7281 7590 * On a subsite, the settings written to wp-config.php and .htaccess are
7282 7591 * the main site's business. Resetting the local copy of those would only
7283 7592 * make this screen disagree with the file, so they are carried over
7284 7593 * untouched, and a section that is nothing but shared settings is not
7285 - * 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).
7286 7596 */
7287 - if ( ! Vigilante_Settings::can_write_shared_files() ) {
7288 - $shared = Vigilante_Settings::get_shared_file_settings();
7597 + $locked = Vigilante_Settings::get_locked_file_settings();
7289 7598
7290 - if ( isset( $shared[ $section ] ) ) {
7291 - if ( true === $shared[ $section ] ) {
7292 - wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7293 - }
7294 -
7295 - foreach ( $shared[ $section ] as $shared_key ) {
7296 - if ( array_key_exists( $shared_key, (array) $current_options[ $section ] ) ) {
7297 - $new_values[ $shared_key ] = $current_options[ $section ][ $shared_key ];
7298 - }
7299 - }
7300 - }
7599 + if ( isset( $locked[ $section ] ) && true === $locked[ $section ] ) {
7600 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7301 7601 }
7302 7602
7303 7603 $current_options[ $section ] = $new_values;
7604 + $current_options = Vigilante_Settings::keep_locked_file_settings( $current_options, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7304 7605
7305 7606 // Save
7306 7607 update_option( Vigilante_Settings::OPTION_NAME, $current_options );
7307 7608 $this->settings->clear_cache();
@@ -7384,8 +7685,19 @@
7384 7685 // Save new results
7385 7686 update_option( 'vigilante_last_integrity_scan', time() );
7386 7687 update_option( 'vigilante_last_integrity_results', $results );
7387 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 +
7388 7700 wp_send_json_success( array(
7389 7701 'message' => __( 'Scan completed.', 'vigilante' ),
7390 7702 'results' => $results,
7391 7703 'ignored_count' => count( get_option( 'vigilante_ignored_files', array() ) ),
@@ -7419,11 +7731,41 @@
7419 7731 if ( ! current_user_can( 'manage_options' ) ) {
7420 7732 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
7421 7733 }
7422 7734
7735 + $results = get_option( 'vigilante_last_integrity_results' );
7736 + $scanned_at = get_option( 'vigilante_last_integrity_scan' );
7737 +
7423 7738 delete_option( 'vigilante_last_integrity_results' );
7424 7739 delete_option( 'vigilante_last_integrity_scan' );
7425 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 +
7426 7768 if ( $this->database ) {
7427 7769 $this->database->clear_file_hashes();
7428 7770 }
7429 7771
@@ -7449,8 +7791,14 @@
7449 7791 if ( empty( $file ) ) {
7450 7792 wp_send_json_error( __( 'No file specified.', 'vigilante' ) );
7451 7793 }
7452 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 +
7453 7801 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database );
7454 7802 $file_integrity->ignore_file( $file );
7455 7803
7456 7804 // Also remove the file from stored scan results so UI updates
@@ -7514,12 +7862,14 @@
7514 7862 if ( ! is_array( $raw_files ) ) {
7515 7863 wp_send_json_error( __( 'Invalid request.', 'vigilante' ) );
7516 7864 }
7517 7865
7518 - $files = array();
7866 + $files = array();
7867 + $shared = $this->critical_approval_locked() ? array( 'wp-config.php', '.htaccess' ) : array();
7519 7868 foreach ( $raw_files as $f ) {
7520 7869 $clean = sanitize_text_field( $f );
7521 - if ( '' !== $clean ) {
7870 + // Same rule as ajax_ignore_file() for the two shared files.
7871 + if ( '' !== $clean && ! in_array( $clean, $shared, true ) ) {
7522 7872 $files[] = $clean;
7523 7873 }
7524 7874 }
7525 7875