PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.12
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.12
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 +649 -63 2.10.02.11.12 View file →
@@ -199,8 +199,26 @@
199 199 /**
200 200 * Run database migrations based on stored version
201 201 */
202 202 public function run_migrations() {
203 + /*
204 + * admin-ajax.php fires admin_init before it decides who is asking
205 + * (wp-admin/admin-ajax.php:45), so until 2.11.10 an anonymous POST to
206 + * admin-ajax.php with any action ran every pending migration. That is
207 + * not a read: the migrations rewrite wp-config.php through
208 + * apply_security_constants(), rewrite the root .htaccess, move user meta
209 + * of the whole network and can rebuild the file integrity baseline,
210 + * taking whatever is on disk as approved. Reproduced on 12 sep 2026 with
211 + * curl and no cookies, and found by the file-by-file review of 2.11.10.
212 + *
213 + * Migrations are maintenance for whoever administers the site, so they
214 + * wait for an administrator to load a screen. Nothing is lost by
215 + * waiting: every migration is idempotent and version gated.
216 + */
217 + if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {
218 + return;
219 + }
220 +
203 221 $db_version = get_option( 'vigilante_db_version', '0' );
204 222
205 223 // 1.2.3: Fix IP lists corrupted by sanitize_text_field stripping newlines
206 224 if ( version_compare( $db_version, '1.2.3', '<' ) ) {
@@ -273,9 +291,29 @@
273 291 if ( ! class_exists( 'Vigilante_File_Integrity' ) ) {
274 292 require_once VIGILANTE_INCLUDES_DIR . 'class-file-integrity.php';
275 293 }
276 294 $fi = new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
277 - $fi->regenerate_all_baselines();
295 +
296 + /*
297 + * Only when there is nothing on record. This migration exists to
298 + * create the baseline that did not exist, never to discard the one
299 + * the owner approved: rebuilding it from the files takes whatever
300 + * is on disk right now as approved, so a wp-config.php modified and
301 + * awaiting review would be blessed in silence.
302 + *
303 + * And this is not theory. vigilante_db_version is written on two
304 + * different scales into the same option: this file counts in plugin
305 + * versions (2.11.0) and Vigilante_Database counts in schema
306 + * versions, currently 1.4.0 (class-database.php:322 and :380). For
307 + * version_compare, 1.4.0 is LOWER than 1.14.0, so any site whose
308 + * option was last written by the schema runs this migration again.
309 + * Measured on the Multisite install on 10 sep 2026: one of the three
310 + * sites was sitting on 1.4.0.
311 + */
312 + if ( ! $fi->get_critical_files_baseline() ) {
313 + $fi->regenerate_all_baselines();
314 + }
315 +
278 316 update_option( 'vigilante_db_version', '1.14.0' );
279 317 }
280 318
281 319 // 2.0.0: Move hide_server_signature and remove_fingerprinting_headers
@@ -425,11 +463,172 @@
425 463 }
426 464
427 465 update_option( 'vigilante_db_version', '2.9.9' );
428 466 }
467 +
468 + /*
469 + * 2.11.0: security release (audit of 28 Aug 2026). Runs here and not
470 + * from Vigilante_Database::needs_update(): this option is shared with
471 + * that class, and on any updated site it already holds a plugin version
472 + * (2.9.9 or later), so a bump of DB_VERSION would never fire.
473 + * create_tables() widens the email code column through dbDelta (varchar
474 + * 6 to 64, the code is stored hashed since 2.11.0) and purge_for_2_11_0()
475 + * does what dbDelta cannot: it empties the trusted devices, which were
476 + * identified by User-Agent until now (S1), and the pending email codes,
477 + * stored in clear until now (S11). Every remembered device asks for the
478 + * second factor once more after this update, and the changelog says so.
479 + */
480 + if ( version_compare( $db_version, '2.11.0', '<' ) ) {
481 + $this->database->create_tables();
482 + $this->database->purge_for_2_11_0();
483 +
484 + update_option( 'vigilante_db_version', '2.11.0' );
485 + }
486 +
487 + /*
488 + * 2.11.9: clear the raw .htaccess copies that older versions left in
489 + * options, on the first admin load after the update. Uninstall already
490 + * removes them, but that only fires when the plugin is deleted, so a
491 + * site that keeps the plugin carried them until now. Three stores, each
492 + * a copy of a file that can hold secrets (a SetEnv token, an
493 + * Authorization header): the same exposure the wp.org review flagged as
494 + * 4.4, on the paths its fix did not reach.
495 + *
496 + * - vigilante_htaccess_history: up to five raw copies, by design, until
497 + * 2.11.8. The writer is gone, nothing reads it, so it is deleted.
498 + * - vigilante_htaccess_backup: the single rollback buffer, normally
499 + * cleared in the finally of each write; a copy only lingers if a write
500 + * crashed mid-operation. Nothing outside one write reads it, so a
501 + * leftover is deleted.
502 + * - vigilante_htaccess_pre_migration: still read by the header recovery,
503 + * but older versions stored the whole file where only our own block is
504 + * ever used. Truncated to that block, so the feature keeps working and
505 + * nothing outside our markers stays in the option.
506 + */
507 + if ( version_compare( $db_version, '2.11.9', '<' ) ) {
508 + delete_option( 'vigilante_htaccess_history' );
509 + delete_option( 'vigilante_htaccess_backup' );
510 +
511 + $snapshot = get_option( 'vigilante_htaccess_pre_migration' );
512 + if ( is_array( $snapshot ) && isset( $snapshot['content'] ) && '' !== (string) $snapshot['content'] ) {
513 + require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-recovery.php';
514 + $block = Vigilante_Htaccess_Recovery::get_raw_block();
515 +
516 + if ( '' === $block ) {
517 + delete_option( 'vigilante_htaccess_pre_migration' );
518 + } elseif ( $block !== $snapshot['content'] ) {
519 + $snapshot['content'] = $block;
520 + update_option( 'vigilante_htaccess_pre_migration', $snapshot, false );
521 + }
522 + }
523 +
524 + update_option( 'vigilante_db_version', '2.11.9' );
525 + }
526 +
527 + /*
528 + * 2.11.10: the pending-approval flag becomes one per site on a network.
529 + * Until 2.11.9 it was a single global user meta, so the queue was shared
530 + * across the whole network. Moving the key is not enough: the accounts
531 + * already waiting carry the old key, and reading only the new one would
532 + * let them log in. So they are moved here, each to the site it belongs
533 + * to, and the old key is removed only once the new one is written.
534 + */
535 + if ( version_compare( $db_version, '2.11.10', '<' ) ) {
536 + $this->migrate_pending_approval_per_site();
537 +
538 + update_option( 'vigilante_db_version', '2.11.10' );
539 + }
429 540 }
430 541
431 542 /**
543 + * Move the pending-approval flag of a network to a key per site
544 + *
545 + * Runs once for the whole network, not once per site: the data it moves is
546 + * global, so the guard is a network option and any site may be the one that
547 + * does it. On a single site the key does not change and there is nothing to
548 + * do.
549 + *
550 + * Each waiting account goes to its primary site, or to the only site it
551 + * belongs to; one that belongs to none goes to the main site rather than
552 + * nowhere, because losing the flag would silently approve it.
553 + *
554 + * @since 2.11.10
555 + */
556 + private function migrate_pending_approval_per_site() {
557 + global $wpdb;
558 +
559 + if ( ! is_multisite() ) {
560 + return;
561 + }
562 +
563 + if ( get_site_option( 'vigilante_pending_per_site_done' ) ) {
564 + return;
565 + }
566 +
567 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- One-off migration of the plugin's own user meta; the meta API has no "list every user with this key".
568 + $user_ids = $wpdb->get_col(
569 + $wpdb->prepare( "SELECT DISTINCT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s", 'vigilante_pending_approval' )
570 + );
571 +
572 + foreach ( (array) $user_ids as $user_id ) {
573 + $user_id = (int) $user_id;
574 + if ( ! $user_id ) {
575 + continue;
576 + }
577 +
578 + $pending = get_user_meta( $user_id, 'vigilante_pending_approval', true );
579 + $since = get_user_meta( $user_id, 'vigilante_pending_since', true );
580 +
581 + /*
582 + * Every site the account belongs to, not its primary one. The global
583 + * flag does not say where the registration happened, and the first
584 + * version of this guessed the primary blog: an account that
585 + * registered on B while its primary was A came out pending on A and
586 + * free to log in on B, which is the very site it had never been
587 + * approved on. Found by the cross review of 2.11.10.
588 + *
589 + * Marking every site it belongs to fails closed instead: the account
590 + * stays blocked wherever it can log in, and shows up in the queue of
591 + * each of those sites so somebody can actually act on it. An account
592 + * that belongs to no site goes to the main one rather than nowhere,
593 + * because losing the flag would silently approve it.
594 + */
595 + /*
596 + * With $all true, because the default leaves out archived, spam and
597 + * deleted sites (wp-includes/user.php:1113-1117): a site archived on
598 + * the day this runs would lose the flag, and the account would walk
599 + * in unapproved the moment it was brought back. Found by the second
600 + * cross review of 2.11.10.
601 + */
602 + $blog_ids = array();
603 +
604 + foreach ( get_blogs_of_user( $user_id, true ) as $blog ) {
605 + if ( ! empty( $blog->userblog_id ) ) {
606 + $blog_ids[] = (int) $blog->userblog_id;
607 + }
608 + }
609 +
610 + if ( empty( $blog_ids ) ) {
611 + $blog_ids[] = (int) get_main_site_id();
612 + }
613 +
614 + foreach ( array_unique( $blog_ids ) as $blog_id ) {
615 + $prefix = $wpdb->get_blog_prefix( $blog_id );
616 +
617 + update_user_meta( $user_id, $prefix . 'vigilante_pending_approval', $pending );
618 + if ( '' !== $since && false !== $since ) {
619 + update_user_meta( $user_id, $prefix . 'vigilante_pending_since', $since );
620 + }
621 + }
622 +
623 + delete_user_meta( $user_id, 'vigilante_pending_approval' );
624 + delete_user_meta( $user_id, 'vigilante_pending_since' );
625 + }
626 +
627 + update_site_option( 'vigilante_pending_per_site_done', 1 );
628 + }
629 +
630 + /**
432 631 * Migration: Remove orphaned email fields from saved options
433 632 *
434 633 * v1.10.0 centralized notification recipients into email section.
435 634 * Old per-module notify_email fields and dead email section fields
@@ -697,23 +896,32 @@
697 896 if ( ! did_action( 'plugins_loaded' ) ) {
698 897 return 0;
699 898 }
700 899
701 - $registration_approval = $this->settings->get_section( 'user_security' );
702 - $approval_settings = $registration_approval['registration_approval'] ?? array();
703 -
704 - if ( empty( $approval_settings['enabled'] ) ) {
705 - return 0;
706 - }
900 + /*
901 + * Counted whether the feature is on or off. An account already waiting
902 + * stays blocked when it is switched off (see init_enforcement_hooks()),
903 + * so reporting zero there hid people who cannot log in and whom nobody
904 + * could see to approve. Found by the cross review of 2.11.10.
905 + */
707 906
708 907 // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Limited results in admin context.
709 - $pending_users = get_users( array(
710 - 'meta_key' => 'vigilante_pending_approval',
908 + $args = array(
909 + 'meta_key' => Vigilante_User_Security::site_user_meta_key( 'vigilante_pending_approval' ),
711 910 'meta_value' => '1',
712 911 'fields' => 'ID',
713 - ) );
912 + );
714 913 // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
715 914
915 + // Same query as Vigilante_User_Security::get_pending_users(), and for the
916 + // same reason: the meta key already scopes this to the site, and adding
917 + // core's membership filter on top hid the accounts that have no role yet.
918 + if ( is_multisite() ) {
919 + $args['blog_id'] = 0;
920 + }
921 +
922 + $pending_users = get_users( $args );
923 +
716 924 return count( $pending_users );
717 925 }
718 926
719 927 /**
@@ -1429,8 +1637,11 @@
1429 1637 'currentUserId' => get_current_user_id(),
1430 1638 'logoutUrl' => wp_logout_url( wp_login_url() ),
1431 1639 'adminUrl' => admin_url( 'admin.php?page=vigilante' ),
1432 1640 'searchIndex' => $this->get_search_index(),
1641 + // The scan repaints this table from JavaScript, so the same gate
1642 + // has to travel with it or half the screen keeps the dead button.
1643 + 'approvalLocked' => $this->critical_approval_locked(),
1433 1644 'underAttack' => array(
1434 1645 'active' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->is_active(),
1435 1646 'remaining' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->get_remaining_time(),
1436 1647 ),
@@ -1497,13 +1708,17 @@
1497 1708 'criticalConfigTitle' => __( 'Critical config files modified', 'vigilante' ),
1498 1709 'criticalConfigDesc' => __( 'These files are common targets for code injection. Review the changes and approve if they are legitimate. Vigilant\'s own blocks are excluded from this check.', 'vigilante' ),
1499 1710 'approve' => __( 'Approve', 'vigilante' ),
1500 1711 'approving' => __( 'Approving...', 'vigilante' ),
1712 + 'approvalLockedNotice' => $this->critical_approval_notice(),
1501 1713 'criticalApproved' => __( 'Change approved. Next scan will use the current state as baseline.', 'vigilante' ),
1502 1714 'reviewChanges' => __( 'Review changes', 'vigilante' ),
1503 1715 'hideChanges' => __( 'Hide changes', 'vigilante' ),
1504 1716 'changes' => __( 'Changes', 'vigilante' ),
1505 1717 'diffUnavailable' => __( 'Diff not available for this file (baseline was created before diff tracking was added). Approve to enable diff on future changes.', 'vigilante' ),
1718 + 'diffNetwork' => __( 'This file belongs to the whole network, so its line changes are only shown to network administrators, on the main site.', 'vigilante' ),
1719 + 'diffRescan' => __( 'Run a new scan to see the line changes of this file.', 'vigilante' ),
1720 + '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 1721 'diffEmpty' => __( 'No line-level changes detected (may be whitespace or reordering).', 'vigilante' ),
1507 1722 'diffLines' => __( 'lines', 'vigilante' ),
1508 1723 // Under Attack mode strings
1509 1724 'underAttackConfirmActivate' => __( 'Activate Under Attack mode? All visitors will see a verification page for the next 4 hours.', 'vigilante' ),
@@ -1559,8 +1774,9 @@
1559 1774 'logType' => __( 'Type', 'vigilante' ),
1560 1775 'logAction' => __( 'Action', 'vigilante' ),
1561 1776 'logSeverity' => __( 'Severity', 'vigilante' ),
1562 1777 'logMessage' => __( 'Message', 'vigilante' ),
1778 + 'logRequestUri' => __( 'Address', 'vigilante' ),
1563 1779 'logClient' => __( 'Client', 'vigilante' ),
1564 1780 'logUser' => __( 'User', 'vigilante' ),
1565 1781 'logIpAddress' => __( 'IP Address', 'vigilante' ),
1566 1782 'logUserAgent' => __( 'User Agent', 'vigilante' ),
@@ -1765,8 +1981,21 @@
1765 1981 </p>
1766 1982 <p>
1767 1983 <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>
1768 1984 </p>
1985 + <?php
1986 + // The cache-bypass rules could not be written (a host where
1987 + // WordPress cannot write files by itself, a held lock, a
1988 + // failed read-back): show them, so they can be added by hand.
1989 + $ua_instance = new Vigilante_Under_Attack( $this->settings, $this->activity_log );
1990 + if ( $ua_instance->cache_rules_missing() ) :
1991 + ?>
1992 + <p>
1993 + <strong><?php esc_html_e( 'The cache-bypass rules could not be written to your .htaccess.', 'vigilante' ); ?></strong>
1994 + <?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' ); ?>
1995 + </p>
1996 + <textarea readonly rows="9" class="large-text code" onclick="this.select();"><?php echo esc_textarea( Vigilante_Under_Attack::get_cache_bypass_block() ); ?></textarea>
1997 + <?php endif; ?>
1769 1998 </div>
1770 1999 <?php
1771 2000 }
1772 2001 }
@@ -1998,8 +2227,41 @@
1998 2227 return ! Vigilante_Settings::can_write_shared_files();
1999 2228 }
2000 2229
2001 2230 /**
2231 + * Whether this is the main site and the user cannot change what it builds the shared files from
2232 + *
2233 + * See Vigilante_Settings::get_main_site_file_settings(). On a subsite those
2234 + * settings only act on that site, so they are never locked there.
2235 + *
2236 + * @since 2.11.6
2237 + *
2238 + * @return bool
2239 + */
2240 + private function main_site_files_locked() {
2241 + return $this->shared_files_locked() && Vigilante_Settings::owns_shared_files();
2242 + }
2243 +
2244 + /**
2245 + * Sentence added to a bulk change when some settings were left as they were
2246 + *
2247 + * Importing a file, applying a preset and restoring the defaults touch every
2248 + * section at once, so the user is told that the shared file settings did
2249 + * not move.
2250 + *
2251 + * @since 2.11.6
2252 + *
2253 + * @return string Empty when the user can change every setting.
2254 + */
2255 + private function locked_file_settings_message() {
2256 + if ( ! Vigilante_Settings::get_locked_file_settings() ) {
2257 + return '';
2258 + }
2259 +
2260 + return ' ' . __( 'The settings that end up in wp-config.php or .htaccess were left as they were.', 'vigilante' ) . ' ' . Vigilante_Settings::get_shared_files_notice();
2261 + }
2262 +
2263 + /**
2002 2264 * Print the shared-files notice for a section that cannot be edited here
2003 2265 *
2004 2266 * @since 2.9.8
2005 2267 */
@@ -2014,8 +2276,109 @@
2014 2276 <?php
2015 2277 }
2016 2278
2017 2279 /**
2280 + * Acting on another user's account needs permission over that user
2281 + *
2282 + * Since 2.10.3 the handlers behind these tools ask for edit_user over the
2283 + * target, which is the rule WordPress itself applies. On a network the core
2284 + * grants edit_user only to network administrators, so for anybody else these
2285 + * controls do nothing. Better to say so than to paint a button that silently
2286 + * skips every user.
2287 + *
2288 + * @since 2.10.4
2289 + * @return bool
2290 + */
2291 + private function forwarded_chain_readings() {
2292 + // Shown, not decided on: the firewall resolves the address elsewhere.
2293 + $chain = Vigilante_IP_Utils::trusted_forwarded_for();
2294 +
2295 + if ( '' === $chain ) {
2296 + return array();
2297 + }
2298 +
2299 + $public = array();
2300 +
2301 + foreach ( explode( ',', $chain ) as $entry ) {
2302 + $address = Vigilante_IP_Utils::unmap_ipv4( trim( $entry ) );
2303 +
2304 + if ( filter_var( $address, FILTER_VALIDATE_IP ) && ! Vigilante_IP_Utils::is_own_network( $address ) ) {
2305 + $public[] = $address;
2306 + }
2307 + }
2308 +
2309 + if ( count( $public ) < 2 ) {
2310 + return array();
2311 + }
2312 +
2313 + return array(
2314 + 'now' => Vigilante_IP_Utils::client_from_chain( $chain ),
2315 + 'before' => $public[0],
2316 + );
2317 + }
2318 +
2319 + /**
2320 + * Whether the user tools of this screen are out of reach for this user
2321 + *
2322 + * @return bool
2323 + */
2324 + private function user_actions_locked() {
2325 + // On a single site edit_user maps to edit_users, which a custom role with
2326 + // manage_options may lack: since 2.11.8 approving and rejecting a pending
2327 + // registration ask for it, so the buttons have to say so there too.
2328 + return is_multisite() ? ! current_user_can( 'manage_network_users' ) : ! current_user_can( 'edit_users' );
2329 + }
2330 +
2331 + /**
2332 + * Print the notice for user tools that cannot be used from this site
2333 + *
2334 + * @since 2.10.4
2335 + */
2336 + private function render_user_actions_notice() {
2337 + if ( ! $this->user_actions_locked() ) {
2338 + return;
2339 + }
2340 + ?>
2341 + <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;">
2342 + <?php if ( is_multisite() ) : ?>
2343 + <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>
2344 + <?php else : ?>
2345 + <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>
2346 + <?php endif; ?>
2347 + </div>
2348 + <?php
2349 + }
2350 +
2351 + /**
2352 + * Approving a change to the shared config files needs the network
2353 + *
2354 + * Since 2.11.3 the handler behind the Approve button asks for
2355 + * manage_network_options, because the two files it approves, wp-config.php
2356 + * and the root .htaccess, belong to the installation, and so does the
2357 + * record of them. The button, though, went on being painted for everybody,
2358 + * so the administrator of a subsite saw the warning, saw the button,
2359 + * pressed it and got "Permission denied" with no explanation. That is
2360 + * exactly what user_actions_locked() above exists to avoid, one release
2361 + * later and one screen over. Flagged by @calzbert.
2362 + *
2363 + * @since 2.11.4
2364 + * @return bool
2365 + */
2366 + private function critical_approval_locked() {
2367 + return is_multisite() && ! current_user_can( 'manage_network_options' );
2368 + }
2369 +
2370 + /**
2371 + * The line that replaces the Approve button where it cannot be used
2372 + *
2373 + * @since 2.11.4
2374 + * @return string
2375 + */
2376 + private function critical_approval_notice() {
2377 + 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' );
2378 + }
2379 +
2380 + /**
2018 2381 * Check if module is disabled and render warning
2019 2382 *
2020 2383 * @param string $module_key Module key.
2021 2384 * @return bool True if disabled.
@@ -2559,14 +2922,15 @@
2559 2922
2560 2923 <?php $this->render_analyzer_widget( $analyzer_last_scan, $analyzer_history, $analyzer_categories_def, $analyzer_settings ); ?>
2561 2924
2562 2925 <div class="vigilante-modules-grid">
2563 - <h2><?php esc_html_e( 'Security Modules', 'vigilante' ); ?></h2>
2926 + <h2 id="vigilante-section-dashboard-modules"><?php esc_html_e( 'Security Modules', 'vigilante' ); ?></h2>
2564 2927 <p class="description"><?php esc_html_e( 'Enable or disable security modules. Each module controls a tab with detailed settings.', 'vigilante' ); ?></p>
2565 2928 <div class="vigilante-modules-list">
2566 2929 <?php foreach ( $options['modules'] as $module => $enabled ) :
2567 2930 $label = isset( $module_labels[ $module ] ) ? $module_labels[ $module ] : ucwords( str_replace( '_', ' ', $module ) );
2568 2931 $description = isset( $module_descriptions[ $module ] ) ? $module_descriptions[ $module ] : '';
2932 + $vg_module_locked = $this->main_site_files_locked() && in_array( $module, Vigilante_Settings::get_main_site_file_settings()['modules'], true );
2569 2933 ?>
2570 2934 <div class="vigilante-module-item <?php echo $enabled ? 'enabled' : 'disabled'; ?>">
2571 2935 <div class="vigilante-module-header">
2572 2936 <span class="vigilante-module-status"></span>
@@ -2579,8 +2943,9 @@
2579 2943 <input type="checkbox"
2580 2944 name="modules[<?php echo esc_attr( $module ); ?>]"
2581 2945 value="1"
2582 2946 <?php checked( $enabled ); ?>
2947 + <?php disabled( $vg_module_locked ); ?>
2583 2948 aria-label="<?php echo esc_attr( $toggle_label ); ?>"
2584 2949 data-module="<?php echo esc_attr( $module ); ?>">
2585 2950 <span class="vigilante-toggle-slider"></span>
2586 2951 </label>
@@ -2587,8 +2952,11 @@
2587 2952 </div>
2588 2953 <?php if ( $description ) : ?>
2589 2954 <p class="vigilante-module-desc"><?php echo esc_html( $description ); ?></p>
2590 2955 <?php endif; ?>
2956 + <?php if ( $vg_module_locked ) : ?>
2957 + <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>
2958 + <?php endif; ?>
2591 2959 </div>
2592 2960 <?php endforeach; ?>
2593 2961 </div>
2594 2962 </div>
@@ -2620,9 +2988,9 @@
2620 2988 $ua_remaining_hours = floor( $ua_remaining / 3600 );
2621 2989 $ua_remaining_mins = floor( ( $ua_remaining % 3600 ) / 60 );
2622 2990 ?>
2623 2991 <div class="vigilante-preset-card vigilante-under-attack-card <?php echo $ua_active ? 'vigilante-under-attack-active' : ''; ?>">
2624 - <h3>
2992 + <h3 id="vigilante-section-dashboard-under-attack">
2625 2993 <span class="dashicons dashicons-shield"></span>
2626 2994 <?php esc_html_e( 'Under Attack', 'vigilante' ); ?>
2627 2995 </h3>
2628 2996 <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>
@@ -2978,14 +3346,21 @@
2978 3346 <?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' ); ?>
2979 3347 </p>
2980 3348 </div>
2981 3349
3350 + <?php $vg_main_locked = $this->main_site_files_locked(); ?>
3351 + <?php if ( $vg_main_locked ) : ?>
3352 + <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;">
3353 + <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>
3354 + </div>
3355 + <?php endif; ?>
3356 +
2982 3357 <table class="form-table">
2983 3358 <tr>
2984 3359 <th scope="row"><?php esc_html_e( 'Block Bad Query Strings', 'vigilante' ); ?></th>
2985 3360 <td>
2986 3361 <label>
2987 - <input type="checkbox" name="firewall[block_bad_query_strings]" value="1" <?php checked( ! empty( $options['block_bad_query_strings'] ) ); ?>>
3362 + <input type="checkbox" name="firewall[block_bad_query_strings]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( ! empty( $options['block_bad_query_strings'] ) ); ?>>
2988 3363 <?php esc_html_e( 'Block malicious query string patterns', 'vigilante' ); ?>
2989 3364 </label>
2990 3365 </td>
2991 3366 </tr>
@@ -3028,9 +3403,9 @@
3028 3403 <tr>
3029 3404 <th scope="row"><?php esc_html_e( 'Block Bad Bots', 'vigilante' ); ?></th>
3030 3405 <td>
3031 3406 <label>
3032 - <input type="checkbox" name="firewall[block_bad_bots]" value="1" <?php checked( ! empty( $options['block_bad_bots'] ) ); ?>>
3407 + <input type="checkbox" name="firewall[block_bad_bots]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( ! empty( $options['block_bad_bots'] ) ); ?>>
3033 3408 <?php esc_html_e( 'Block known malicious bots and scanners', 'vigilante' ); ?>
3034 3409 </label>
3035 3410 </td>
3036 3411 </tr>
@@ -3144,8 +3519,29 @@
3144 3519 </table>
3145 3520 </div>
3146 3521 <?php endif; ?>
3147 3522
3523 + <?php
3524 + // Since 2.11.8 X-Forwarded-For is read from its end, where the proxy
3525 + // writes. The administrator's own request shows whether that end is
3526 + // a CDN or a balancer for everybody here. Cross review of 2.11.8.
3527 + $xff_readings = $this->forwarded_chain_readings();
3528 + if ( $xff_readings ) :
3529 + ?>
3530 + <div id="vigilante-xff-chain-notice" class="notice notice-warning inline" style="margin:10px 0 16px;padding:8px 12px;">
3531 + <p style="margin:0;">
3532 + <?php
3533 + printf(
3534 + /* translators: 1: last address in the header, the one Vigilant reads, 2: first address in the header, which a visitor can write */
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, 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 + esc_html( $xff_readings['now'] ),
3537 + esc_html( $xff_readings['before'] )
3538 + );
3539 + ?>
3540 + </p>
3541 + </div>
3542 + <?php endif; ?>
3543 +
3148 3544 <h3><?php esc_html_e( 'IP Lists', 'vigilante' ); ?></h3>
3149 3545 <p class="description">
3150 3546 <?php
3151 3547 printf(
@@ -3159,9 +3555,9 @@
3159 3555 <tr>
3160 3556 <th scope="row"><label for="vigilante-f-firewall-trusted-proxy-header"><?php esc_html_e( 'Visitor IP detection', 'vigilante' ); ?></label></th>
3161 3557 <td>
3162 3558 <?php $proxy_header = $options['trusted_proxy_header'] ?? ''; ?>
3163 - <select id="vigilante-f-firewall-trusted-proxy-header" name="firewall[trusted_proxy_header]">
3559 + <select id="vigilante-f-firewall-trusted-proxy-header" name="firewall[trusted_proxy_header]" <?php disabled( $vg_main_locked ); ?>>
3164 3560 <option value="" <?php selected( $proxy_header, '' ); ?>><?php esc_html_e( 'Direct connection, only REMOTE_ADDR (recommended)', 'vigilante' ); ?></option>
3165 3561 <option value="cf-connecting-ip" <?php selected( $proxy_header, 'cf-connecting-ip' ); ?>><?php esc_html_e( 'Behind Cloudflare (CF-Connecting-IP)', 'vigilante' ); ?></option>
3166 3562 <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>
3167 3563 <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>
@@ -3171,11 +3567,23 @@
3171 3567 </p>
3172 3568 </td>
3173 3569 </tr>
3174 3570 <tr>
3571 + <th scope="row"><label for="vigilante-f-firewall-trusted-proxies"><?php esc_html_e( 'Trusted proxy IPs', 'vigilante' ); ?></label></th>
3572 + <td>
3573 + <textarea id="vigilante-f-firewall-trusted-proxies" name="firewall[trusted_proxies]" rows="3" class="large-text code" placeholder="10.0.0.0/8&#10;192.168.1.1" <?php disabled( $vg_main_locked ); ?>><?php echo esc_textarea( implode( "\n", $options['trusted_proxies'] ?? array() ) ); ?></textarea>
3574 + <p class="description">
3575 + <?php esc_html_e( 'Only used with a forwarded header selected above. One IP or CIDR range per line: the addresses your proxy or load balancer connects from. The forwarded header is accepted only from these. Left empty, Vigilant accepts it from your own private network, and for Cloudflare from Cloudflare\'s own ranges automatically.', 'vigilante' ); ?>
3576 + <?php if ( in_array( $proxy_header, array( 'x-forwarded-for', 'x-real-ip' ), true ) && empty( $options['trusted_proxies'] ) ) : ?>
3577 + <br><strong><?php esc_html_e( 'The header above is trusted but no proxy IPs are set. If your proxy or load balancer connects from a public address, add it here, or the header is ignored for safety and every visitor is seen as that proxy.', 'vigilante' ); ?></strong>
3578 + <?php endif; ?>
3579 + </p>
3580 + </td>
3581 + </tr>
3582 + <tr>
3175 3583 <th scope="row"><label for="vigilante-f-firewall-ip-whitelist"><?php esc_html_e( 'IP Whitelist', 'vigilante' ); ?></label></th>
3176 3584 <td>
3177 - <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>
3585 + <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>
3178 3586 <p class="description">
3179 3587 <?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' ); ?>
3180 3588 <br>
3181 3589 <?php
@@ -3214,9 +3622,9 @@
3214 3622 <table class="form-table">
3215 3623 <tr>
3216 3624 <th scope="row"><label for="vigilante-f-firewall-ua-whitelist"><?php esc_html_e( 'User-Agent Whitelist', 'vigilante' ); ?></label></th>
3217 3625 <td>
3218 - <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>
3626 + <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>
3219 3627 <p class="description"><?php esc_html_e( 'One User-Agent per line. These will bypass all firewall checks. Example: ManageWP, MainWP, UptimeRobot.', 'vigilante' ); ?></p>
3220 3628 </td>
3221 3629 </tr>
3222 3630 <tr>
@@ -3504,9 +3912,9 @@
3504 3912 $two_factor = $options['two_factor'] ?? array();
3505 3913 $two_factor_enabled = ! empty( $two_factor['enabled'] );
3506 3914 ?>
3507 3915 <div class="vigilante-settings-section vigilante-lockout-section">
3508 - <h2><?php esc_html_e( 'Login Protection Status', 'vigilante' ); ?></h2>
3916 + <h2 id="vigilante-section-login-status"><?php esc_html_e( 'Login Protection Status', 'vigilante' ); ?></h2>
3509 3917
3510 3918 <table class="form-table">
3511 3919 <tr>
3512 3920 <th scope="row"><?php esc_html_e( 'Current settings', 'vigilante' ); ?></th>
@@ -3666,9 +4074,9 @@
3666 4074 $excluded = $two_factor['excluded_users'] ?? array();
3667 4075 $method = $two_factor['method'] ?? 'email';
3668 4076 $grace_days = $two_factor['grace_period_days'] ?? 3;
3669 4077 ?>
3670 - <h3>
4078 + <h3 id="vigilante-section-login-2fa">
3671 4079 <?php esc_html_e( 'Two-Factor Authentication (2FA)', 'vigilante' ); ?>
3672 4080 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
3673 4081 <span class="vigilante-method-badge database"><?php esc_html_e( 'Database', 'vigilante' ); ?></span>
3674 4082 </h3>
@@ -3925,9 +4333,9 @@
3925 4333 ?>
3926 4334 <div class="vigilante-settings-section" id="vigilante-headers-recovery">
3927 4335 <h2><?php esc_html_e( 'Recover your previous header settings', 'vigilante' ); ?></h2>
3928 4336 <p>
3929 - <?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' ); ?>
4337 + <?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' ); ?>
3930 4338 </p>
3931 4339 <?php if ( $taken ) : ?>
3932 4340 <p class="description">
3933 4341 <?php
@@ -4039,9 +4447,9 @@
4039 4447 </td>
4040 4448 </tr>
4041 4449 </table>
4042 4450
4043 - <h3><?php esc_html_e( 'Content Security Policy', 'vigilante' ); ?></h3>
4451 + <h3 id="vigilante-section-headers-csp"><?php esc_html_e( 'Content Security Policy', 'vigilante' ); ?></h3>
4044 4452 <table class="form-table">
4045 4453 <tr>
4046 4454 <th scope="row"><?php esc_html_e( 'Enable CSP', 'vigilante' ); ?></th>
4047 4455 <td>
@@ -4061,9 +4469,9 @@
4061 4469 </td>
4062 4470 </tr>
4063 4471 </table>
4064 4472
4065 - <h3><?php esc_html_e( 'HTTPS', 'vigilante' ); ?></h3>
4473 + <h3 id="vigilante-section-headers-force-https"><?php esc_html_e( 'HTTPS', 'vigilante' ); ?></h3>
4066 4474 <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>
4067 4475 <table class="form-table">
4068 4476 <tr>
4069 4477 <th scope="row"><?php esc_html_e( 'Redirect HTTP to HTTPS', 'vigilante' ); ?></th>
@@ -4106,9 +4514,9 @@
4106 4514 </td>
4107 4515 </tr>
4108 4516 </table>
4109 4517
4110 - <h3><?php esc_html_e( 'HSTS (HTTP Strict Transport Security)', 'vigilante' ); ?></h3>
4518 + <h3 id="vigilante-section-headers-hsts"><?php esc_html_e( 'HSTS (HTTP Strict Transport Security)', 'vigilante' ); ?></h3>
4111 4519 <?php $vig_home_https = ( 0 === strpos( (string) get_option( 'home' ), 'https://' ) ); ?>
4112 4520 <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>
4113 4521 <?php if ( ! $vig_home_https ) : ?>
4114 4522 <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>
@@ -4149,9 +4557,9 @@
4149 4557 </td>
4150 4558 </tr>
4151 4559 </table>
4152 4560
4153 - <h3><?php esc_html_e( 'Server Identity', 'vigilante' ); ?></h3>
4561 + <h3 id="vigilante-section-headers-fingerprint"><?php esc_html_e( 'Server Identity', 'vigilante' ); ?></h3>
4154 4562 <p class="description"><?php esc_html_e( 'Hide identifying information that servers expose in responses.', 'vigilante' ); ?></p>
4155 4563 <table class="form-table">
4156 4564 <tr>
4157 4565 <th scope="row"><?php esc_html_e( 'Server Signature', 'vigilante' ); ?></th>
@@ -4536,8 +4944,16 @@
4536 4944 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
4537 4945 </h2>
4538 4946 <p><?php esc_html_e( 'Limit the number of simultaneous sessions per user.', 'vigilante' ); ?></p>
4539 4947
4948 + <?php if ( Vigilante_User_Security::session_limit_is_network_wide() ) : ?>
4949 + <div class="notice notice-warning inline">
4950 + <p>
4951 + <?php esc_html_e( 'This limit does not apply on a network. WordPress keeps the sessions of an account for the whole network, not per site, so a limit set here would count and close the sessions that person opened on other sites, including an administrator session elsewhere. A network-wide session policy is planned; until then these settings are saved but not enforced.', 'vigilante' ); ?>
4952 + </p>
4953 + </div>
4954 + <?php endif; ?>
4955 +
4540 4956 <table class="form-table">
4541 4957 <tr>
4542 4958 <th scope="row"><?php esc_html_e( 'Enable Session Limits', 'vigilante' ); ?></th>
4543 4959 <td>
@@ -4740,8 +5156,11 @@
4740 5156 <h2 class="vigilante-tools-header">
4741 5157 <?php esc_html_e( 'User security tools', 'vigilante' ); ?>
4742 5158 </h2>
4743 5159
5160 + <?php $this->render_user_actions_notice(); ?>
5161 + <?php if ( ! $this->user_actions_locked() ) : ?>
5162 +
4744 5163 <!-- Force Password Reset -->
4745 5164 <div class="vigilante-tool-box">
4746 5165 <h3><?php esc_html_e( 'Force password reset', 'vigilante' ); ?></h3>
4747 5166 <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>
@@ -4874,12 +5293,20 @@
4874 5293 </div>
4875 5294
4876 5295 <!-- Pending Registrations -->
4877 5296 <?php
4878 - $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log );
5297 + // Enforcement-only: this instance exists to read the queue, and the
5298 + // flag keeps it from registering the module's own hooks a second
5299 + // time. It is not inert, and saying it was would be a false comment:
5300 + // init_enforcement_hooks() does add its three filters again, on top
5301 + // of the ones already registered. They are idempotent (the same
5302 + // methods of an equivalent instance, deciding on the same user meta),
5303 + // so running them twice in an admin request changes nothing, which is
5304 + // why this is accepted rather than worked around.
5305 + $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log, true );
4879 5306 $pending_users = $user_security->get_pending_users();
4880 5307 ?>
4881 - <div class="vigilante-tool-box vigilante-pending-users-section">
5308 + <div id="vigilante-section-users-pending" class="vigilante-tool-box vigilante-pending-users-section">
4882 5309 <h3>
4883 5310 <?php esc_html_e( 'Pending registrations', 'vigilante' ); ?>
4884 5311 <?php if ( count( $pending_users ) > 0 ) : ?>
4885 5312 <span class="vigilante-badge vigilante-badge-warning"><?php echo esc_html( count( $pending_users ) ); ?></span>
@@ -4885,9 +5312,20 @@
4885 5312 <span class="vigilante-badge vigilante-badge-warning"><?php echo esc_html( count( $pending_users ) ); ?></span>
4886 5313 <?php endif; ?>
4887 5314 </h3>
4888 5315
4889 - <?php if ( empty( $registration['enabled'] ) ) : ?>
5316 + <?php
5317 + /*
5318 + * The queue is shown whenever there is somebody in it, even with
5319 + * the feature off. Since 2.11.10 an account already waiting stays
5320 + * blocked when the feature is switched off, which is the point:
5321 + * turning a setting off must not quietly let in people an
5322 + * administrator decided not to approve. But hiding the table then
5323 + * left them locked out with no button anywhere to approve or
5324 + * reject them. Found by the cross review of 2.11.10.
5325 + */
5326 + ?>
5327 + <?php if ( empty( $registration['enabled'] ) && empty( $pending_users ) ) : ?>
4890 5328 <p class="description">
4891 5329 <span class="dashicons dashicons-info" style="color: #72aee6;"></span>
4892 5330 <?php esc_html_e( 'Registration approval is disabled. Enable it in the settings above to require manual approval for new users.', 'vigilante' ); ?>
4893 5331 </p>
@@ -4896,8 +5334,9 @@
4896 5334 <span class="dashicons dashicons-yes-alt"></span>
4897 5335 <p><?php esc_html_e( 'No pending registrations.', 'vigilante' ); ?></p>
4898 5336 </div>
4899 5337 <?php else : ?>
5338 + <?php $this->render_user_actions_notice(); ?>
4900 5339 <table class="wp-list-table widefat fixed striped vigilante-pending-users-table">
4901 5340 <thead>
4902 5341 <tr>
4903 5342 <th><?php esc_html_e( 'User', 'vigilante' ); ?></th>
@@ -4907,9 +5346,9 @@
4907 5346 </tr>
4908 5347 </thead>
4909 5348 <tbody>
4910 5349 <?php foreach ( $pending_users as $pending_user ) :
4911 - $pending_since = get_user_meta( $pending_user->ID, 'vigilante_pending_since', true );
5350 + $pending_since = get_user_meta( $pending_user->ID, Vigilante_User_Security::site_user_meta_key( 'vigilante_pending_since' ), true );
4912 5351 ?>
4913 5352 <tr data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>">
4914 5353 <td>
4915 5354 <?php echo get_avatar( $pending_user->ID, 32 ); ?>
@@ -4926,12 +5365,12 @@
4926 5365 }
4927 5366 ?>
4928 5367 </td>
4929 5368 <td>
4930 - <button type="button" class="button button-small vigilante-approve-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>">
5369 + <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() ); ?>>
4931 5370 <?php esc_html_e( 'Approve', 'vigilante' ); ?>
4932 5371 </button>
4933 - <button type="button" class="button button-small vigilante-reject-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>" style="color: #d63638;">
5372 + <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() ); ?>>
4934 5373 <?php esc_html_e( 'Reject', 'vigilante' ); ?>
4935 5374 </button>
4936 5375 </td>
4937 5376 </tr>
@@ -5053,8 +5492,10 @@
5053 5492 </button>
5054 5493 </p>
5055 5494 </div>
5056 5495 </div>
5496 +
5497 + <?php endif; ?>
5057 5498 </div>
5058 5499 <?php
5059 5500 }
5060 5501
@@ -5768,8 +6209,9 @@
5768 6209 'user' => (string) ( $log->user_login ?? '' ),
5769 6210 'ip' => $ip_val,
5770 6211 'user_agent' => $ua_val,
5771 6212 'request_method' => (string) $request_method,
6213 + 'request_uri' => Vigilante_Activity_Log::extract_request_uri( $log->extra_data ?? '' ),
5772 6214 'date' => (string) ( $log->created_at ?? '' ),
5773 6215 'severity' => (string) ( $log->severity ?? 'info' ),
5774 6216 'is_ip_whitelisted' => ( '' !== $ip_val && in_array( $ip_val, $ip_whitelist, true ) ),
5775 6217 'is_ip_blacklisted' => ( '' !== $ip_val && in_array( $ip_val, $ip_blacklist, true ) ),
@@ -5822,8 +6264,13 @@
5822 6264 */
5823 6265 private function render_tab_file_integrity() {
5824 6266 $is_disabled = $this->render_module_disabled_notice( 'file_integrity' );
5825 6267 $options = $this->settings->get_section( 'file_integrity' );
6268 + // On the main site of a network the critical-file scan is the network's
6269 + // canary for a change to wp-config.php or the root .htaccess, so a
6270 + // main-site admin without network rights cannot turn it off. Since
6271 + // 2.11.8; see Vigilante_Settings::get_main_site_file_settings().
6272 + $vg_main_locked = $this->main_site_files_locked();
5826 6273 $last_scan = get_option( 'vigilante_last_integrity_scan' );
5827 6274 $last_results = get_option( 'vigilante_last_integrity_results' );
5828 6275 $ignored_files = get_option( 'vigilante_ignored_files', array() );
5829 6276
@@ -5949,10 +6396,13 @@
5949 6396 <?php esc_html_e( 'Uploads directory (detect PHP files, double extensions, .htaccess)', 'vigilante' ); ?>
5950 6397 </label>
5951 6398 <br>
5952 6399 <label>
5953 - <input type="checkbox" name="file_integrity[scan_critical_config]" value="1" <?php checked( $options['scan_critical_config'] ?? true ); ?>>
6400 + <input type="checkbox" name="file_integrity[scan_critical_config]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( $options['scan_critical_config'] ?? true ); ?>>
5954 6401 <?php esc_html_e( 'Critical config files (wp-config.php, .htaccess baseline monitoring)', 'vigilante' ); ?>
6402 + <?php if ( $vg_main_locked ) : ?>
6403 + <span class="description" style="display:block;margin-left:24px;"><?php echo esc_html( Vigilante_Settings::get_shared_files_notice() ); ?></span>
6404 + <?php endif; ?>
5955 6405 </label>
5956 6406 <br>
5957 6407 <label>
5958 6408 <input type="checkbox" name="file_integrity[check_closed_plugins]" value="1" <?php checked( $options['check_closed_plugins'] ?? true ); ?>>
@@ -6231,9 +6681,15 @@
6231 6681 $crit_diff = $crit_item['diff'] ?? array();
6232 6682 $crit_id = sanitize_html_class( $crit_file );
6233 6683 $added_count = is_array( $crit_diff ) ? count( $crit_diff['added'] ?? array() ) : 0;
6234 6684 $removed_count = is_array( $crit_diff ) ? count( $crit_diff['removed'] ?? array() ) : 0;
6235 - $diff_unavailable = is_array( $crit_diff ) && ! empty( $crit_diff['unavailable'] );
6685 + // The lines of a shared file are for whoever approves it. Results
6686 + // stored before 2.11.8 on the main site still carry them, so the
6687 + // screen asks too, not only the scan that wrote them.
6688 + $diff_network = ( is_array( $crit_diff ) && ! empty( $crit_diff['network'] ) ) || $this->critical_approval_locked();
6689 + $diff_rescan = is_array( $crit_diff ) && ! empty( $crit_diff['rescan'] );
6690 + $diff_redaction = is_array( $crit_diff ) && ! empty( $crit_diff['redaction'] );
6691 + $diff_unavailable = $diff_network || ( is_array( $crit_diff ) && ! empty( $crit_diff['unavailable'] ) );
6236 6692 ?>
6237 6693 <tr>
6238 6694 <td><code style="color: #e36210;"><?php echo esc_html( $crit_file ); ?></code></td>
6239 6695 <td>
@@ -6256,18 +6712,36 @@
6256 6712 <td>
6257 6713 <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' ); ?>">
6258 6714 <?php esc_html_e( 'Review changes', 'vigilante' ); ?>
6259 6715 </button>
6260 - <button type="button" class="button button-small button-primary vigilante-approve-critical-file" data-file="<?php echo esc_attr( $crit_file ); ?>">
6261 - <?php esc_html_e( 'Approve', 'vigilante' ); ?>
6262 - </button>
6716 + <?php if ( $this->critical_approval_locked() ) : ?>
6717 + <span class="description" style="display:block;margin-top:4px;">
6718 + <?php echo esc_html( $this->critical_approval_notice() ); ?>
6719 + </span>
6720 + <?php else : ?>
6721 + <button type="button" class="button button-small button-primary vigilante-approve-critical-file" data-file="<?php echo esc_attr( $crit_file ); ?>">
6722 + <?php esc_html_e( 'Approve', 'vigilante' ); ?>
6723 + </button>
6724 + <?php endif; ?>
6263 6725 </td>
6264 6726 </tr>
6265 6727 <tr id="vigilante-critical-content-<?php echo esc_attr( $crit_id ); ?>" class="vigilante-critical-content-row" style="display:none;">
6266 6728 <td colspan="3" style="padding: 0;">
6267 6729 <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;">
6268 - <?php if ( $diff_unavailable ) : ?>
6730 + <?php if ( $diff_network ) : ?>
6269 6731 <p style="color: #50575e; font-style: italic; margin: 0;">
6732 + <?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' ); ?>
6733 + </p>
6734 + <?php elseif ( $diff_rescan ) : ?>
6735 + <p style="color: #50575e; font-style: italic; margin: 0;">
6736 + <?php esc_html_e( 'Run a new scan to see the line changes of this file.', 'vigilante' ); ?>
6737 + </p>
6738 + <?php elseif ( $diff_redaction ) : ?>
6739 + <p style="color: #50575e; font-style: italic; margin: 0;">
6740 + <?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' ); ?>
6741 + </p>
6742 + <?php elseif ( $diff_unavailable ) : ?>
6743 + <p style="color: #50575e; font-style: italic; margin: 0;">
6270 6744 <?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' ); ?>
6271 6745 </p>
6272 6746 <?php elseif ( empty( $crit_diff['added'] ) && empty( $crit_diff['removed'] ) ) : ?>
6273 6747 <p style="color: #50575e; font-style: italic; margin: 0;">
@@ -6295,9 +6769,9 @@
6295 6769 <?php endif; ?>
6296 6770
6297 6771 <?php if ( $has_closed ) : ?>
6298 6772 <div class="vigilante-file-list vigilante-closed-plugins">
6299 - <h3 style="color: #d63638;"><?php esc_html_e( 'Closed + Removed Plugins', 'vigilante' ); ?></h3>
6773 + <h3 id="vigilante-section-fi-closed-plugins" style="color: #d63638;"><?php esc_html_e( 'Closed + Removed Plugins', 'vigilante' ); ?></h3>
6300 6774 <p class="description" style="color: #d63638;">
6301 6775 <?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' ); ?>
6302 6776 </p>
6303 6777 <table class="wp-list-table widefat striped">
@@ -6506,8 +6980,15 @@
6506 6980 if ( ! current_user_can( 'manage_options' ) ) {
6507 6981 wp_die( esc_html__( 'Permission denied.', 'vigilante' ), 403 );
6508 6982 }
6509 6983
6984 + // The archive carries wp-config.php, which a whole network shares. On a
6985 + // network manage_options is held by every subsite administrator, so the
6986 + // same gate the writers use applies here.
6987 + if ( ! Vigilante_Settings::can_write_shared_files() ) {
6988 + wp_die( esc_html( Vigilante_Settings::get_shared_files_notice() ), 403 );
6989 + }
6990 +
6510 6991 $backup_manager = new Vigilante_Backup_Manager();
6511 6992 $result = $backup_manager->stream_files_zip();
6512 6993
6513 6994 // stream_files_zip() exits on success; only a WP_Error returns here.
@@ -6596,10 +7077,30 @@
6596 7077
6597 7078 // Read ONLY saved options from database (not merged with defaults)
6598 7079 $saved_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
6599 7080
6600 - $rejected_ips = array();
7081 + // What is stored before this request changes anything: the shared file
7082 + // settings this user may not change are put back from here (2.11.6).
7083 + $stored_options = $saved_options;
7084 + $locked = Vigilante_Settings::get_locked_file_settings();
6601 7085
7086 + if ( isset( $locked[ $section ] ) && true === $locked[ $section ] ) {
7087 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7088 + }
7089 +
7090 + // A module switch is a single key, so refusing says more than a success
7091 + // that changed nothing, and the dashboard puts the toggle back.
7092 + if ( 'modules' === $section && isset( $locked['modules'], $data['modules'] ) && is_array( $locked['modules'] ) && is_array( $data['modules'] ) ) {
7093 + foreach ( array_keys( $data['modules'] ) as $vg_module ) {
7094 + if ( in_array( sanitize_key( $vg_module ), $locked['modules'], true ) ) {
7095 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7096 + }
7097 + }
7098 + }
7099 +
7100 + $rejected_ips = array();
7101 + $rejected_proxies = array();
7102 +
6602 7103 // Handle modules
6603 7104 if ( 'modules' === $section && isset( $data['modules'] ) ) {
6604 7105 if ( ! isset( $saved_options['modules'] ) ) {
6605 7106 $saved_options['modules'] = array();
@@ -6623,9 +7124,9 @@
6623 7124 // went straight into the option. An entry the matcher can never
6624 7125 // match still sits in a security list looking like protection,
6625 7126 // so the ones that cannot match are dropped and reported back
6626 7127 // instead of being stored in silence.
6627 - $rejected_ips = $this->filter_ip_lists( $section, $processed );
7128 + $rejected_ips = $this->filter_ip_lists( $section, $processed, $rejected_proxies );
6628 7129
6629 7130 // Save the processed section
6630 7131 $saved_options[ $section ] = $processed;
6631 7132
@@ -6636,8 +7137,10 @@
6636 7137
6637 7138 // Clear cache before saving
6638 7139 wp_cache_delete( Vigilante_Settings::OPTION_NAME, 'options' );
6639 7140
7141 + $saved_options = Vigilante_Settings::keep_locked_file_settings( $saved_options, $stored_options );
7142 +
6640 7143 // Save to database
6641 7144 update_option( Vigilante_Settings::OPTION_NAME, $saved_options );
6642 7145
6643 7146 // Clear the settings cache
@@ -6699,8 +7202,21 @@
6699 7202 implode( ', ', array_map( 'esc_html', $rejected_ips ) )
6700 7203 );
6701 7204 }
6702 7205
7206 + if ( ! empty( $rejected_proxies ) ) {
7207 + $message .= ' ' . sprintf(
7208 + /* translators: %s: comma separated list of the trusted proxy entries that were not saved. */
7209 + _n(
7210 + 'A trusted proxy must be an exact IP or a CIDR range, not a wildcard, so this entry was not saved: %s',
7211 + 'A trusted proxy must be an exact IP or a CIDR range, not a wildcard, so these entries were not saved: %s',
7212 + count( $rejected_proxies ),
7213 + 'vigilante'
7214 + ),
7215 + implode( ', ', array_map( 'esc_html', $rejected_proxies ) )
7216 + );
7217 + }
7218 +
6703 7219 wp_send_json_success( $message );
6704 7220 }
6705 7221
6706 7222 /**
@@ -6711,9 +7227,21 @@
6711 7227 * @param string $section Section being saved.
6712 7228 * @param array $processed Section data, edited in place.
6713 7229 * @return array Entries that were dropped, for the message back to the user.
6714 7230 */
6715 - private function filter_ip_lists( $section, &$processed ) {
7231 + private function filter_ip_lists( $section, &$processed, &$rejected_proxies = array() ) {
7232 + $rejected_proxies = array();
7233 +
7234 + // Trusted proxies feed an identity decision, so only exact addresses and
7235 + // CIDR ranges belong there: a wildcard is stripped with its own message,
7236 + // never stored looking effective. The matcher ignores it anyway (see
7237 + // Vigilante_IP_Utils::in_list_ip_or_cidr), this stops it persisting.
7238 + if ( 'firewall' === $section && isset( $processed['trusted_proxies'] ) && is_array( $processed['trusted_proxies'] ) ) {
7239 + $split = Vigilante_IP_Utils::split_list_ip_or_cidr( $processed['trusted_proxies'] );
7240 + $processed['trusted_proxies'] = $split['valid'];
7241 + $rejected_proxies = $split['rejected'];
7242 + }
7243 +
6716 7244 $lists = array(
6717 7245 'firewall' => array( 'ip_whitelist', 'ip_blacklist' ),
6718 7246 'login_security' => array( 'ip_whitelist' ),
6719 7247 );
@@ -7060,13 +7588,27 @@
7060 7588
7061 7589 // Sanitize imported data recursively
7062 7590 $imported = map_deep( $imported, 'sanitize_text_field' );
7063 7591
7064 - // Validate structure
7065 - $defaults = $this->settings->get_default_options();
7066 - $merged = array_replace_recursive( $defaults, $imported );
7592 + // Validate structure: only sections and keys of the schema survive, and
7593 + // every value takes the type of its default. Until 2.11.0 this was an
7594 + // array_replace_recursive() of the file over the defaults, so any key in
7595 + // the file, known or not, landed in vigilante_options (S7). Sections
7596 + // the file does not carry keep their defaults; a section it does carry
7597 + // replaces the default one whole, because validate_options() has
7598 + // already filled in whatever the file left out.
7599 + $defaults = $this->settings->get_default_options();
7600 + $validated = $this->settings->validate_options( $imported );
7601 + $merged = $defaults;
7067 7602
7603 + foreach ( $validated as $section => $data ) {
7604 + if ( is_array( $data ) ) {
7605 + $merged[ $section ] = $data;
7606 + }
7607 + }
7608 +
7068 7609 // Save
7610 + $merged = Vigilante_Settings::keep_locked_file_settings( $merged, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7069 7611 update_option( Vigilante_Settings::OPTION_NAME, $merged );
7070 7612 $this->settings->clear_cache();
7071 7613
7072 7614 // Re-evaluate the active preset marker. The imported config may match
@@ -7089,9 +7631,9 @@
7089 7631 if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
7090 7632 wp_schedule_single_event( time() + 5, 'vigilante_under_attack_post_scan' );
7091 7633 }
7092 7634
7093 - wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) );
7635 + wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
7094 7636 }
7095 7637
7096 7638 /**
7097 7639 * Detect whether a vigilante_options array matches a known preset.
@@ -7194,9 +7736,11 @@
7194 7736 $preset = isset( $_POST['preset'] ) ? sanitize_key( $_POST['preset'] ) : '';
7195 7737
7196 7738 // Handle reset to defaults
7197 7739 if ( 'reset' === $preset ) {
7198 - $defaults = Vigilante_Settings::get_defaults_preserving_user_data( get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7740 + $stored_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
7741 + $defaults = Vigilante_Settings::get_defaults_preserving_user_data( $stored_options );
7742 + $defaults = Vigilante_Settings::keep_locked_file_settings( $defaults, $stored_options );
7199 7743 update_option( Vigilante_Settings::OPTION_NAME, $defaults );
7200 7744 $this->settings->clear_cache();
7201 7745
7202 7746 // Clear active preset
@@ -7204,9 +7748,9 @@
7204 7748
7205 7749 // Apply file changes after reset
7206 7750 $this->apply_all_file_changes( $defaults );
7207 7751
7208 - wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) );
7752 + wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) . $this->locked_file_settings_message() );
7209 7753 return;
7210 7754 }
7211 7755
7212 7756 $presets = $this->settings->get_presets();
@@ -7232,8 +7776,9 @@
7232 7776 // invent keys that are missing on both sides.
7233 7777 $current = Vigilante_Settings::merge_preset( $this->settings->get_default_options(), $current );
7234 7778
7235 7779 $merged = Vigilante_Settings::merge_preset( $current, $preset_options );
7780 + $merged = Vigilante_Settings::keep_locked_file_settings( $merged, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7236 7781
7237 7782 update_option( Vigilante_Settings::OPTION_NAME, $merged );
7238 7783 $this->settings->clear_cache();
7239 7784
@@ -7242,9 +7787,9 @@
7242 7787
7243 7788 // Apply file changes after preset
7244 7789 $this->apply_all_file_changes( $merged );
7245 7790
7246 - wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) );
7791 + wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
7247 7792 }
7248 7793
7249 7794 /**
7250 7795 * AJAX: Reset a specific section to defaults
@@ -7279,27 +7824,19 @@
7279 7824 * On a subsite, the settings written to wp-config.php and .htaccess are
7280 7825 * the main site's business. Resetting the local copy of those would only
7281 7826 * make this screen disagree with the file, so they are carried over
7282 7827 * untouched, and a section that is nothing but shared settings is not
7283 - * reset at all.
7828 + * reset at all. On the main site, a user without network rights keeps
7829 + * the ones the shared files are built from as well (2.11.6).
7284 7830 */
7285 - if ( ! Vigilante_Settings::can_write_shared_files() ) {
7286 - $shared = Vigilante_Settings::get_shared_file_settings();
7831 + $locked = Vigilante_Settings::get_locked_file_settings();
7287 7832
7288 - if ( isset( $shared[ $section ] ) ) {
7289 - if ( true === $shared[ $section ] ) {
7290 - wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7291 - }
7292 -
7293 - foreach ( $shared[ $section ] as $shared_key ) {
7294 - if ( array_key_exists( $shared_key, (array) $current_options[ $section ] ) ) {
7295 - $new_values[ $shared_key ] = $current_options[ $section ][ $shared_key ];
7296 - }
7297 - }
7298 - }
7833 + if ( isset( $locked[ $section ] ) && true === $locked[ $section ] ) {
7834 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7299 7835 }
7300 7836
7301 7837 $current_options[ $section ] = $new_values;
7838 + $current_options = Vigilante_Settings::keep_locked_file_settings( $current_options, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7302 7839
7303 7840 // Save
7304 7841 update_option( Vigilante_Settings::OPTION_NAME, $current_options );
7305 7842 $this->settings->clear_cache();
@@ -7382,8 +7919,19 @@
7382 7919 // Save new results
7383 7920 update_option( 'vigilante_last_integrity_scan', time() );
7384 7921 update_option( 'vigilante_last_integrity_results', $results );
7385 7922
7923 + // On the main site the scan does compute the lines of wp-config.php and
7924 + // .htaccess, for the network administrator. Somebody without network
7925 + // rights gets the change and its sizes, not the lines.
7926 + if ( $this->critical_approval_locked() && ! empty( $results['modified'] ) && is_array( $results['modified'] ) ) {
7927 + foreach ( $results['modified'] as $index => $item ) {
7928 + if ( is_array( $item ) && 'critical_config' === ( $item['type'] ?? '' ) ) {
7929 + $results['modified'][ $index ]['diff'] = Vigilante_File_Integrity::network_only_diff();
7930 + }
7931 + }
7932 + }
7933 +
7386 7934 wp_send_json_success( array(
7387 7935 'message' => __( 'Scan completed.', 'vigilante' ),
7388 7936 'results' => $results,
7389 7937 'ignored_count' => count( get_option( 'vigilante_ignored_files', array() ) ),
@@ -7417,11 +7965,41 @@
7417 7965 if ( ! current_user_can( 'manage_options' ) ) {
7418 7966 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
7419 7967 }
7420 7968
7969 + $results = get_option( 'vigilante_last_integrity_results' );
7970 + $scanned_at = get_option( 'vigilante_last_integrity_scan' );
7971 +
7421 7972 delete_option( 'vigilante_last_integrity_results' );
7422 7973 delete_option( 'vigilante_last_integrity_scan' );
7423 7974
7975 + /*
7976 + * A pending change to wp-config.php or the root .htaccess is closed by
7977 + * approving it, which takes the network. Clearing the results was one
7978 + * more way to close it without, until the next scan: the ignore list was
7979 + * shut in 2.11.8 and this button was left open, found by the cross
7980 + * review of 2.11.8. So for somebody who cannot approve, those entries
7981 + * stay and everything else goes.
7982 + */
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 + );
7992 +
7993 + if ( $critical ) {
7994 + $results['modified'] = $critical;
7995 + $results['suspicious'] = array();
7996 + $results['extra'] = array();
7997 + update_option( 'vigilante_last_integrity_results', $results );
7998 + update_option( 'vigilante_last_integrity_scan', $scanned_at ? $scanned_at : time() );
7999 + }
8000 + }
8001 +
7424 8002 if ( $this->database ) {
7425 8003 $this->database->clear_file_hashes();
7426 8004 }
7427 8005
@@ -7447,8 +8025,14 @@
7447 8025 if ( empty( $file ) ) {
7448 8026 wp_send_json_error( __( 'No file specified.', 'vigilante' ) );
7449 8027 }
7450 8028
8029 + // A change to a shared file is closed by approving it, and approving it
8030 + // takes the network. Ignoring it would close the same warning without.
8031 + if ( $this->critical_approval_locked() && in_array( $file, array( 'wp-config.php', '.htaccess' ), true ) ) {
8032 + wp_send_json_error( $this->critical_approval_notice() );
8033 + }
8034 +
7451 8035 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database );
7452 8036 $file_integrity->ignore_file( $file );
7453 8037
7454 8038 // Also remove the file from stored scan results so UI updates
@@ -7512,12 +8096,14 @@
7512 8096 if ( ! is_array( $raw_files ) ) {
7513 8097 wp_send_json_error( __( 'Invalid request.', 'vigilante' ) );
7514 8098 }
7515 8099
7516 - $files = array();
8100 + $files = array();
8101 + $shared = $this->critical_approval_locked() ? array( 'wp-config.php', '.htaccess' ) : array();
7517 8102 foreach ( $raw_files as $f ) {
7518 8103 $clean = sanitize_text_field( $f );
7519 - if ( '' !== $clean ) {
8104 + // Same rule as ajax_ignore_file() for the two shared files.
8105 + if ( '' !== $clean && ! in_array( $clean, $shared, true ) ) {
7520 8106 $files[] = $clean;
7521 8107 }
7522 8108 }
7523 8109