PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.2
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.2
3.0.5 3.0.4 3.0.3 3.0.2 3.0.1 3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 All 93 releases
← All changes | admin/class-admin.php +1699 -88 2.10.1 → 3.0.2 View file →
@@ -107,8 +107,11 @@
107 107 add_action( 'admin_init', array( $this, 'redirect_submenu_shortcuts' ) );
108 108 add_action( 'admin_init', array( $this, 'register_settings' ) );
109 109 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
110 110 add_action( 'admin_notices', array( $this, 'show_admin_notices' ) );
111 + // Self-protection speaks in the network admin too: on a network its
112 + // files are shared, and the super administrator is who can repair them.
113 + add_action( 'network_admin_notices', array( $this, 'maybe_show_self_protection_notice' ) );
111 114
112 115 // Highlight correct submenu based on active tab
113 116 add_filter( 'submenu_file', array( $this, 'highlight_submenu_tab' ) );
114 117
@@ -199,8 +202,26 @@
199 202 /**
200 203 * Run database migrations based on stored version
201 204 */
202 205 public function run_migrations() {
206 + /*
207 + * admin-ajax.php fires admin_init before it decides who is asking
208 + * (wp-admin/admin-ajax.php:45), so until 2.11.10 an anonymous POST to
209 + * admin-ajax.php with any action ran every pending migration. That is
210 + * not a read: the migrations rewrite wp-config.php through
211 + * apply_security_constants(), rewrite the root .htaccess, move user meta
212 + * of the whole network and can rebuild the file integrity baseline,
213 + * taking whatever is on disk as approved. Reproduced on 12 sep 2026 with
214 + * curl and no cookies, and found by the file-by-file review of 2.11.10.
215 + *
216 + * Migrations are maintenance for whoever administers the site, so they
217 + * wait for an administrator to load a screen. Nothing is lost by
218 + * waiting: every migration is idempotent and version gated.
219 + */
220 + if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {
221 + return;
222 + }
223 +
203 224 $db_version = get_option( 'vigilante_db_version', '0' );
204 225
205 226 // 1.2.3: Fix IP lists corrupted by sanitize_text_field stripping newlines
206 227 if ( version_compare( $db_version, '1.2.3', '<' ) ) {
@@ -273,9 +294,29 @@
273 294 if ( ! class_exists( 'Vigilante_File_Integrity' ) ) {
274 295 require_once VIGILANTE_INCLUDES_DIR . 'class-file-integrity.php';
275 296 }
276 297 $fi = new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
277 - $fi->regenerate_all_baselines();
298 +
299 + /*
300 + * Only when there is nothing on record. This migration exists to
301 + * create the baseline that did not exist, never to discard the one
302 + * the owner approved: rebuilding it from the files takes whatever
303 + * is on disk right now as approved, so a wp-config.php modified and
304 + * awaiting review would be blessed in silence.
305 + *
306 + * And this is not theory. vigilante_db_version is written on two
307 + * different scales into the same option: this file counts in plugin
308 + * versions (2.11.0) and Vigilante_Database counts in schema
309 + * versions, currently 1.4.0 (class-database.php:322 and :380). For
310 + * version_compare, 1.4.0 is LOWER than 1.14.0, so any site whose
311 + * option was last written by the schema runs this migration again.
312 + * Measured on the Multisite install on 10 sep 2026: one of the three
313 + * sites was sitting on 1.4.0.
314 + */
315 + if ( ! $fi->get_critical_files_baseline() ) {
316 + $fi->regenerate_all_baselines();
317 + }
318 +
278 319 update_option( 'vigilante_db_version', '1.14.0' );
279 320 }
280 321
281 322 // 2.0.0: Move hide_server_signature and remove_fingerprinting_headers
@@ -425,11 +466,232 @@
425 466 }
426 467
427 468 update_option( 'vigilante_db_version', '2.9.9' );
428 469 }
470 +
471 + /*
472 + * 2.11.0: security release (audit of 28 Aug 2026). Runs here and not
473 + * from Vigilante_Database::needs_update(): this option is shared with
474 + * that class, and on any updated site it already holds a plugin version
475 + * (2.9.9 or later), so a bump of DB_VERSION would never fire.
476 + * create_tables() widens the email code column through dbDelta (varchar
477 + * 6 to 64, the code is stored hashed since 2.11.0) and purge_for_2_11_0()
478 + * does what dbDelta cannot: it empties the trusted devices, which were
479 + * identified by User-Agent until now (S1), and the pending email codes,
480 + * stored in clear until now (S11). Every remembered device asks for the
481 + * second factor once more after this update, and the changelog says so.
482 + */
483 + if ( version_compare( $db_version, '2.11.0', '<' ) ) {
484 + $this->database->create_tables();
485 + $this->database->purge_for_2_11_0();
486 +
487 + update_option( 'vigilante_db_version', '2.11.0' );
488 + }
489 +
490 + /*
491 + * 2.11.9: clear the raw .htaccess copies that older versions left in
492 + * options, on the first admin load after the update. Uninstall already
493 + * removes them, but that only fires when the plugin is deleted, so a
494 + * site that keeps the plugin carried them until now. Three stores, each
495 + * a copy of a file that can hold secrets (a SetEnv token, an
496 + * Authorization header): the same exposure the wp.org review flagged as
497 + * 4.4, on the paths its fix did not reach.
498 + *
499 + * - vigilante_htaccess_history: up to five raw copies, by design, until
500 + * 2.11.8. The writer is gone, nothing reads it, so it is deleted.
501 + * - vigilante_htaccess_backup: the single rollback buffer, normally
502 + * cleared in the finally of each write; a copy only lingers if a write
503 + * crashed mid-operation. Nothing outside one write reads it, so a
504 + * leftover is deleted.
505 + * - vigilante_htaccess_pre_migration: still read by the header recovery,
506 + * but older versions stored the whole file where only our own block is
507 + * ever used. Truncated to that block, so the feature keeps working and
508 + * nothing outside our markers stays in the option.
509 + */
510 + if ( version_compare( $db_version, '2.11.9', '<' ) ) {
511 + delete_option( 'vigilante_htaccess_history' );
512 + delete_option( 'vigilante_htaccess_backup' );
513 +
514 + $snapshot = get_option( 'vigilante_htaccess_pre_migration' );
515 + if ( is_array( $snapshot ) && isset( $snapshot['content'] ) && '' !== (string) $snapshot['content'] ) {
516 + require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-recovery.php';
517 + $block = Vigilante_Htaccess_Recovery::get_raw_block();
518 +
519 + if ( '' === $block ) {
520 + delete_option( 'vigilante_htaccess_pre_migration' );
521 + } elseif ( $block !== $snapshot['content'] ) {
522 + $snapshot['content'] = $block;
523 + update_option( 'vigilante_htaccess_pre_migration', $snapshot, false );
524 + }
525 + }
526 +
527 + update_option( 'vigilante_db_version', '2.11.9' );
528 + }
529 +
530 + /*
531 + * 2.11.10: the pending-approval flag becomes one per site on a network.
532 + * Until 2.11.9 it was a single global user meta, so the queue was shared
533 + * across the whole network. Moving the key is not enough: the accounts
534 + * already waiting carry the old key, and reading only the new one would
535 + * let them log in. So they are moved here, each to the site it belongs
536 + * to, and the old key is removed only once the new one is written.
537 + */
538 + if ( version_compare( $db_version, '2.11.10', '<' ) ) {
539 + $this->migrate_pending_approval_per_site();
540 +
541 + update_option( 'vigilante_db_version', '2.11.10' );
542 + }
543 +
544 + /*
545 + * 3.0.0: self-protection package. This block must stay the LAST one of
546 + * run_migrations(): every block compares against the same $db_version
547 + * captured at the top, so the last update_option() that runs is the
548 + * one that sticks, and on a jump from any older version it has to be
549 + * this one.
550 + *
551 + * 1. Capture the self-integrity anchor (the manifest fingerprint) and
552 + * run an inline self-check. It covers every update the old code never
553 + * saw through the upgrader hook: from any 2.x, and manual or FTP
554 + * uploads. A plain new is enough, the constructor registers no hooks.
555 + * 2. Drop the cached Security Check report: the Internal category grows
556 + * from 30 to 33 points (the self_integrity check), and the cached
557 + * report would keep the old denominator until the next full scan.
558 + * Same reason and same background refresh as the 2.6.1 block above.
559 + * 3. Remove Vigilant's own files from the ignore list before that check.
560 + * Until 2.11.11 the generic scan listed them like any plugin, and any
561 + * administrator could ignore one (after a false alarm while
562 + * WordPress.org published new checksums, for example). Kept, those
563 + * entries would silence the self-check of those files for good,
564 + * PHP included.
565 + */
566 + if ( version_compare( $db_version, '3.0.0', '<' ) ) {
567 + if ( ! class_exists( 'Vigilante_Self_Integrity' ) ) {
568 + require_once VIGILANTE_INCLUDES_DIR . 'class-self-integrity.php';
569 + }
570 + $ignored_files = get_option( 'vigilante_ignored_files', array() );
571 + if ( is_array( $ignored_files ) && $ignored_files ) {
572 + $kept_files = array_values(
573 + array_filter(
574 + $ignored_files,
575 + function ( $ignored_file ) {
576 + return ! Vigilante_Self_Integrity::is_own_file_path( (string) $ignored_file );
577 + }
578 + )
579 + );
580 + if ( count( $kept_files ) !== count( $ignored_files ) ) {
581 + update_option( 'vigilante_ignored_files', $kept_files );
582 + if ( $this->activity_log ) {
583 + $this->activity_log->log(
584 + 'system',
585 + 'self_ignored_files_removed',
586 + __( 'Vigilant files were removed from the File Integrity ignore list: self-protection checks them now.', 'vigilante' ),
587 + array( 'removed' => count( $ignored_files ) - count( $kept_files ) ),
588 + 'info'
589 + );
590 + }
591 + }
592 + }
593 + $self_integrity = new Vigilante_Self_Integrity( $this->settings, $this->activity_log );
594 + $self_integrity->run_check( 'migration' );
595 +
596 + delete_option( 'vigilante_analyzer_last_scan' );
597 + if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
598 + wp_schedule_single_event( time() + 5, 'vigilante_under_attack_post_scan' );
599 + }
600 +
601 + update_option( 'vigilante_db_version', '3.0.0' );
602 + }
429 603 }
430 604
431 605 /**
606 + * Move the pending-approval flag of a network to a key per site
607 + *
608 + * Runs once for the whole network, not once per site: the data it moves is
609 + * global, so the guard is a network option and any site may be the one that
610 + * does it. On a single site the key does not change and there is nothing to
611 + * do.
612 + *
613 + * Each waiting account goes to its primary site, or to the only site it
614 + * belongs to; one that belongs to none goes to the main site rather than
615 + * nowhere, because losing the flag would silently approve it.
616 + *
617 + * @since 2.11.10
618 + */
619 + private function migrate_pending_approval_per_site() {
620 + global $wpdb;
621 +
622 + if ( ! is_multisite() ) {
623 + return;
624 + }
625 +
626 + if ( get_site_option( 'vigilante_pending_per_site_done' ) ) {
627 + return;
628 + }
629 +
630 + // 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".
631 + $user_ids = $wpdb->get_col(
632 + $wpdb->prepare( "SELECT DISTINCT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s", 'vigilante_pending_approval' )
633 + );
634 +
635 + foreach ( (array) $user_ids as $user_id ) {
636 + $user_id = (int) $user_id;
637 + if ( ! $user_id ) {
638 + continue;
639 + }
640 +
641 + $pending = get_user_meta( $user_id, 'vigilante_pending_approval', true );
642 + $since = get_user_meta( $user_id, 'vigilante_pending_since', true );
643 +
644 + /*
645 + * Every site the account belongs to, not its primary one. The global
646 + * flag does not say where the registration happened, and the first
647 + * version of this guessed the primary blog: an account that
648 + * registered on B while its primary was A came out pending on A and
649 + * free to log in on B, which is the very site it had never been
650 + * approved on. Found by the cross review of 2.11.10.
651 + *
652 + * Marking every site it belongs to fails closed instead: the account
653 + * stays blocked wherever it can log in, and shows up in the queue of
654 + * each of those sites so somebody can actually act on it. An account
655 + * that belongs to no site goes to the main one rather than nowhere,
656 + * because losing the flag would silently approve it.
657 + */
658 + /*
659 + * With $all true, because the default leaves out archived, spam and
660 + * deleted sites (wp-includes/user.php:1113-1117): a site archived on
661 + * the day this runs would lose the flag, and the account would walk
662 + * in unapproved the moment it was brought back. Found by the second
663 + * cross review of 2.11.10.
664 + */
665 + $blog_ids = array();
666 +
667 + foreach ( get_blogs_of_user( $user_id, true ) as $blog ) {
668 + if ( ! empty( $blog->userblog_id ) ) {
669 + $blog_ids[] = (int) $blog->userblog_id;
670 + }
671 + }
672 +
673 + if ( empty( $blog_ids ) ) {
674 + $blog_ids[] = (int) get_main_site_id();
675 + }
676 +
677 + foreach ( array_unique( $blog_ids ) as $blog_id ) {
678 + $prefix = $wpdb->get_blog_prefix( $blog_id );
679 +
680 + update_user_meta( $user_id, $prefix . 'vigilante_pending_approval', $pending );
681 + if ( '' !== $since && false !== $since ) {
682 + update_user_meta( $user_id, $prefix . 'vigilante_pending_since', $since );
683 + }
684 + }
685 +
686 + delete_user_meta( $user_id, 'vigilante_pending_approval' );
687 + delete_user_meta( $user_id, 'vigilante_pending_since' );
688 + }
689 +
690 + update_site_option( 'vigilante_pending_per_site_done', 1 );
691 + }
692 +
693 + /**
432 694 * Migration: Remove orphaned email fields from saved options
433 695 *
434 696 * v1.10.0 centralized notification recipients into email section.
435 697 * Old per-module notify_email fields and dead email section fields
@@ -525,17 +787,30 @@
525 787 $pending_count = $this->get_pending_approvals_count();
526 788
527 789 // Get security issues with severity
528 790 $security_status = $this->get_security_status_for_badge();
529 -
791 +
792 + /*
793 + * Self-protection: a change to Vigilant own files is the one finding
794 + * that has to be visible from any screen of WordPress, so it adds to
795 + * the counter and paints it red. A verified state, a state with fewer
796 + * references than usual and the check turned off add nothing: a counter
797 + * that is always on is a counter nobody reads.
798 + */
799 + $self_tone = Vigilante_Self_Integrity::tone(
800 + Vigilante_Self_Integrity::display_state(),
801 + Vigilante_Self_Integrity::is_on()
802 + );
803 + $self_badge = in_array( $self_tone, array( 'critical', 'off', 'warning' ), true ) ? 1 : 0;
804 +
530 805 // Total count for badge
531 - $total_badge = $pending_count + $security_status['count'];
532 -
806 + $total_badge = $pending_count + $security_status['count'] + $self_badge;
807 +
533 808 if ( $total_badge > 0 ) {
534 809 // Determine badge color:
535 810 // - Red (awaiting-mod): pending approvals OR critical modules disabled
536 811 // - Orange (update-plugins): only non-critical modules disabled
537 - if ( $pending_count > 0 || $security_status['has_critical'] ) {
812 + if ( $pending_count > 0 || $security_status['has_critical'] || in_array( $self_tone, array( 'critical', 'off' ), true ) ) {
538 813 $badge_class = 'awaiting-mod';
539 814 } else {
540 815 $badge_class = 'update-plugins vigilante-badge-warning';
541 816 }
@@ -577,13 +852,21 @@
577 852 'vigilante-activity-log',
578 853 array( $this, 'redirect_to_tab' )
579 854 );
580 855
581 - // File Integrity shortcut
856 + // File Integrity shortcut, with its own counter when the self-check has
857 + // something to say: that is the screen that explains it.
858 + $fi_menu_title = __( 'File Integrity', 'vigilante' );
859 + if ( $self_badge > 0 ) {
860 + $fi_menu_title .= sprintf(
861 + ' <span class="%s count-1"><span class="pending-count">1</span></span>',
862 + in_array( $self_tone, array( 'critical', 'off' ), true ) ? 'awaiting-mod' : 'update-plugins vigilante-badge-warning'
863 + );
864 + }
582 865 add_submenu_page(
583 866 'vigilante',
584 867 __( 'File Integrity', 'vigilante' ),
585 - __( 'File Integrity', 'vigilante' ),
868 + $fi_menu_title,
586 869 'manage_options',
587 870 'vigilante-file-integrity',
588 871 array( $this, 'redirect_to_tab' )
589 872 );
@@ -697,27 +980,56 @@
697 980 if ( ! did_action( 'plugins_loaded' ) ) {
698 981 return 0;
699 982 }
700 983
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 - }
984 + /*
985 + * Counted whether the feature is on or off. An account already waiting
986 + * stays blocked when it is switched off (see init_enforcement_hooks()),
987 + * so reporting zero there hid people who cannot log in and whom nobody
988 + * could see to approve. Found by the cross review of 2.11.10.
989 + */
707 990
708 991 // 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',
992 + $args = array(
993 + 'meta_key' => Vigilante_User_Security::site_user_meta_key( 'vigilante_pending_approval' ),
711 994 'meta_value' => '1',
712 995 'fields' => 'ID',
713 - ) );
996 + );
714 997 // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
715 998
999 + // Same query as Vigilante_User_Security::get_pending_users(), and for the
1000 + // same reason: the meta key already scopes this to the site, and adding
1001 + // core's membership filter on top hid the accounts that have no role yet.
1002 + if ( is_multisite() ) {
1003 + $args['blog_id'] = 0;
1004 + }
1005 +
1006 + $pending_users = get_users( $args );
1007 +
716 1008 return count( $pending_users );
717 1009 }
718 1010
719 1011 /**
1012 + * Whether XML-RPC is blocked, whole or its pingback methods
1013 + *
1014 + * Both levels have to say so: the three-way setting under WP Hardening, read
1015 + * through the module's own resolver, and the WP Hardening module that
1016 + * enforces it. Until 3.0.2 the Dashboard read login_security[disable_xmlrpc],
1017 + * the checkbox that moved to WP Hardening in 2.9.7 and is never written any
1018 + * more, so the score never granted its three points and the recommendation
1019 + * to disable XML-RPC stayed on for good.
1020 + *
1021 + * @since 3.0.2
1022 + *
1023 + * @param array $options Plugin options.
1024 + * @return bool
1025 + */
1026 + private function is_xmlrpc_protected( $options ) {
1027 + return ! empty( $options['modules']['wp_hardening'] )
1028 + && 'none' !== Vigilante_Comment_Security::resolve_xmlrpc_mode( $this->settings );
1029 + }
1030 +
1031 + /**
720 1032 * Calculate comprehensive security score (0-100)
721 1033 *
722 1034 * @param array $options Plugin options.
723 1035 * @return int Security score.
@@ -775,10 +1087,10 @@
775 1087 // Max attempts configured
776 1088 if ( isset( $login['max_attempts'] ) && $login['max_attempts'] <= 5 ) {
777 1089 $score += 3;
778 1090 }
779 - // XML-RPC disabled
780 - if ( ! empty( $login['disable_xmlrpc'] ) ) {
1091 + // XML-RPC blocked, whole or its pingback methods
1092 + if ( $this->is_xmlrpc_protected( $options ) ) {
781 1093 $score += 3;
782 1094 }
783 1095 // 2FA enabled
784 1096 if ( ! empty( $login['two_factor']['enabled'] ) ) {
@@ -837,8 +1149,28 @@
837 1149 $score += 3;
838 1150 }
839 1151 }
840 1152
1153 + /*
1154 + * Self-protection (15 points). Having it on is configuration, which is
1155 + * what this card measures; the state of the last check caps the card,
1156 + * because a plugin whose own files were changed is not well configured
1157 + * in any useful sense, whatever the rest of the settings say. The
1158 + * environment block below already mixes measured state into this score
1159 + * (WP_DEBUG, insecure usernames), so the card keeps its meaning.
1160 + */
1161 + $max_score += 15;
1162 + $self_enabled = Vigilante_Self_Integrity::is_on();
1163 + $self_tone = Vigilante_Self_Integrity::tone( Vigilante_Self_Integrity::display_state(), $self_enabled );
1164 + if ( $self_enabled ) {
1165 + $score += 10;
1166 + // The five points are for a check that ran and came out clean: a
1167 + // site that has never checked itself has not earned them.
1168 + if ( in_array( $self_tone, array( 'ok', 'info' ), true ) ) {
1169 + $score += 5;
1170 + }
1171 + }
1172 +
841 1173 // Environment checks (8 points) - penalize insecure server configuration
842 1174 $max_score += 8;
843 1175 $env_score = 8;
844 1176
@@ -854,9 +1186,14 @@
854 1186 }
855 1187
856 1188 $score += max( 0, $env_score );
857 1189
858 - return $max_score > 0 ? round( ( $score / $max_score ) * 100 ) : 0;
1190 + $percent = $max_score > 0 ? (int) round( ( $score / $max_score ) * 100 ) : 0;
1191 + if ( in_array( $self_tone, array( 'critical', 'off' ), true ) ) {
1192 + // Same cap and same reason as the Security Check score.
1193 + $percent = min( $percent, Vigilante_Security_Analyzer::SCORE_CAP_ON_TAMPER );
1194 + }
1195 + return $percent;
859 1196 }
860 1197
861 1198 /**
862 1199 * Get security recommendations based on current settings
@@ -866,8 +1203,51 @@
866 1203 */
867 1204 private function get_security_recommendations( $options ) {
868 1205 $recommendations = array();
869 1206
1207 + // Self-protection first: if Vigilant itself cannot be trusted, nothing
1208 + // else on this card means much.
1209 + $vg_self_enabled = Vigilante_Self_Integrity::is_on();
1210 + $vg_self_tone = Vigilante_Self_Integrity::tone( Vigilante_Self_Integrity::display_state(), $vg_self_enabled );
1211 + if ( ! $vg_self_enabled ) {
1212 + $recommendations[] = array(
1213 + 'icon' => 'shield',
1214 + 'priority' => 'high',
1215 + 'tab' => 'file-integrity',
1216 + 'message' => __( 'Turn Vigilant self-protection on, so a change to Vigilant own files does not go unnoticed.', 'vigilante' ),
1217 + );
1218 + } elseif ( 'off' === $vg_self_tone ) {
1219 + $recommendations[] = array(
1220 + 'icon' => 'warning',
1221 + 'priority' => 'critical',
1222 + 'tab' => 'file-integrity',
1223 + 'message' => __( 'Something on this site switched Vigilant self-protection off: File Integrity says which file does it.', 'vigilante' ),
1224 + );
1225 + } elseif ( 'critical' === $vg_self_tone ) {
1226 + $recommendations[] = array(
1227 + 'icon' => 'warning',
1228 + 'priority' => 'critical',
1229 + 'tab' => 'file-integrity',
1230 + 'message' => __( 'Vigilant own files have been changed: repair Vigilant from File Integrity before anything else.', 'vigilante' ),
1231 + );
1232 + } elseif ( 'warning' === $vg_self_tone ) {
1233 + $recommendations[] = array(
1234 + 'icon' => 'shield',
1235 + 'priority' => 'high',
1236 + 'tab' => 'file-integrity',
1237 + 'message' => __( 'Vigilant self-protection needs your attention: File Integrity says what it found and what to do.', 'vigilante' ),
1238 + );
1239 + } elseif ( 'none' === $vg_self_tone ) {
1240 + // The score holds back the points of a check that has not run yet,
1241 + // so the card has to say why instead of just showing a lower number.
1242 + $recommendations[] = array(
1243 + 'icon' => 'shield',
1244 + 'priority' => 'high',
1245 + 'tab' => 'file-integrity',
1246 + 'message' => __( 'Vigilant has not checked its own files yet: run a scan from File Integrity.', 'vigilante' ),
1247 + );
1248 + }
1249 +
870 1250 // Critical: Firewall disabled
871 1251 if ( empty( $options['modules']['firewall'] ) ) {
872 1252 $recommendations[] = array(
873 1253 'icon' => 'warning',
@@ -950,14 +1330,14 @@
950 1330 );
951 1331 }
952 1332
953 1333 // Low: XML-RPC enabled (only if login security is active)
954 - if ( ! empty( $options['modules']['login_security'] ) && empty( $login['disable_xmlrpc'] ) ) {
1334 + if ( ! empty( $options['modules']['login_security'] ) && ! $this->is_xmlrpc_protected( $options ) ) {
955 1335 $recommendations[] = array(
956 1336 'icon' => 'info',
957 1337 'priority' => 'low',
958 1338 'message' => __( 'Disable XML-RPC if not needed (reduces attack surface).', 'vigilante' ),
959 - 'tab' => 'login',
1339 + 'tab' => 'wp-hardening',
960 1340 );
961 1341 }
962 1342
963 1343 // Low: Strong passwords not enforced (only if user security is active)
@@ -1270,8 +1650,9 @@
1270 1650 // File Integrity
1271 1651 array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'File Integrity Monitoring', 'vigilante' ), 'anchor' => 'vigilante-section-fi-monitoring', 'label' => __( 'File Integrity Monitoring', 'vigilante' ), 'label_en' => 'File Integrity Monitoring', 'keywords' => _x( 'file integrity monitoring files checksum checksums tamper', 'settings search keywords', 'vigilante' ) ),
1272 1652 array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'File Integrity Monitoring', 'vigilante' ), 'anchor' => 'vigilante-section-fi-monitoring', 'label' => __( 'Scan schedule', 'vigilante' ), 'label_en' => 'Scan schedule', 'keywords' => _x( 'scan schedule scans scanning check cron', 'settings search keywords', 'vigilante' ) ),
1273 1653 array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'File Integrity Monitoring', 'vigilante' ), 'anchor' => 'vigilante-section-fi-monitoring', 'label' => __( 'Instant alert', 'vigilante' ), 'label_en' => 'Instant alert', 'keywords' => _x( 'instant alert alerts notification warning email', 'settings search keywords', 'vigilante' ) ),
1654 + array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'Vigilant self-protection', 'vigilante' ), 'anchor' => 'vigilante-section-fi-self', 'label' => __( 'Vigilant self-protection', 'vigilante' ), 'label_en' => 'Vigilant self-protection', 'keywords' => _x( 'self protection selfprotection self-check autoproteccion manifest sha256 checksums tampering tampered repair reinstall own files guardian integrity of the plugin', 'settings search keywords', 'vigilante' ) ),
1274 1655 array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'Ignored Files', 'vigilante' ), 'anchor' => 'vigilante-section-fi-ignored', 'label' => __( 'Ignored Files', 'vigilante' ), 'label_en' => 'Ignored Files', 'keywords' => _x( 'ignored files file exclude', 'settings search keywords', 'vigilante' ) ),
1275 1656 // Security Audit
1276 1657 array( 'tab' => 'activity-log', 'tab_label' => __( 'Security Audit', 'vigilante' ), 'section' => __( 'Security Audit Settings', 'vigilante' ), 'anchor' => 'vigilante-section-audit-settings', 'label' => __( 'Retention', 'vigilante' ), 'label_en' => 'Retention', 'keywords' => _x( 'retention keep days storage log', 'settings search keywords', 'vigilante' ) ),
1277 1658 array( 'tab' => 'activity-log', 'tab_label' => __( 'Security Audit', 'vigilante' ), 'section' => __( 'Security Audit Settings', 'vigilante' ), 'anchor' => 'vigilante-section-audit-settings', 'label' => __( 'Events to Log', 'vigilante' ), 'label_en' => 'Events to Log', 'keywords' => _x( 'events to log', 'settings search keywords', 'vigilante' ) ),
@@ -1424,13 +1805,17 @@
1424 1805 );
1425 1806
1426 1807 wp_localize_script( 'vigilante-admin', 'vigilanteAdmin', array(
1427 1808 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
1809 + 'selfBoxUrl' => esc_url( admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' ) ),
1428 1810 'nonce' => wp_create_nonce( 'vigilante_admin_nonce' ),
1429 1811 'currentUserId' => get_current_user_id(),
1430 1812 'logoutUrl' => wp_logout_url( wp_login_url() ),
1431 1813 'adminUrl' => admin_url( 'admin.php?page=vigilante' ),
1432 1814 'searchIndex' => $this->get_search_index(),
1815 + // The scan repaints this table from JavaScript, so the same gate
1816 + // has to travel with it or half the screen keeps the dead button.
1817 + 'approvalLocked' => $this->critical_approval_locked(),
1433 1818 'underAttack' => array(
1434 1819 'active' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->is_active(),
1435 1820 'remaining' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->get_remaining_time(),
1436 1821 ),
@@ -1470,8 +1855,13 @@
1470 1855 'file' => __( 'File', 'vigilante' ),
1471 1856 'reason' => __( 'Reason', 'vigilante' ),
1472 1857 'type' => __( 'Type', 'vigilante' ),
1473 1858 'unknown' => __( 'Unknown', 'vigilante' ),
1859 + 'selfType' => __( 'Vigilant (self)', 'vigilante' ),
1860 + 'logWhatHappened' => __( 'What happened', 'vigilante' ),
1861 + 'logWhatItMeans' => __( 'What it means', 'vigilante' ),
1862 + 'logWhatToDo' => __( 'What to do', 'vigilante' ),
1863 + 'logSelfSeeDetails' => __( 'Open File Integrity for the full detail', 'vigilante' ),
1474 1864 'modifiedFiles' => __( 'Modified Files', 'vigilante' ),
1475 1865 'modifiedDescription' => __( 'These files (apparently) differ from the original WordPress or plugin versions.', 'vigilante' ),
1476 1866 'extraFiles' => __( 'Extra Files', 'vigilante' ),
1477 1867 'extra' => __( 'Extra', 'vigilante' ),
@@ -1497,13 +1887,17 @@
1497 1887 'criticalConfigTitle' => __( 'Critical config files modified', 'vigilante' ),
1498 1888 '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 1889 'approve' => __( 'Approve', 'vigilante' ),
1500 1890 'approving' => __( 'Approving...', 'vigilante' ),
1891 + 'approvalLockedNotice' => $this->critical_approval_notice(),
1501 1892 'criticalApproved' => __( 'Change approved. Next scan will use the current state as baseline.', 'vigilante' ),
1502 1893 'reviewChanges' => __( 'Review changes', 'vigilante' ),
1503 1894 'hideChanges' => __( 'Hide changes', 'vigilante' ),
1504 1895 'changes' => __( 'Changes', 'vigilante' ),
1505 1896 'diffUnavailable' => __( 'Diff not available for this file (baseline was created before diff tracking was added). Approve to enable diff on future changes.', 'vigilante' ),
1897 + 'diffNetwork' => __( 'This file belongs to the whole network, so its line changes are only shown to network administrators, on the main site.', 'vigilante' ),
1898 + 'diffRescan' => __( 'Run a new scan to see the line changes of this file.', 'vigilante' ),
1899 + '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 1900 'diffEmpty' => __( 'No line-level changes detected (may be whitespace or reordering).', 'vigilante' ),
1507 1901 'diffLines' => __( 'lines', 'vigilante' ),
1508 1902 // Under Attack mode strings
1509 1903 'underAttackConfirmActivate' => __( 'Activate Under Attack mode? All visitors will see a verification page for the next 4 hours.', 'vigilante' ),
@@ -1559,8 +1953,9 @@
1559 1953 'logType' => __( 'Type', 'vigilante' ),
1560 1954 'logAction' => __( 'Action', 'vigilante' ),
1561 1955 'logSeverity' => __( 'Severity', 'vigilante' ),
1562 1956 'logMessage' => __( 'Message', 'vigilante' ),
1957 + 'logRequestUri' => __( 'Address', 'vigilante' ),
1563 1958 'logClient' => __( 'Client', 'vigilante' ),
1564 1959 'logUser' => __( 'User', 'vigilante' ),
1565 1960 'logIpAddress' => __( 'IP Address', 'vigilante' ),
1566 1961 'logUserAgent' => __( 'User Agent', 'vigilante' ),
@@ -1718,9 +2113,89 @@
1718 2113
1719 2114 /**
1720 2115 * Show admin notices
1721 2116 */
2117 + /**
2118 + * Self-protection notice.
2119 + *
2120 + * A change to Vigilant own files is shown on every admin screen, because
2121 + * waiting for someone to open the plugin is exactly what an attacker who
2122 + * patched it would want. A warning is shown only on Vigilant screens, so
2123 + * the notice that matters is not diluted. Neither is dismissible, both are
2124 + * for administrators only, and on a network the steps depend on whether
2125 + * the person can install plugins at all.
2126 + */
2127 + public function maybe_show_self_protection_notice() {
2128 + if ( ! current_user_can( 'manage_options' ) ) {
2129 + return;
2130 + }
2131 + $summary = $this->self_integrity_summary();
2132 + $tone = $summary['tone'];
2133 + if ( ! in_array( $tone, array( 'critical', 'off', 'warning' ), true ) ) {
2134 + return;
2135 + }
2136 +
2137 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the screen slug to decide where a notice is shown; no action taken.
2138 + $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
2139 + $on_vigilante = ( 0 === strpos( $page, 'vigilante' ) );
2140 + if ( 'warning' === $tone && ! $on_vigilante ) {
2141 + return;
2142 + }
2143 + $alarma = in_array( $tone, array( 'critical', 'off' ), true );
2144 +
2145 + $link = admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' );
2146 + $network = is_multisite() && ! current_user_can( 'update_plugins' );
2147 + ?>
2148 + <div class="notice notice-<?php echo $alarma ? 'error' : 'warning'; ?> vigilante-self-notice">
2149 + <p class="vigilante-self-notice-title">
2150 + <span class="dashicons dashicons-shield" aria-hidden="true"></span>
2151 + <strong>
2152 + <?php
2153 + if ( 'critical' === $tone ) {
2154 + esc_html_e( 'Vigilant detected changes in its own files', 'vigilante' );
2155 + } elseif ( 'off' === $tone ) {
2156 + esc_html_e( 'Vigilant self-protection is switched off by code', 'vigilante' );
2157 + } else {
2158 + echo esc_html( $this->self_integrity_headline( $tone, $summary['state'] ) );
2159 + }
2160 + ?>
2161 + </strong>
2162 + </p>
2163 + <p class="vigilante-self-notice-text">
2164 + <span>
2165 + <?php
2166 + if ( 'critical' === $tone ) {
2167 + esc_html_e( 'Your security plugin may have been tampered with, and while that is true nothing it reports can be trusted.', 'vigilante' );
2168 + } elseif ( 'off' === $tone ) {
2169 + esc_html_e( 'Nothing is checking that Vigilant own files are intact. File Integrity says which file switches it off.', 'vigilante' );
2170 + } else {
2171 + esc_html_e( 'File Integrity says what was found, what it means and what to do about it.', 'vigilante' );
2172 + }
2173 + if ( $network ) {
2174 + echo ' ' . esc_html__( 'Only your network administrator can repair Vigilant, because its files are shared by every site in the network. Let them know.', 'vigilante' );
2175 + }
2176 + ?>
2177 + </span>
2178 + <?php
2179 + /*
2180 + * One button, on the same line as the text it belongs to, and it
2181 + * leads to the whole story. Repairing from a notice, without
2182 + * seeing which files, what it means and what it will do, is
2183 + * asking someone to fix what they have not read: the repair
2184 + * button lives in the card, after all that.
2185 + */
2186 + ?>
2187 + <a class="button button-primary button-small" href="<?php echo esc_url( $link ); ?>">
2188 + <?php esc_html_e( 'See what to do', 'vigilante' ); ?>
2189 + </a>
2190 + </p>
2191 + </div>
2192 + <?php
2193 + }
2194 +
1722 2195 public function show_admin_notices() {
2196 + $this->maybe_show_self_protection_notice();
2197 +
1723 2198 // Activation notice
1724 2199 if ( get_transient( 'vigilante_activated' ) ) {
1725 2200 ?>
1726 2201 <div class="notice notice-success is-dismissible vigilante-activation-notice">
@@ -1765,8 +2240,21 @@
1765 2240 </p>
1766 2241 <p>
1767 2242 <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 2243 </p>
2244 + <?php
2245 + // The cache-bypass rules could not be written (a host where
2246 + // WordPress cannot write files by itself, a held lock, a
2247 + // failed read-back): show them, so they can be added by hand.
2248 + $ua_instance = new Vigilante_Under_Attack( $this->settings, $this->activity_log );
2249 + if ( $ua_instance->cache_rules_missing() ) :
2250 + ?>
2251 + <p>
2252 + <strong><?php esc_html_e( 'The cache-bypass rules could not be written to your .htaccess.', 'vigilante' ); ?></strong>
2253 + <?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' ); ?>
2254 + </p>
2255 + <textarea readonly rows="9" class="large-text code" onclick="this.select();"><?php echo esc_textarea( Vigilante_Under_Attack::get_cache_bypass_block() ); ?></textarea>
2256 + <?php endif; ?>
1769 2257 </div>
1770 2258 <?php
1771 2259 }
1772 2260 }
@@ -1998,8 +2486,41 @@
1998 2486 return ! Vigilante_Settings::can_write_shared_files();
1999 2487 }
2000 2488
2001 2489 /**
2490 + * Whether this is the main site and the user cannot change what it builds the shared files from
2491 + *
2492 + * See Vigilante_Settings::get_main_site_file_settings(). On a subsite those
2493 + * settings only act on that site, so they are never locked there.
2494 + *
2495 + * @since 2.11.6
2496 + *
2497 + * @return bool
2498 + */
2499 + private function main_site_files_locked() {
2500 + return $this->shared_files_locked() && Vigilante_Settings::owns_shared_files();
2501 + }
2502 +
2503 + /**
2504 + * Sentence added to a bulk change when some settings were left as they were
2505 + *
2506 + * Importing a file, applying a preset and restoring the defaults touch every
2507 + * section at once, so the user is told that the shared file settings did
2508 + * not move.
2509 + *
2510 + * @since 2.11.6
2511 + *
2512 + * @return string Empty when the user can change every setting.
2513 + */
2514 + private function locked_file_settings_message() {
2515 + if ( ! Vigilante_Settings::get_locked_file_settings() ) {
2516 + return '';
2517 + }
2518 +
2519 + return ' ' . __( 'The settings that end up in wp-config.php or .htaccess were left as they were.', 'vigilante' ) . ' ' . Vigilante_Settings::get_shared_files_notice();
2520 + }
2521 +
2522 + /**
2002 2523 * Print the shared-files notice for a section that cannot be edited here
2003 2524 *
2004 2525 * @since 2.9.8
2005 2526 */
@@ -2014,8 +2535,109 @@
2014 2535 <?php
2015 2536 }
2016 2537
2017 2538 /**
2539 + * Acting on another user's account needs permission over that user
2540 + *
2541 + * Since 2.10.3 the handlers behind these tools ask for edit_user over the
2542 + * target, which is the rule WordPress itself applies. On a network the core
2543 + * grants edit_user only to network administrators, so for anybody else these
2544 + * controls do nothing. Better to say so than to paint a button that silently
2545 + * skips every user.
2546 + *
2547 + * @since 2.10.4
2548 + * @return bool
2549 + */
2550 + private function forwarded_chain_readings() {
2551 + // Shown, not decided on: the firewall resolves the address elsewhere.
2552 + $chain = Vigilante_IP_Utils::trusted_forwarded_for();
2553 +
2554 + if ( '' === $chain ) {
2555 + return array();
2556 + }
2557 +
2558 + $public = array();
2559 +
2560 + foreach ( explode( ',', $chain ) as $entry ) {
2561 + $address = Vigilante_IP_Utils::unmap_ipv4( trim( $entry ) );
2562 +
2563 + if ( filter_var( $address, FILTER_VALIDATE_IP ) && ! Vigilante_IP_Utils::is_own_network( $address ) ) {
2564 + $public[] = $address;
2565 + }
2566 + }
2567 +
2568 + if ( count( $public ) < 2 ) {
2569 + return array();
2570 + }
2571 +
2572 + return array(
2573 + 'now' => Vigilante_IP_Utils::client_from_chain( $chain ),
2574 + 'before' => $public[0],
2575 + );
2576 + }
2577 +
2578 + /**
2579 + * Whether the user tools of this screen are out of reach for this user
2580 + *
2581 + * @return bool
2582 + */
2583 + private function user_actions_locked() {
2584 + // On a single site edit_user maps to edit_users, which a custom role with
2585 + // manage_options may lack: since 2.11.8 approving and rejecting a pending
2586 + // registration ask for it, so the buttons have to say so there too.
2587 + return is_multisite() ? ! current_user_can( 'manage_network_users' ) : ! current_user_can( 'edit_users' );
2588 + }
2589 +
2590 + /**
2591 + * Print the notice for user tools that cannot be used from this site
2592 + *
2593 + * @since 2.10.4
2594 + */
2595 + private function render_user_actions_notice() {
2596 + if ( ! $this->user_actions_locked() ) {
2597 + return;
2598 + }
2599 + ?>
2600 + <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;">
2601 + <?php if ( is_multisite() ) : ?>
2602 + <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>
2603 + <?php else : ?>
2604 + <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>
2605 + <?php endif; ?>
2606 + </div>
2607 + <?php
2608 + }
2609 +
2610 + /**
2611 + * Approving a change to the shared config files needs the network
2612 + *
2613 + * Since 2.11.3 the handler behind the Approve button asks for
2614 + * manage_network_options, because the two files it approves, wp-config.php
2615 + * and the root .htaccess, belong to the installation, and so does the
2616 + * record of them. The button, though, went on being painted for everybody,
2617 + * so the administrator of a subsite saw the warning, saw the button,
2618 + * pressed it and got "Permission denied" with no explanation. That is
2619 + * exactly what user_actions_locked() above exists to avoid, one release
2620 + * later and one screen over. Flagged by @calzbert.
2621 + *
2622 + * @since 2.11.4
2623 + * @return bool
2624 + */
2625 + private function critical_approval_locked() {
2626 + return is_multisite() && ! current_user_can( 'manage_network_options' );
2627 + }
2628 +
2629 + /**
2630 + * The line that replaces the Approve button where it cannot be used
2631 + *
2632 + * @since 2.11.4
2633 + * @return string
2634 + */
2635 + private function critical_approval_notice() {
2636 + 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' );
2637 + }
2638 +
2639 + /**
2018 2640 * Check if module is disabled and render warning
2019 2641 *
2020 2642 * @param string $module_key Module key.
2021 2643 * @return bool True if disabled.
@@ -2076,8 +2698,22 @@
2076 2698 $categories = isset( $last_scan['categories'] ) && is_array( $last_scan['categories'] ) ? $last_scan['categories'] : array();
2077 2699 $weekly_enabled = ! isset( $analyzer_settings['weekly_scan_enabled'] ) || ! empty( $analyzer_settings['weekly_scan_enabled'] );
2078 2700 $email_enabled = ! empty( $analyzer_settings['email_on_regression'] );
2079 2701
2702 + /*
2703 + * Self-protection caps the score, and it does so here and not only in
2704 + * the stored report: the report is reused until the next Security
2705 + * Check, so tampering found after it ran would otherwise be shown next
2706 + * to the score the site earned before it happened.
2707 + */
2708 + $self_summary = $this->self_integrity_summary();
2709 + $self_capped = in_array( $self_summary['tone'], array( 'critical', 'off' ), true )
2710 + || ( 'self_integrity' === ( isset( $last_scan['capped_by'] ) ? $last_scan['capped_by'] : '' ) );
2711 + if ( $self_capped && $has_data && $score > Vigilante_Security_Analyzer::SCORE_CAP_ON_TAMPER ) {
2712 + $score = Vigilante_Security_Analyzer::SCORE_CAP_ON_TAMPER;
2713 + $grade = 'E';
2714 + }
2715 +
2080 2716 $quality = self::analyzer_quality_tag( $score );
2081 2717 ?>
2082 2718 <div class="vigilante-analyzer" id="vigilante-analyzer"
2083 2719 data-has-data="<?php echo $has_data ? '1' : '0'; ?>">
@@ -2087,9 +2723,9 @@
2087 2723 <span class="dashicons dashicons-shield-alt" aria-hidden="true"></span>
2088 2724 <?php esc_html_e( 'Security Check', 'vigilante' ); ?>
2089 2725 </h2>
2090 2726 <p class="description">
2091 - <?php esc_html_e( 'On-demand audit of what an attacker would see right now, plus 13 internal checks impossible from the outside.', 'vigilante' ); ?>
2727 + <?php esc_html_e( 'On-demand audit of what an attacker would see right now, plus internal checks impossible from the outside.', 'vigilante' ); ?>
2092 2728 </p>
2093 2729 <p class="vigilante-analyzer-last-scan" data-role="ran-at">
2094 2730 <span class="dashicons dashicons-clock" aria-hidden="true"></span>
2095 2731 <?php echo esc_html( $ran_at_human ); ?>
@@ -2102,8 +2738,19 @@
2102 2738 </button>
2103 2739 </div>
2104 2740 </div>
2105 2741
2742 + <?php if ( $self_capped ) : ?>
2743 + <p class="vigilante-analyzer-capped">
2744 + <span class="dashicons dashicons-shield" aria-hidden="true"></span>
2745 + <strong><?php esc_html_e( 'This score is not reliable right now.', 'vigilante' ); ?></strong>
2746 + <?php esc_html_e( 'Vigilant own files have been changed, and every other result on this page is produced by that same code. The score is held at the bottom of the scale until the files verify clean again.', 'vigilante' ); ?>
2747 + <a href="<?php echo esc_url( admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' ) ); ?>">
2748 + <?php esc_html_e( 'See what to do', 'vigilante' ); ?>
2749 + </a>
2750 + </p>
2751 + <?php endif; ?>
2752 +
2106 2753 <div class="vigilante-analyzer-summary">
2107 2754 <div class="vigilante-analyzer-score-card">
2108 2755 <?php if ( $has_data && $grade ) : ?>
2109 2756 <div class="vigilante-score-circle vigilante-grade-<?php echo esc_attr( strtolower( $grade ) ); ?>">
@@ -2477,8 +3124,58 @@
2477 3124
2478 3125 /**
2479 3126 * Render dashboard tab
2480 3127 */
3128 + /**
3129 + * One line strip at the top of the Dashboard tab: the state of Vigilant own
3130 + * files, above the Configuration Score and the Security Check, because
3131 + * nothing else on this screen means much while it is red.
3132 + */
3133 + private function render_self_protection_strip() {
3134 + $summary = $this->self_integrity_summary();
3135 + $tone = $summary['tone'];
3136 + $state = $summary['state'];
3137 + $count = count( $summary['findings'] );
3138 + $link = admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' );
3139 + ?>
3140 + <div class="vigilante-self-strip vigilante-self-strip--<?php echo esc_attr( $tone ); ?>">
3141 + <span class="dashicons dashicons-shield" aria-hidden="true"></span>
3142 + <strong><?php esc_html_e( 'Vigilant self-protection', 'vigilante' ); ?></strong>
3143 + <span class="vigilante-self-strip-state"><?php echo esc_html( $this->self_integrity_headline( $tone, $state ) ); ?></span>
3144 + <?php if ( $count > 0 ) : ?>
3145 + <span class="vigilante-self-strip-count">
3146 + <?php
3147 + printf(
3148 + /* translators: %d: number of findings about Vigilant own files */
3149 + esc_html( _n( '%d finding', '%d findings', $count, 'vigilante' ) ),
3150 + (int) $count
3151 + );
3152 + ?>
3153 + </span>
3154 + <?php elseif ( ! empty( $state['last_check'] ) && 'off' !== $tone ) : ?>
3155 + <span class="vigilante-self-strip-count">
3156 + <?php
3157 + printf(
3158 + /* translators: %s: human time difference, like "2 hours" */
3159 + esc_html__( 'checked %s ago', 'vigilante' ),
3160 + esc_html( human_time_diff( (int) $state['last_check'], time() ) )
3161 + );
3162 + ?>
3163 + </span>
3164 + <?php endif; ?>
3165 + <a href="<?php echo esc_url( $link ); ?>">
3166 + <?php
3167 + if ( in_array( $tone, array( 'critical', 'off', 'warning' ), true ) ) {
3168 + esc_html_e( 'See what to do', 'vigilante' );
3169 + } else {
3170 + esc_html_e( 'See details', 'vigilante' );
3171 + }
3172 + ?>
3173 + </a>
3174 + </div>
3175 + <?php
3176 + }
3177 +
2481 3178 private function render_tab_dashboard() {
2482 3179 $options = $this->settings->get_all_options();
2483 3180 $module_labels = $this->settings->get_module_labels();
2484 3181 $module_descriptions = $this->settings->get_module_descriptions();
@@ -2498,8 +3195,9 @@
2498 3195 $analyzer_categories_def = Vigilante_Security_Analyzer::get_categories();
2499 3196 $analyzer_settings = isset( $options['security_analyzer'] ) ? $options['security_analyzer'] : array();
2500 3197 ?>
2501 3198 <div class="vigilante-dashboard">
3199 + <?php $this->render_self_protection_strip(); ?>
2502 3200 <div class="vigilante-status-card">
2503 3201 <h2><?php esc_html_e( 'Configuration Score', 'vigilante' ); ?></h2>
2504 3202 <p class="vigilante-score-kind description">
2505 3203 <?php esc_html_e( 'How well Vigilante is configured right now. Pair it with the Security Check below to see the real-world result.', 'vigilante' ); ?>
@@ -2559,14 +3257,15 @@
2559 3257
2560 3258 <?php $this->render_analyzer_widget( $analyzer_last_scan, $analyzer_history, $analyzer_categories_def, $analyzer_settings ); ?>
2561 3259
2562 3260 <div class="vigilante-modules-grid">
2563 - <h2><?php esc_html_e( 'Security Modules', 'vigilante' ); ?></h2>
3261 + <h2 id="vigilante-section-dashboard-modules"><?php esc_html_e( 'Security Modules', 'vigilante' ); ?></h2>
2564 3262 <p class="description"><?php esc_html_e( 'Enable or disable security modules. Each module controls a tab with detailed settings.', 'vigilante' ); ?></p>
2565 3263 <div class="vigilante-modules-list">
2566 3264 <?php foreach ( $options['modules'] as $module => $enabled ) :
2567 3265 $label = isset( $module_labels[ $module ] ) ? $module_labels[ $module ] : ucwords( str_replace( '_', ' ', $module ) );
2568 3266 $description = isset( $module_descriptions[ $module ] ) ? $module_descriptions[ $module ] : '';
3267 + $vg_module_locked = $this->main_site_files_locked() && in_array( $module, Vigilante_Settings::get_main_site_file_settings()['modules'], true );
2569 3268 ?>
2570 3269 <div class="vigilante-module-item <?php echo $enabled ? 'enabled' : 'disabled'; ?>">
2571 3270 <div class="vigilante-module-header">
2572 3271 <span class="vigilante-module-status"></span>
@@ -2579,8 +3278,9 @@
2579 3278 <input type="checkbox"
2580 3279 name="modules[<?php echo esc_attr( $module ); ?>]"
2581 3280 value="1"
2582 3281 <?php checked( $enabled ); ?>
3282 + <?php disabled( $vg_module_locked ); ?>
2583 3283 aria-label="<?php echo esc_attr( $toggle_label ); ?>"
2584 3284 data-module="<?php echo esc_attr( $module ); ?>">
2585 3285 <span class="vigilante-toggle-slider"></span>
2586 3286 </label>
@@ -2587,8 +3287,11 @@
2587 3287 </div>
2588 3288 <?php if ( $description ) : ?>
2589 3289 <p class="vigilante-module-desc"><?php echo esc_html( $description ); ?></p>
2590 3290 <?php endif; ?>
3291 + <?php if ( $vg_module_locked ) : ?>
3292 + <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>
3293 + <?php endif; ?>
2591 3294 </div>
2592 3295 <?php endforeach; ?>
2593 3296 </div>
2594 3297 </div>
@@ -2620,9 +3323,9 @@
2620 3323 $ua_remaining_hours = floor( $ua_remaining / 3600 );
2621 3324 $ua_remaining_mins = floor( ( $ua_remaining % 3600 ) / 60 );
2622 3325 ?>
2623 3326 <div class="vigilante-preset-card vigilante-under-attack-card <?php echo $ua_active ? 'vigilante-under-attack-active' : ''; ?>">
2624 - <h3>
3327 + <h3 id="vigilante-section-dashboard-under-attack">
2625 3328 <span class="dashicons dashicons-shield"></span>
2626 3329 <?php esc_html_e( 'Under Attack', 'vigilante' ); ?>
2627 3330 </h3>
2628 3331 <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 +3681,21 @@
2978 3681 <?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 3682 </p>
2980 3683 </div>
2981 3684
3685 + <?php $vg_main_locked = $this->main_site_files_locked(); ?>
3686 + <?php if ( $vg_main_locked ) : ?>
3687 + <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;">
3688 + <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>
3689 + </div>
3690 + <?php endif; ?>
3691 +
2982 3692 <table class="form-table">
2983 3693 <tr>
2984 3694 <th scope="row"><?php esc_html_e( 'Block Bad Query Strings', 'vigilante' ); ?></th>
2985 3695 <td>
2986 3696 <label>
2987 - <input type="checkbox" name="firewall[block_bad_query_strings]" value="1" <?php checked( ! empty( $options['block_bad_query_strings'] ) ); ?>>
3697 + <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 3698 <?php esc_html_e( 'Block malicious query string patterns', 'vigilante' ); ?>
2989 3699 </label>
2990 3700 </td>
2991 3701 </tr>
@@ -3028,9 +3738,9 @@
3028 3738 <tr>
3029 3739 <th scope="row"><?php esc_html_e( 'Block Bad Bots', 'vigilante' ); ?></th>
3030 3740 <td>
3031 3741 <label>
3032 - <input type="checkbox" name="firewall[block_bad_bots]" value="1" <?php checked( ! empty( $options['block_bad_bots'] ) ); ?>>
3742 + <input type="checkbox" name="firewall[block_bad_bots]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( ! empty( $options['block_bad_bots'] ) ); ?>>
3033 3743 <?php esc_html_e( 'Block known malicious bots and scanners', 'vigilante' ); ?>
3034 3744 </label>
3035 3745 </td>
3036 3746 </tr>
@@ -3144,8 +3854,29 @@
3144 3854 </table>
3145 3855 </div>
3146 3856 <?php endif; ?>
3147 3857
3858 + <?php
3859 + // Since 2.11.8 X-Forwarded-For is read from its end, where the proxy
3860 + // writes. The administrator's own request shows whether that end is
3861 + // a CDN or a balancer for everybody here. Cross review of 2.11.8.
3862 + $xff_readings = $this->forwarded_chain_readings();
3863 + if ( $xff_readings ) :
3864 + ?>
3865 + <div id="vigilante-xff-chain-notice" class="notice notice-warning inline" style="margin:10px 0 16px;padding:8px 12px;">
3866 + <p style="margin:0;">
3867 + <?php
3868 + printf(
3869 + /* translators: 1: last address in the header, the one Vigilant reads, 2: first address in the header, which a visitor can write */
3870 + esc_html__( 'Your own request reaches the site with more than one public address in X-Forwarded-For. Vigilant reads the last one, %1$s, which is the one your proxy added, and not the first one, %2$s, which a visitor can write. If %1$s belongs to a CDN or a load balancer rather than to you, every visitor shares it for rate limiting, login lockouts and the IP lists: choose the header of that CDN in Visitor IP detection, such as CF-Connecting-IP for Cloudflare.', 'vigilante' ),
3871 + esc_html( $xff_readings['now'] ),
3872 + esc_html( $xff_readings['before'] )
3873 + );
3874 + ?>
3875 + </p>
3876 + </div>
3877 + <?php endif; ?>
3878 +
3148 3879 <h3><?php esc_html_e( 'IP Lists', 'vigilante' ); ?></h3>
3149 3880 <p class="description">
3150 3881 <?php
3151 3882 printf(
@@ -3159,9 +3890,9 @@
3159 3890 <tr>
3160 3891 <th scope="row"><label for="vigilante-f-firewall-trusted-proxy-header"><?php esc_html_e( 'Visitor IP detection', 'vigilante' ); ?></label></th>
3161 3892 <td>
3162 3893 <?php $proxy_header = $options['trusted_proxy_header'] ?? ''; ?>
3163 - <select id="vigilante-f-firewall-trusted-proxy-header" name="firewall[trusted_proxy_header]">
3894 + <select id="vigilante-f-firewall-trusted-proxy-header" name="firewall[trusted_proxy_header]" <?php disabled( $vg_main_locked ); ?>>
3164 3895 <option value="" <?php selected( $proxy_header, '' ); ?>><?php esc_html_e( 'Direct connection, only REMOTE_ADDR (recommended)', 'vigilante' ); ?></option>
3165 3896 <option value="cf-connecting-ip" <?php selected( $proxy_header, 'cf-connecting-ip' ); ?>><?php esc_html_e( 'Behind Cloudflare (CF-Connecting-IP)', 'vigilante' ); ?></option>
3166 3897 <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 3898 <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 +3902,23 @@
3171 3902 </p>
3172 3903 </td>
3173 3904 </tr>
3174 3905 <tr>
3906 + <th scope="row"><label for="vigilante-f-firewall-trusted-proxies"><?php esc_html_e( 'Trusted proxy IPs', 'vigilante' ); ?></label></th>
3907 + <td>
3908 + <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>
3909 + <p class="description">
3910 + <?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' ); ?>
3911 + <?php if ( in_array( $proxy_header, array( 'x-forwarded-for', 'x-real-ip' ), true ) && empty( $options['trusted_proxies'] ) ) : ?>
3912 + <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>
3913 + <?php endif; ?>
3914 + </p>
3915 + </td>
3916 + </tr>
3917 + <tr>
3175 3918 <th scope="row"><label for="vigilante-f-firewall-ip-whitelist"><?php esc_html_e( 'IP Whitelist', 'vigilante' ); ?></label></th>
3176 3919 <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>
3920 + <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 3921 <p class="description">
3179 3922 <?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 3923 <br>
3181 3924 <?php
@@ -3214,9 +3957,9 @@
3214 3957 <table class="form-table">
3215 3958 <tr>
3216 3959 <th scope="row"><label for="vigilante-f-firewall-ua-whitelist"><?php esc_html_e( 'User-Agent Whitelist', 'vigilante' ); ?></label></th>
3217 3960 <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>
3961 + <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 3962 <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 3963 </td>
3221 3964 </tr>
3222 3965 <tr>
@@ -3504,9 +4247,9 @@
3504 4247 $two_factor = $options['two_factor'] ?? array();
3505 4248 $two_factor_enabled = ! empty( $two_factor['enabled'] );
3506 4249 ?>
3507 4250 <div class="vigilante-settings-section vigilante-lockout-section">
3508 - <h2><?php esc_html_e( 'Login Protection Status', 'vigilante' ); ?></h2>
4251 + <h2 id="vigilante-section-login-status"><?php esc_html_e( 'Login Protection Status', 'vigilante' ); ?></h2>
3509 4252
3510 4253 <table class="form-table">
3511 4254 <tr>
3512 4255 <th scope="row"><?php esc_html_e( 'Current settings', 'vigilante' ); ?></th>
@@ -3666,9 +4409,9 @@
3666 4409 $excluded = $two_factor['excluded_users'] ?? array();
3667 4410 $method = $two_factor['method'] ?? 'email';
3668 4411 $grace_days = $two_factor['grace_period_days'] ?? 3;
3669 4412 ?>
3670 - <h3>
4413 + <h3 id="vigilante-section-login-2fa">
3671 4414 <?php esc_html_e( 'Two-Factor Authentication (2FA)', 'vigilante' ); ?>
3672 4415 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
3673 4416 <span class="vigilante-method-badge database"><?php esc_html_e( 'Database', 'vigilante' ); ?></span>
3674 4417 </h3>
@@ -3714,8 +4457,9 @@
3714 4457 <tr>
3715 4458 <th scope="row"><?php esc_html_e( 'Enforce for roles', 'vigilante' ); ?></th>
3716 4459 <td>
3717 4460 <div class="vigilante-2fa-roles">
4461 + <?php $this->list_sentinel( 'login_security[two_factor][enforced_roles]' ); ?>
3718 4462 <?php foreach ( $all_roles as $role_slug => $role_data ) :
3719 4463 $user_count = count( get_users( array( 'role' => $role_slug, 'fields' => 'ID' ) ) );
3720 4464 ?>
3721 4465 <label>
@@ -3743,8 +4487,9 @@
3743 4487 autocomplete="off">
3744 4488 <div class="vigilante-2fa-search-results"></div>
3745 4489 </div>
3746 4490 <div class="vigilante-2fa-excluded-users">
4491 + <?php $this->list_sentinel( 'login_security[two_factor][excluded_users]' ); ?>
3747 4492 <?php
3748 4493 foreach ( $excluded as $user_id ) :
3749 4494 $user = get_user_by( 'ID', $user_id );
3750 4495 if ( ! $user ) continue;
@@ -3925,9 +4670,9 @@
3925 4670 ?>
3926 4671 <div class="vigilante-settings-section" id="vigilante-headers-recovery">
3927 4672 <h2><?php esc_html_e( 'Recover your previous header settings', 'vigilante' ); ?></h2>
3928 4673 <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' ); ?>
4674 + <?php esc_html_e( 'An earlier update reset this tab to factory values: the migration replaced the whole section instead of merging into it. Your server kept sending the right headers, because the .htaccess had not been rewritten yet, so Vigilant saved a copy of that file before touching it. These are the settings it found in that copy.', 'vigilante' ); ?>
3930 4675 </p>
3931 4676 <?php if ( $taken ) : ?>
3932 4677 <p class="description">
3933 4678 <?php
@@ -4039,9 +4784,9 @@
4039 4784 </td>
4040 4785 </tr>
4041 4786 </table>
4042 4787
4043 - <h3><?php esc_html_e( 'Content Security Policy', 'vigilante' ); ?></h3>
4788 + <h3 id="vigilante-section-headers-csp"><?php esc_html_e( 'Content Security Policy', 'vigilante' ); ?></h3>
4044 4789 <table class="form-table">
4045 4790 <tr>
4046 4791 <th scope="row"><?php esc_html_e( 'Enable CSP', 'vigilante' ); ?></th>
4047 4792 <td>
@@ -4048,8 +4793,9 @@
4048 4793 <label>
4049 4794 <input type="checkbox" name="security_headers[csp][enabled]" value="1" <?php checked( ! empty( $options['csp']['enabled'] ) ); ?>>
4050 4795 <?php esc_html_e( 'Enable Content Security Policy', 'vigilante' ); ?>
4051 4796 </label>
4797 + <p class="description"><?php esc_html_e( 'The default policy includes upgrade-insecure-requests, which asks browsers to request every http:// resource over https. Vigilant writes it only when the site address starts with https://, because on a site served over http it would stop every stylesheet, script and image from loading. If your site is served over https through a proxy or CDN that terminates TLS, set the site address to https:// in Settings > General; the rules follow the next time this tab is saved or Vigilant is updated.', 'vigilante' ); ?></p>
4052 4798 </td>
4053 4799 </tr>
4054 4800 <tr>
4055 4801 <th scope="row"><?php esc_html_e( 'Report Only Mode', 'vigilante' ); ?></th>
@@ -4061,9 +4807,9 @@
4061 4807 </td>
4062 4808 </tr>
4063 4809 </table>
4064 4810
4065 - <h3><?php esc_html_e( 'HTTPS', 'vigilante' ); ?></h3>
4811 + <h3 id="vigilante-section-headers-force-https"><?php esc_html_e( 'HTTPS', 'vigilante' ); ?></h3>
4066 4812 <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 4813 <table class="form-table">
4068 4814 <tr>
4069 4815 <th scope="row"><?php esc_html_e( 'Redirect HTTP to HTTPS', 'vigilante' ); ?></th>
@@ -4106,9 +4852,9 @@
4106 4852 </td>
4107 4853 </tr>
4108 4854 </table>
4109 4855
4110 - <h3><?php esc_html_e( 'HSTS (HTTP Strict Transport Security)', 'vigilante' ); ?></h3>
4856 + <h3 id="vigilante-section-headers-hsts"><?php esc_html_e( 'HSTS (HTTP Strict Transport Security)', 'vigilante' ); ?></h3>
4111 4857 <?php $vig_home_https = ( 0 === strpos( (string) get_option( 'home' ), 'https://' ) ); ?>
4112 4858 <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 4859 <?php if ( ! $vig_home_https ) : ?>
4114 4860 <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 +4895,9 @@
4149 4895 </td>
4150 4896 </tr>
4151 4897 </table>
4152 4898
4153 - <h3><?php esc_html_e( 'Server Identity', 'vigilante' ); ?></h3>
4899 + <h3 id="vigilante-section-headers-fingerprint"><?php esc_html_e( 'Server Identity', 'vigilante' ); ?></h3>
4154 4900 <p class="description"><?php esc_html_e( 'Hide identifying information that servers expose in responses.', 'vigilante' ); ?></p>
4155 4901 <table class="form-table">
4156 4902 <tr>
4157 4903 <th scope="row"><?php esc_html_e( 'Server Signature', 'vigilante' ); ?></th>
@@ -4400,8 +5146,9 @@
4400 5146 </tr>
4401 5147 <tr>
4402 5148 <th scope="row"><?php esc_html_e( 'Apply Password Rules To', 'vigilante' ); ?></th>
4403 5149 <td>
5150 + <?php $this->list_sentinel( 'user_security[password_policy][affected_roles]' ); ?>
4404 5151 <?php foreach ( wp_roles()->get_names() as $role_slug => $role_name ) : ?>
4405 5152 <label style="display:block;margin-bottom:5px;">
4406 5153 <input type="checkbox" name="user_security[password_policy][affected_roles][]" value="<?php echo esc_attr( $role_slug ); ?>" <?php checked( empty( $pw_policy_roles ) || in_array( $role_slug, $pw_policy_roles, true ) ); ?>>
4407 5154 <?php echo esc_html( translate_user_role( $role_name ) ); ?>
@@ -4536,8 +5283,16 @@
4536 5283 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
4537 5284 </h2>
4538 5285 <p><?php esc_html_e( 'Limit the number of simultaneous sessions per user.', 'vigilante' ); ?></p>
4539 5286
5287 + <?php if ( Vigilante_User_Security::session_limit_is_network_wide() ) : ?>
5288 + <div class="notice notice-warning inline">
5289 + <p>
5290 + <?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' ); ?>
5291 + </p>
5292 + </div>
5293 + <?php endif; ?>
5294 +
4540 5295 <table class="form-table">
4541 5296 <tr>
4542 5297 <th scope="row"><?php esc_html_e( 'Enable Session Limits', 'vigilante' ); ?></th>
4543 5298 <td>
@@ -4633,8 +5388,9 @@
4633 5388 <td>
4634 5389 <?php
4635 5390 $affected_roles = $password_exp['affected_roles'] ?? array( 'administrator', 'editor' );
4636 5391 $all_roles = wp_roles()->get_names();
5392 + $this->list_sentinel( 'user_security[password_expiration][affected_roles]' );
4637 5393 foreach ( $all_roles as $role_slug => $role_name ) :
4638 5394 ?>
4639 5395 <label style="display: block; margin-bottom: 5px;">
4640 5396 <input type="checkbox" name="user_security[password_expiration][affected_roles][]" value="<?php echo esc_attr( $role_slug ); ?>" <?php checked( in_array( $role_slug, $affected_roles, true ) ); ?>>
@@ -4656,8 +5412,9 @@
4656 5412 autocomplete="off">
4657 5413 <div class="vigilante-pwexp-search-results"></div>
4658 5414 </div>
4659 5415 <div class="vigilante-pwexp-excluded-users">
5416 + <?php $this->list_sentinel( 'user_security[password_expiration][excluded_users]' ); ?>
4660 5417 <?php
4661 5418 foreach ( $pwexp_excluded as $pwexp_excluded_id ) :
4662 5419 $excluded_user = get_user_by( 'ID', $pwexp_excluded_id );
4663 5420 if ( ! $excluded_user ) {
@@ -4740,8 +5497,11 @@
4740 5497 <h2 class="vigilante-tools-header">
4741 5498 <?php esc_html_e( 'User security tools', 'vigilante' ); ?>
4742 5499 </h2>
4743 5500
5501 + <?php $this->render_user_actions_notice(); ?>
5502 + <?php if ( ! $this->user_actions_locked() ) : ?>
5503 +
4744 5504 <!-- Force Password Reset -->
4745 5505 <div class="vigilante-tool-box">
4746 5506 <h3><?php esc_html_e( 'Force password reset', 'vigilante' ); ?></h3>
4747 5507 <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 +5634,20 @@
4874 5634 </div>
4875 5635
4876 5636 <!-- Pending Registrations -->
4877 5637 <?php
4878 - $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log );
5638 + // Enforcement-only: this instance exists to read the queue, and the
5639 + // flag keeps it from registering the module's own hooks a second
5640 + // time. It is not inert, and saying it was would be a false comment:
5641 + // init_enforcement_hooks() does add its three filters again, on top
5642 + // of the ones already registered. They are idempotent (the same
5643 + // methods of an equivalent instance, deciding on the same user meta),
5644 + // so running them twice in an admin request changes nothing, which is
5645 + // why this is accepted rather than worked around.
5646 + $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log, true );
4879 5647 $pending_users = $user_security->get_pending_users();
4880 5648 ?>
4881 - <div class="vigilante-tool-box vigilante-pending-users-section">
5649 + <div id="vigilante-section-users-pending" class="vigilante-tool-box vigilante-pending-users-section">
4882 5650 <h3>
4883 5651 <?php esc_html_e( 'Pending registrations', 'vigilante' ); ?>
4884 5652 <?php if ( count( $pending_users ) > 0 ) : ?>
4885 5653 <span class="vigilante-badge vigilante-badge-warning"><?php echo esc_html( count( $pending_users ) ); ?></span>
@@ -4885,9 +5653,20 @@
4885 5653 <span class="vigilante-badge vigilante-badge-warning"><?php echo esc_html( count( $pending_users ) ); ?></span>
4886 5654 <?php endif; ?>
4887 5655 </h3>
4888 5656
4889 - <?php if ( empty( $registration['enabled'] ) ) : ?>
5657 + <?php
5658 + /*
5659 + * The queue is shown whenever there is somebody in it, even with
5660 + * the feature off. Since 2.11.10 an account already waiting stays
5661 + * blocked when the feature is switched off, which is the point:
5662 + * turning a setting off must not quietly let in people an
5663 + * administrator decided not to approve. But hiding the table then
5664 + * left them locked out with no button anywhere to approve or
5665 + * reject them. Found by the cross review of 2.11.10.
5666 + */
5667 + ?>
5668 + <?php if ( empty( $registration['enabled'] ) && empty( $pending_users ) ) : ?>
4890 5669 <p class="description">
4891 5670 <span class="dashicons dashicons-info" style="color: #72aee6;"></span>
4892 5671 <?php esc_html_e( 'Registration approval is disabled. Enable it in the settings above to require manual approval for new users.', 'vigilante' ); ?>
4893 5672 </p>
@@ -4896,8 +5675,9 @@
4896 5675 <span class="dashicons dashicons-yes-alt"></span>
4897 5676 <p><?php esc_html_e( 'No pending registrations.', 'vigilante' ); ?></p>
4898 5677 </div>
4899 5678 <?php else : ?>
5679 + <?php $this->render_user_actions_notice(); ?>
4900 5680 <table class="wp-list-table widefat fixed striped vigilante-pending-users-table">
4901 5681 <thead>
4902 5682 <tr>
4903 5683 <th><?php esc_html_e( 'User', 'vigilante' ); ?></th>
@@ -4907,9 +5687,9 @@
4907 5687 </tr>
4908 5688 </thead>
4909 5689 <tbody>
4910 5690 <?php foreach ( $pending_users as $pending_user ) :
4911 - $pending_since = get_user_meta( $pending_user->ID, 'vigilante_pending_since', true );
5691 + $pending_since = get_user_meta( $pending_user->ID, Vigilante_User_Security::site_user_meta_key( 'vigilante_pending_since' ), true );
4912 5692 ?>
4913 5693 <tr data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>">
4914 5694 <td>
4915 5695 <?php echo get_avatar( $pending_user->ID, 32 ); ?>
@@ -4926,12 +5706,12 @@
4926 5706 }
4927 5707 ?>
4928 5708 </td>
4929 5709 <td>
4930 - <button type="button" class="button button-small vigilante-approve-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>">
5710 + <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 5711 <?php esc_html_e( 'Approve', 'vigilante' ); ?>
4932 5712 </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;">
5713 + <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 5714 <?php esc_html_e( 'Reject', 'vigilante' ); ?>
4935 5715 </button>
4936 5716 </td>
4937 5717 </tr>
@@ -5053,8 +5833,10 @@
5053 5833 </button>
5054 5834 </p>
5055 5835 </div>
5056 5836 </div>
5837 +
5838 + <?php endif; ?>
5057 5839 </div>
5058 5840 <?php
5059 5841 }
5060 5842
@@ -5768,8 +6550,9 @@
5768 6550 'user' => (string) ( $log->user_login ?? '' ),
5769 6551 'ip' => $ip_val,
5770 6552 'user_agent' => $ua_val,
5771 6553 'request_method' => (string) $request_method,
6554 + 'request_uri' => Vigilante_Activity_Log::extract_request_uri( $log->extra_data ?? '' ),
5772 6555 'date' => (string) ( $log->created_at ?? '' ),
5773 6556 'severity' => (string) ( $log->severity ?? 'info' ),
5774 6557 'is_ip_whitelisted' => ( '' !== $ip_val && in_array( $ip_val, $ip_whitelist, true ) ),
5775 6558 'is_ip_blacklisted' => ( '' !== $ip_val && in_array( $ip_val, $ip_blacklist, true ) ),
@@ -5775,8 +6558,19 @@
5775 6558 'is_ip_blacklisted' => ( '' !== $ip_val && in_array( $ip_val, $ip_blacklist, true ) ),
5776 6559 'is_ua_whitelisted' => ( '' !== $ua_val && in_array( $ua_val, $ua_whitelist, true ) ),
5777 6560 'is_ua_blacklisted' => ( '' !== $ua_val && in_array( $ua_val, $ua_blacklist, true ) ),
5778 6561 );
6562 + // Self-protection entries carry what happened, what
6563 + // it means and what to do, from the same catalogue
6564 + // the File Integrity box uses.
6565 + $vg_self_event = Vigilante_Self_Integrity_Guidance::for_log_event(
6566 + (string) ( $log->event_action ?? '' ),
6567 + $log->extra_data ?? '',
6568 + (string) ( $log->severity ?? 'info' )
6569 + );
6570 + if ( null !== $vg_self_event ) {
6571 + $details['self'] = $vg_self_event;
6572 + }
5779 6573 $display_type = isset( $type_labels[ $log->event_type ] ) ? $type_labels[ $log->event_type ] : $log->event_type;
5780 6574 $display_severity = isset( $severity_labels[ $log->severity ] ) ? $severity_labels[ $log->severity ] : $log->severity;
5781 6575 ?>
5782 6576 <tr class="vigilante-severity-<?php echo esc_attr( $log->severity ); ?>">
@@ -5819,15 +6613,439 @@
5819 6613
5820 6614 /**
5821 6615 * Render File Integrity tab
5822 6616 */
6617 + /**
6618 + * Findings of the self-check grouped by the case that explains them, worst
6619 + * first: ten modified files are one case with ten paths, not ten copies of
6620 + * the same explanation.
6621 + *
6622 + * @param array $findings Findings from the state.
6623 + * @return array
6624 + */
6625 + private function self_findings_by_case( $findings ) {
6626 + $groups = array();
6627 + foreach ( (array) $findings as $finding ) {
6628 + if ( ! is_array( $finding ) || 'info' === ( $finding['severity'] ?? '' ) ) {
6629 + continue;
6630 + }
6631 + $guidance = Vigilante_Self_Integrity_Guidance::for_finding( $finding );
6632 + $key = $guidance['key'];
6633 + if ( ! isset( $groups[ $key ] ) ) {
6634 + $guidance['files'] = array();
6635 + $guidance['severity'] = 'warning';
6636 + $groups[ $key ] = $guidance;
6637 + }
6638 + $file = isset( $finding['file'] ) ? (string) $finding['file'] : '';
6639 + if ( '' !== $file && ! in_array( $file, $groups[ $key ]['files'], true ) ) {
6640 + $groups[ $key ]['files'][] = $file;
6641 + }
6642 + if ( 'critical' === ( $finding['severity'] ?? '' ) ) {
6643 + $groups[ $key ]['severity'] = 'critical';
6644 + }
6645 + }
6646 + uasort(
6647 + $groups,
6648 + function ( $a, $b ) {
6649 + $rank = array( 'critical' => 0, 'warning' => 1 );
6650 + $ra = isset( $rank[ $a['severity'] ] ) ? $rank[ $a['severity'] ] : 2;
6651 + $rb = isset( $rank[ $b['severity'] ] ) ? $rank[ $b['severity'] ] : 2;
6652 + return $ra - $rb;
6653 + }
6654 + );
6655 + return $groups;
6656 + }
6657 +
6658 + /**
6659 + * Human label for the context that ran the last self-check.
6660 + *
6661 + * @param string $context Stored context.
6662 + * @return string
6663 + */
6664 + private function self_context_label( $context ) {
6665 + switch ( (string) $context ) {
6666 + case 'scan':
6667 + return __( 'during a file integrity scan', 'vigilante' );
6668 + case 'upgrader':
6669 + return __( 'right after updating Vigilant', 'vigilante' );
6670 + case 'version_change':
6671 + return __( 'after a version change made outside the updater', 'vigilante' );
6672 + case 'migration':
6673 + return __( 'while updating to this version', 'vigilante' );
6674 + case 'activation':
6675 + return __( 'when Vigilant was activated', 'vigilante' );
6676 + case 'watchdog':
6677 + return __( 'from the scheduled task watchdog', 'vigilante' );
6678 + }
6679 + return '';
6680 + }
6681 +
6682 + /**
6683 + * Short line for the badge of the box, the notices and the Dashboard strip.
6684 + *
6685 + * @param string $tone Tone from Vigilante_Self_Integrity::tone().
6686 + * @param array $state State.
6687 + * @return string
6688 + */
6689 + private function self_integrity_headline( $tone, $state ) {
6690 + $files = isset( $state['files_checked'] ) ? (int) $state['files_checked'] : 0;
6691 + $anchors = ( isset( $state['anchors'] ) && is_array( $state['anchors'] ) ) ? $state['anchors'] : array();
6692 + switch ( $tone ) {
6693 + case 'critical':
6694 + return __( 'Changes detected in Vigilant own files', 'vigilante' );
6695 + case 'warning':
6696 + return ( $files < 1 )
6697 + ? __( 'Vigilant could not check its own files', 'vigilante' )
6698 + : __( 'Vigilant self-protection needs your attention', 'vigilante' );
6699 + case 'off':
6700 + return __( 'Self-protection is switched off by code', 'vigilante' );
6701 + case 'none':
6702 + return __( 'Vigilant has not checked its own files yet', 'vigilante' );
6703 + }
6704 + return sprintf(
6705 + /* translators: 1: number of files verified, 2: number of references available, out of three */
6706 + __( 'Verified: %1$d files, %2$d of 3 references', 'vigilante' ),
6707 + $files,
6708 + count( array_filter( $anchors ) )
6709 + );
6710 + }
6711 +
6712 + /**
6713 + * Vigilant self-protection box: the first block of the File Integrity
6714 + * results, with its own colour by severity, what each finding means and
6715 + * how to fix it.
6716 + *
6717 + * It renders whether or not a scan has been stored, because the self-check
6718 + * also runs after every update and once a day: before 3.0.0 this lived in
6719 + * a line above the numeric cards of the last scan, where it was invisible
6720 + * and, without a stored scan, absent.
6721 + *
6722 + * @param array $fi_options File Integrity settings section.
6723 + */
6724 + /**
6725 + * Everything the screens need to say about self-protection, read once:
6726 + * the state, its findings grouped by case, and the tone that colours the
6727 + * box, the menu counter and the notices.
6728 + *
6729 + * @param array|null $fi_options File Integrity settings, read if not given.
6730 + * @return array { state, findings, groups, tone, enabled }
6731 + */
6732 + private function self_integrity_summary( $fi_options = null ) {
6733 + // Four to six screens ask for this in the same page load (menu, notice,
6734 + // box, strip, both scores). The option is cached by the core options
6735 + // layer, but the grouping and the tone are not: memoize per request.
6736 + static $cached = null;
6737 + if ( null !== $cached && ! is_array( $fi_options ) ) {
6738 + return $cached;
6739 + }
6740 + if ( ! is_array( $fi_options ) ) {
6741 + $fi_options = (array) $this->settings->get_section( 'file_integrity' );
6742 + }
6743 + $enabled = Vigilante_Self_Integrity::is_on();
6744 + $state = Vigilante_Self_Integrity::display_state();
6745 + $findings = Vigilante_Self_Integrity::state_findings( $state, $enabled );
6746 + $summary = array(
6747 + 'state' => $state,
6748 + 'findings' => $findings,
6749 + 'groups' => $this->self_findings_by_case( $findings ),
6750 + 'tone' => Vigilante_Self_Integrity::tone( $state, $enabled ),
6751 + 'enabled' => $enabled,
6752 + );
6753 + $cached = $summary;
6754 + return $summary;
6755 + }
6756 +
6757 + private function render_self_protection_box( $fi_options ) {
6758 + $summary = $this->self_integrity_summary( $fi_options );
6759 + $enabled = $summary['enabled'];
6760 + $state = $summary['state'];
6761 + $findings = $summary['findings'];
6762 + $groups = $summary['groups'];
6763 + $tone = $summary['tone'];
6764 + $total = isset( $state['last_findings_total'] ) ? (int) $state['last_findings_total'] : count( $findings );
6765 + $status = array(
6766 + 'status' => isset( $state['last_status'] ) ? (string) $state['last_status'] : '',
6767 + 'files' => isset( $state['files_checked'] ) ? (int) $state['files_checked'] : 0,
6768 + 'anchors' => ( isset( $state['anchors'] ) && is_array( $state['anchors'] ) ) ? $state['anchors'] : array(),
6769 + 'enabled' => $enabled,
6770 + 'has_run' => ! empty( $state['last_check'] ),
6771 + );
6772 +
6773 + /*
6774 + * Nothing to do, nothing to open: one line. A card with a big icon and
6775 + * folded sections for "everything is fine" is furniture, and furniture
6776 + * is what made the previous version of this invisible.
6777 + */
6778 + if ( empty( $groups ) ) {
6779 + $this->render_self_line( $tone, $state, $status );
6780 + return;
6781 + }
6782 +
6783 + $datetime_format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' );
6784 + $context_label = $this->self_context_label( isset( $state['last_context'] ) ? $state['last_context'] : '' );
6785 + $icon = ( 'critical' === $tone ) ? 'dashicons-shield' : 'dashicons-warning';
6786 + ?>
6787 + <div id="vigilante-section-fi-self" class="vigilante-settings-section vigilante-self-box vigilante-self-box--<?php echo esc_attr( $tone ); ?>">
6788 + <div class="vigilante-self-card">
6789 + <div class="vigilante-self-head">
6790 + <span class="vigilante-self-icon"><span class="dashicons <?php echo esc_attr( $icon ); ?>" aria-hidden="true"></span></span>
6791 + <div class="vigilante-self-head-text">
6792 + <h2><?php echo esc_html( $this->self_integrity_headline( $tone, $state ) ); ?></h2>
6793 + <p class="vigilante-self-meta">
6794 + <?php
6795 + printf(
6796 + /* translators: %s: date and time of the last self-check */
6797 + esc_html__( 'Checked on %s', 'vigilante' ),
6798 + esc_html( wp_date( $datetime_format, (int) $state['last_check'] ) )
6799 + );
6800 + if ( '' !== $context_label ) {
6801 + echo ', ' . esc_html( $context_label );
6802 + }
6803 + if ( $status['files'] > 0 ) {
6804 + echo ', ';
6805 + printf(
6806 + /* translators: 1: number of files checked, 2: number of references available, out of three */
6807 + esc_html__( '%1$d files against %2$d of 3 references', 'vigilante' ),
6808 + (int) $status['files'],
6809 + (int) count( array_filter( $status['anchors'] ) )
6810 + );
6811 + }
6812 + ?>
6813 + </p>
6814 + </div>
6815 + </div>
6816 +
6817 + <div class="vigilante-self-cases">
6818 + <?php
6819 + $first = true;
6820 + foreach ( $groups as $group ) {
6821 + $this->render_self_case( $group, $group['files'], $group['severity'], $first );
6822 + $first = false;
6823 + }
6824 +
6825 + if ( in_array( $tone, array( 'critical', 'warning' ), true ) ) {
6826 + $can_repair = class_exists( 'Vigilante_Self_Repair' ) && Vigilante_Self_Repair::can_repair();
6827 + if ( ! $can_repair ) {
6828 + if ( class_exists( 'Vigilante_Self_Repair' ) && ! Vigilante_Self_Repair::folder_is_the_distributed_one() ) {
6829 + $vg_reason = 'renamed';
6830 + } elseif ( ! wp_is_file_mod_allowed( 'capability_update_core' ) ) {
6831 + // With DISALLOW_FILE_MODS nobody can do it from the admin,
6832 + // not even a network administrator: telling a subsite admin
6833 + // to ask theirs would send them to someone equally blocked.
6834 + $vg_reason = 'no_caps';
6835 + } elseif ( is_multisite() && ! current_user_can( 'update_plugins' ) ) {
6836 + $vg_reason = 'network';
6837 + } else {
6838 + $vg_reason = 'no_caps';
6839 + }
6840 + $this->render_self_case(
6841 + array(
6842 + 'title' => __( 'How to repair it here', 'vigilante' ),
6843 + 'meaning' => __( 'This site does not let Vigilant replace its own files from this screen.', 'vigilante' ),
6844 + 'steps' => Vigilante_Self_Integrity_Guidance::manual_steps( $vg_reason ),
6845 + ),
6846 + array(),
6847 + '',
6848 + false
6849 + );
6850 + }
6851 + }
6852 + ?>
6853 + </div>
6854 +
6855 + <?php if ( $total > count( $findings ) ) : ?>
6856 + <p class="description">
6857 + <?php
6858 + printf(
6859 + /* translators: 1: findings shown, 2: findings found in total */
6860 + esc_html__( 'Showing %1$d of %2$d findings: the rest are of the same kind.', 'vigilante' ),
6861 + (int) count( $findings ),
6862 + (int) $total
6863 + );
6864 + ?>
6865 + </p>
6866 + <?php endif; ?>
6867 +
6868 + <?php
6869 + /*
6870 + * Reinstalling fixes files, not a filter someone wrote or a hook
6871 + * someone removed: the button only appears when at least one of the
6872 + * cases on screen is one a clean copy solves. Offering it next to
6873 + * "switched off by code" was telling the person to fix something
6874 + * else.
6875 + */
6876 + $vg_offer_repair = false;
6877 + foreach ( $groups as $vg_group ) {
6878 + if ( ! empty( $vg_group['repair'] ) ) {
6879 + $vg_offer_repair = true;
6880 + break;
6881 + }
6882 + }
6883 + ?>
6884 + <p class="vigilante-self-actions">
6885 + <?php if ( $vg_offer_repair && class_exists( 'Vigilante_Self_Repair' ) && Vigilante_Self_Repair::can_repair() ) : ?>
6886 + <a class="button button-primary" href="<?php echo esc_url( Vigilante_Self_Repair::action_url() ); ?>">
6887 + <?php esc_html_e( 'Repair Vigilant', 'vigilante' ); ?>
6888 + </a>
6889 + <span class="description">
6890 + <?php esc_html_e( 'It downloads a clean copy from WordPress.org, asks before changing anything, and keeps your settings and log.', 'vigilante' ); ?>
6891 + </span>
6892 + <?php else : ?>
6893 + <span class="description">
6894 + <?php esc_html_e( 'After fixing it, run a new scan with the Run Scan Now button above.', 'vigilante' ); ?>
6895 + </span>
6896 + <?php endif; ?>
6897 + </p>
6898 + </div>
6899 + </div>
6900 + <?php
6901 + }
6902 +
6903 + /**
6904 + * The same state when there is nothing to act on: one line inside the same
6905 + * card, so the first block of the results is always the same object.
6906 + *
6907 + * @param string $tone Tone from Vigilante_Self_Integrity::tone().
6908 + * @param array $state State.
6909 + * @param array $status Arguments for the guidance catalogue.
6910 + */
6911 + private function render_self_line( $tone, $state, $status ) {
6912 + $guidance = Vigilante_Self_Integrity_Guidance::for_status( $status );
6913 + $icon = ( 'ok' === $tone ) ? 'dashicons-yes-alt' : 'dashicons-shield';
6914 + ?>
6915 + <div id="vigilante-section-fi-self" class="vigilante-settings-section vigilante-self-box vigilante-self-box--<?php echo esc_attr( $tone ); ?> vigilante-self-box--quiet">
6916 + <div class="vigilante-self-card vigilante-self-card--quiet">
6917 + <p class="vigilante-self-line">
6918 + <span class="dashicons <?php echo esc_attr( $icon ); ?>" aria-hidden="true"></span>
6919 + <strong><?php esc_html_e( 'Vigilant self-protection:', 'vigilante' ); ?></strong>
6920 + <span class="vigilante-self-line-state"><?php echo esc_html( $this->self_integrity_headline( $tone, $state ) ); ?></span>
6921 + <?php if ( ! empty( $state['last_check'] ) ) : ?>
6922 + <span class="vigilante-self-line-meta">
6923 + <?php
6924 + printf(
6925 + /* translators: %s: human time difference, like "2 hours" */
6926 + esc_html__( 'checked %s ago', 'vigilante' ),
6927 + esc_html( human_time_diff( (int) $state['last_check'], time() ) )
6928 + );
6929 + ?>
6930 + </span>
6931 + <?php endif; ?>
6932 + </p>
6933 + <?php if ( ! empty( $guidance['steps'] ) && 'none' === $tone ) : ?>
6934 + <p class="description vigilante-self-line-help"><?php echo esc_html( $guidance['steps'][0] ); ?></p>
6935 + <?php endif; ?>
6936 + </div>
6937 + </div>
6938 + <?php
6939 + }
6940 +
6941 + /**
6942 + * One case of the self-protection box, as a row that opens: what it is, how
6943 + * many files, its severity, and inside, the files, what it means and what to
6944 + * do. Plain HTML details, so there is no JavaScript between a finding about
6945 + * the security plugin and the person reading it.
6946 + *
6947 + * @param array $guidance Guidance entry (title, meaning, steps).
6948 + * @param array $files Paths this case was found in.
6949 + * @param string $severity critical|warning, empty for the how-to-repair row.
6950 + * @param bool $open Whether the row starts open.
6951 + */
6952 + private function render_self_case( $guidance, $files = array(), $severity = '', $open = false ) {
6953 + $shown = array_slice( (array) $files, 0, 20 );
6954 + $hidden = count( (array) $files ) - count( $shown );
6955 + ?>
6956 + <details class="vigilante-self-case vigilante-self-case--<?php echo esc_attr( '' !== $severity ? $severity : 'plain' ); ?>"<?php echo $open ? ' open' : ''; ?>>
6957 + <summary>
6958 + <span class="vigilante-self-case-title"><?php echo esc_html( $guidance['title'] ); ?></span>
6959 + <?php if ( ! empty( $files ) ) : ?>
6960 + <span class="vigilante-self-case-count">
6961 + <?php
6962 + printf(
6963 + /* translators: %d: number of files of this finding */
6964 + esc_html( _n( '%d file', '%d files', count( (array) $files ), 'vigilante' ) ),
6965 + (int) count( (array) $files )
6966 + );
6967 + ?>
6968 + </span>
6969 + <?php endif; ?>
6970 + <?php if ( '' !== $severity ) : ?>
6971 + <span class="vigilante-self-case-severity">
6972 + <?php echo esc_html( 'critical' === $severity ? __( 'critical', 'vigilante' ) : __( 'warning', 'vigilante' ) ); ?>
6973 + </span>
6974 + <?php endif; ?>
6975 + </summary>
6976 + <div class="vigilante-self-case-body">
6977 + <?php if ( ! empty( $shown ) ) : ?>
6978 + <ul class="vigilante-self-paths">
6979 + <?php foreach ( $shown as $file ) : ?>
6980 + <li><code><?php echo esc_html( $file ); ?></code></li>
6981 + <?php endforeach; ?>
6982 + <?php if ( $hidden > 0 ) : ?>
6983 + <li>
6984 + <?php
6985 + printf(
6986 + /* translators: %d: number of additional files */
6987 + esc_html__( 'and %d more', 'vigilante' ),
6988 + (int) $hidden
6989 + );
6990 + ?>
6991 + </li>
6992 + <?php endif; ?>
6993 + </ul>
6994 + <?php endif; ?>
6995 + <p class="vigilante-self-meaning">
6996 + <strong><?php esc_html_e( 'What it means:', 'vigilante' ); ?></strong>
6997 + <?php echo esc_html( $guidance['meaning'] ); ?>
6998 + </p>
6999 + <?php if ( ! empty( $guidance['steps'] ) ) : ?>
7000 + <p class="vigilante-self-todo"><strong><?php esc_html_e( 'What to do:', 'vigilante' ); ?></strong></p>
7001 + <ol class="vigilante-self-steps">
7002 + <?php foreach ( $guidance['steps'] as $step ) : ?>
7003 + <li><?php echo esc_html( $step ); ?></li>
7004 + <?php endforeach; ?>
7005 + </ol>
7006 + <?php endif; ?>
7007 + </div>
7008 + </details>
7009 + <?php
7010 + }
7011 +
5823 7012 private function render_tab_file_integrity() {
5824 7013 $is_disabled = $this->render_module_disabled_notice( 'file_integrity' );
5825 7014 $options = $this->settings->get_section( 'file_integrity' );
7015 + // On the main site of a network the critical-file scan is the network's
7016 + // canary for a change to wp-config.php or the root .htaccess, so a
7017 + // main-site admin without network rights cannot turn it off. Since
7018 + // 2.11.8; see Vigilante_Settings::get_main_site_file_settings().
7019 + $vg_main_locked = $this->main_site_files_locked();
5826 7020 $last_scan = get_option( 'vigilante_last_integrity_scan' );
5827 7021 $last_results = get_option( 'vigilante_last_integrity_results' );
5828 7022 $ignored_files = get_option( 'vigilante_ignored_files', array() );
5829 7023
7024 + /*
7025 + * Vigilant own findings are shown in their own box, the first of the
7026 + * results, with what each one means and how to fix it. They are taken
7027 + * out of the generic tables here (and out of the counters above them)
7028 + * so the same finding is not reported twice and so no row of the
7029 + * security plugin's own files offers an Ignore button. The stored
7030 + * results keep them: the scan email reads its own section from there.
7031 + */
7032 + if ( is_array( $last_results ) ) {
7033 + foreach ( array( 'modified', 'suspicious', 'extra', 'missing' ) as $vg_bucket ) {
7034 + if ( empty( $last_results[ $vg_bucket ] ) || ! is_array( $last_results[ $vg_bucket ] ) ) {
7035 + continue;
7036 + }
7037 + $last_results[ $vg_bucket ] = array_values(
7038 + array_filter(
7039 + $last_results[ $vg_bucket ],
7040 + function ( $vg_item ) {
7041 + return ! ( is_array( $vg_item ) && 'vigilante_self' === ( $vg_item['type'] ?? '' ) );
7042 + }
7043 + )
7044 + );
7045 + }
7046 + }
7047 +
5830 7048 // Backward compat: convert old notify_on_changes to notify_level
5831 7049 $notify_level = $options['notify_level'] ?? '';
5832 7050 if ( empty( $notify_level ) ) {
5833 7051 $notify_level = ! empty( $options['notify_on_changes'] ) ? 'all' : 'disabled';
@@ -5949,10 +7167,13 @@
5949 7167 <?php esc_html_e( 'Uploads directory (detect PHP files, double extensions, .htaccess)', 'vigilante' ); ?>
5950 7168 </label>
5951 7169 <br>
5952 7170 <label>
5953 - <input type="checkbox" name="file_integrity[scan_critical_config]" value="1" <?php checked( $options['scan_critical_config'] ?? true ); ?>>
7171 + <input type="checkbox" name="file_integrity[scan_critical_config]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( $options['scan_critical_config'] ?? true ); ?>>
5954 7172 <?php esc_html_e( 'Critical config files (wp-config.php, .htaccess baseline monitoring)', 'vigilante' ); ?>
7173 + <?php if ( $vg_main_locked ) : ?>
7174 + <span class="description" style="display:block;margin-left:24px;"><?php echo esc_html( Vigilante_Settings::get_shared_files_notice() ); ?></span>
7175 + <?php endif; ?>
5955 7176 </label>
5956 7177 <br>
5957 7178 <label>
5958 7179 <input type="checkbox" name="file_integrity[check_closed_plugins]" value="1" <?php checked( $options['check_closed_plugins'] ?? true ); ?>>
@@ -6007,8 +7228,10 @@
6007 7228 </form>
6008 7229
6009 7230 <div id="vigilante-scan-results" class="vigilante-settings-section" style="display:none;"></div>
6010 7231
7232 + <?php $this->render_self_protection_box( $options ); ?>
7233 +
6011 7234 <?php if ( $last_scan || $has_closed || $closed_last_check > 0 ) : ?>
6012 7235 <div id="vigilante-section-fi-last-scan" class="vigilante-settings-section">
6013 7236 <h2><?php esc_html_e( 'Last Scan Results', 'vigilante' ); ?></h2>
6014 7237 <?php if ( $last_scan ) : ?>
@@ -6133,8 +7356,11 @@
6133 7356 }
6134 7357 } else {
6135 7358 $file_path = (string) $item;
6136 7359 }
7360 + if ( 'vigilante_self' === $file_type ) {
7361 + $file_type = __( 'Vigilant (self)', 'vigilante' );
7362 + }
6137 7363 ?>
6138 7364 <tr>
6139 7365 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
6140 7366 <td><code style="color: #d63638;"><?php echo esc_html( $file_path ); ?></code></td>
@@ -6174,8 +7400,11 @@
6174 7400 foreach ( $last_results['extra'] as $item ) {
6175 7401 $file_path = is_array( $item ) ? ( $item['file'] ?? '' ) : (string) $item;
6176 7402 $file_reason = is_array( $item ) ? ( $item['reason'] ?? __( 'Unknown', 'vigilante' ) ) : __( 'Unknown', 'vigilante' );
6177 7403 $file_type = is_array( $item ) ? ( $item['type'] ?? 'unknown' ) : 'unknown';
7404 + if ( 'vigilante_self' === $file_type ) {
7405 + $file_type = __( 'Vigilant (self)', 'vigilante' );
7406 + }
6178 7407 ?>
6179 7408 <tr>
6180 7409 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
6181 7410 <td><code style="color: #b32d2e;"><?php echo esc_html( $file_path ); ?></code></td>
@@ -6206,8 +7435,19 @@
6206 7435 $regular_modified[] = $item;
6207 7436 }
6208 7437 }
6209 7438 }
7439 + // A missing file of Vigilant is critical, and the missing
7440 + // files of the generic scan have no table: it is listed with
7441 + // the modified files, or the tab said "All files passed" with
7442 + // a module deleted.
7443 + if ( $last_results && ! empty( $last_results['missing'] ) && is_array( $last_results['missing'] ) ) {
7444 + foreach ( $last_results['missing'] as $item ) {
7445 + if ( is_array( $item ) && 'vigilante_self' === ( $item['type'] ?? '' ) ) {
7446 + $regular_modified[] = $item;
7447 + }
7448 + }
7449 + }
6210 7450 ?>
6211 7451
6212 7452 <?php if ( ! empty( $critical_modified ) ) : ?>
6213 7453 <div class="vigilante-file-list vigilante-critical-config-files">
@@ -6231,9 +7471,15 @@
6231 7471 $crit_diff = $crit_item['diff'] ?? array();
6232 7472 $crit_id = sanitize_html_class( $crit_file );
6233 7473 $added_count = is_array( $crit_diff ) ? count( $crit_diff['added'] ?? array() ) : 0;
6234 7474 $removed_count = is_array( $crit_diff ) ? count( $crit_diff['removed'] ?? array() ) : 0;
6235 - $diff_unavailable = is_array( $crit_diff ) && ! empty( $crit_diff['unavailable'] );
7475 + // The lines of a shared file are for whoever approves it. Results
7476 + // stored before 2.11.8 on the main site still carry them, so the
7477 + // screen asks too, not only the scan that wrote them.
7478 + $diff_network = ( is_array( $crit_diff ) && ! empty( $crit_diff['network'] ) ) || $this->critical_approval_locked();
7479 + $diff_rescan = is_array( $crit_diff ) && ! empty( $crit_diff['rescan'] );
7480 + $diff_redaction = is_array( $crit_diff ) && ! empty( $crit_diff['redaction'] );
7481 + $diff_unavailable = $diff_network || ( is_array( $crit_diff ) && ! empty( $crit_diff['unavailable'] ) );
6236 7482 ?>
6237 7483 <tr>
6238 7484 <td><code style="color: #e36210;"><?php echo esc_html( $crit_file ); ?></code></td>
6239 7485 <td>
@@ -6256,18 +7502,36 @@
6256 7502 <td>
6257 7503 <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 7504 <?php esc_html_e( 'Review changes', 'vigilante' ); ?>
6259 7505 </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>
7506 + <?php if ( $this->critical_approval_locked() ) : ?>
7507 + <span class="description" style="display:block;margin-top:4px;">
7508 + <?php echo esc_html( $this->critical_approval_notice() ); ?>
7509 + </span>
7510 + <?php else : ?>
7511 + <button type="button" class="button button-small button-primary vigilante-approve-critical-file" data-file="<?php echo esc_attr( $crit_file ); ?>">
7512 + <?php esc_html_e( 'Approve', 'vigilante' ); ?>
7513 + </button>
7514 + <?php endif; ?>
6263 7515 </td>
6264 7516 </tr>
6265 7517 <tr id="vigilante-critical-content-<?php echo esc_attr( $crit_id ); ?>" class="vigilante-critical-content-row" style="display:none;">
6266 7518 <td colspan="3" style="padding: 0;">
6267 7519 <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 ) : ?>
7520 + <?php if ( $diff_network ) : ?>
6269 7521 <p style="color: #50575e; font-style: italic; margin: 0;">
7522 + <?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' ); ?>
7523 + </p>
7524 + <?php elseif ( $diff_rescan ) : ?>
7525 + <p style="color: #50575e; font-style: italic; margin: 0;">
7526 + <?php esc_html_e( 'Run a new scan to see the line changes of this file.', 'vigilante' ); ?>
7527 + </p>
7528 + <?php elseif ( $diff_redaction ) : ?>
7529 + <p style="color: #50575e; font-style: italic; margin: 0;">
7530 + <?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' ); ?>
7531 + </p>
7532 + <?php elseif ( $diff_unavailable ) : ?>
7533 + <p style="color: #50575e; font-style: italic; margin: 0;">
6270 7534 <?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 7535 </p>
6272 7536 <?php elseif ( empty( $crit_diff['added'] ) && empty( $crit_diff['removed'] ) ) : ?>
6273 7537 <p style="color: #50575e; font-style: italic; margin: 0;">
@@ -6295,9 +7559,9 @@
6295 7559 <?php endif; ?>
6296 7560
6297 7561 <?php if ( $has_closed ) : ?>
6298 7562 <div class="vigilante-file-list vigilante-closed-plugins">
6299 - <h3 style="color: #d63638;"><?php esc_html_e( 'Closed + Removed Plugins', 'vigilante' ); ?></h3>
7563 + <h3 id="vigilante-section-fi-closed-plugins" style="color: #d63638;"><?php esc_html_e( 'Closed + Removed Plugins', 'vigilante' ); ?></h3>
6300 7564 <p class="description" style="color: #d63638;">
6301 7565 <?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 7566 </p>
6303 7567 <table class="wp-list-table widefat striped">
@@ -6382,8 +7646,11 @@
6382 7646 }
6383 7647 } else {
6384 7648 $file_path = (string) $item;
6385 7649 }
7650 + if ( 'vigilante_self' === $file_type ) {
7651 + $file_type = __( 'Vigilant (self)', 'vigilante' );
7652 + }
6386 7653 ?>
6387 7654 <tr>
6388 7655 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
6389 7656 <td><code><?php echo esc_html( $file_path ); ?></code></td>
@@ -6397,9 +7664,16 @@
6397 7664 </table>
6398 7665 </div>
6399 7666 <?php endif; ?>
6400 7667
6401 - <?php if ( $last_results && empty( $last_results['modified'] ) && empty( $last_results['suspicious'] ) && empty( $last_results['extra'] ) && ! $has_closed ) : ?>
7668 + <?php
7669 + // "All files passed" is about every file, and Vigilant's own
7670 + // are files too: with the self-protection box in red or amber
7671 + // above, this line contradicted it (its findings no longer
7672 + // travel in the tables below).
7673 + $vg_self_alarm = in_array( $this->self_integrity_summary( $options )['tone'], array( 'critical', 'off', 'warning' ), true );
7674 + ?>
7675 + <?php if ( $last_results && ! $vg_self_alarm && empty( $critical_modified ) && empty( $regular_modified ) && empty( $last_results['suspicious'] ) && empty( $last_results['extra'] ) && ! $has_closed ) : ?>
6402 7676 <p class="vigilante-all-clear" style="color: #00a32a; font-weight: bold;">
6403 7677 <?php esc_html_e( 'Good Job! All files passed integrity check. No issues found.', 'vigilante' ); ?>
6404 7678 </p>
6405 7679 <?php endif; ?>
@@ -6506,8 +7780,15 @@
6506 7780 if ( ! current_user_can( 'manage_options' ) ) {
6507 7781 wp_die( esc_html__( 'Permission denied.', 'vigilante' ), 403 );
6508 7782 }
6509 7783
7784 + // The archive carries wp-config.php, which a whole network shares. On a
7785 + // network manage_options is held by every subsite administrator, so the
7786 + // same gate the writers use applies here.
7787 + if ( ! Vigilante_Settings::can_write_shared_files() ) {
7788 + wp_die( esc_html( Vigilante_Settings::get_shared_files_notice() ), 403 );
7789 + }
7790 +
6510 7791 $backup_manager = new Vigilante_Backup_Manager();
6511 7792 $result = $backup_manager->stream_files_zip();
6512 7793
6513 7794 // stream_files_zip() exits on success; only a WP_Error returns here.
@@ -6534,9 +7815,9 @@
6534 7815 } elseif ( isset( $_POST['data'] ) ) {
6535 7816 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
6536 7817 $raw_data = wp_unslash( $_POST['data'] );
6537 7818 parse_str( $raw_data, $data );
6538 - $data = map_deep( $data, 'sanitize_textarea_field' );
7819 + $data = $this->sanitize_posted_settings( $data );
6539 7820 } else {
6540 7821 $data = array();
6541 7822 }
6542 7823
@@ -6596,10 +7877,30 @@
6596 7877
6597 7878 // Read ONLY saved options from database (not merged with defaults)
6598 7879 $saved_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
6599 7880
6600 - $rejected_ips = array();
7881 + // What is stored before this request changes anything: the shared file
7882 + // settings this user may not change are put back from here (2.11.6).
7883 + $stored_options = $saved_options;
7884 + $locked = Vigilante_Settings::get_locked_file_settings();
6601 7885
7886 + if ( isset( $locked[ $section ] ) && true === $locked[ $section ] ) {
7887 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7888 + }
7889 +
7890 + // A module switch is a single key, so refusing says more than a success
7891 + // that changed nothing, and the dashboard puts the toggle back.
7892 + if ( 'modules' === $section && isset( $locked['modules'], $data['modules'] ) && is_array( $locked['modules'] ) && is_array( $data['modules'] ) ) {
7893 + foreach ( array_keys( $data['modules'] ) as $vg_module ) {
7894 + if ( in_array( sanitize_key( $vg_module ), $locked['modules'], true ) ) {
7895 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7896 + }
7897 + }
7898 + }
7899 +
7900 + $rejected_ips = array();
7901 + $rejected_proxies = array();
7902 +
6602 7903 // Handle modules
6603 7904 if ( 'modules' === $section && isset( $data['modules'] ) ) {
6604 7905 if ( ! isset( $saved_options['modules'] ) ) {
6605 7906 $saved_options['modules'] = array();
@@ -6623,9 +7924,9 @@
6623 7924 // went straight into the option. An entry the matcher can never
6624 7925 // match still sits in a security list looking like protection,
6625 7926 // so the ones that cannot match are dropped and reported back
6626 7927 // instead of being stored in silence.
6627 - $rejected_ips = $this->filter_ip_lists( $section, $processed );
7928 + $rejected_ips = $this->filter_ip_lists( $section, $processed, $rejected_proxies );
6628 7929
6629 7930 // Save the processed section
6630 7931 $saved_options[ $section ] = $processed;
6631 7932
@@ -6636,8 +7937,10 @@
6636 7937
6637 7938 // Clear cache before saving
6638 7939 wp_cache_delete( Vigilante_Settings::OPTION_NAME, 'options' );
6639 7940
7941 + $saved_options = Vigilante_Settings::keep_locked_file_settings( $saved_options, $stored_options );
7942 +
6640 7943 // Save to database
6641 7944 update_option( Vigilante_Settings::OPTION_NAME, $saved_options );
6642 7945
6643 7946 // Clear the settings cache
@@ -6699,8 +8002,21 @@
6699 8002 implode( ', ', array_map( 'esc_html', $rejected_ips ) )
6700 8003 );
6701 8004 }
6702 8005
8006 + if ( ! empty( $rejected_proxies ) ) {
8007 + $message .= ' ' . sprintf(
8008 + /* translators: %s: comma separated list of the trusted proxy entries that were not saved. */
8009 + _n(
8010 + 'A trusted proxy must be an exact IP or a CIDR range, not a wildcard, so this entry was not saved: %s',
8011 + 'A trusted proxy must be an exact IP or a CIDR range, not a wildcard, so these entries were not saved: %s',
8012 + count( $rejected_proxies ),
8013 + 'vigilante'
8014 + ),
8015 + implode( ', ', array_map( 'esc_html', $rejected_proxies ) )
8016 + );
8017 + }
8018 +
6703 8019 wp_send_json_success( $message );
6704 8020 }
6705 8021
6706 8022 /**
@@ -6711,9 +8027,21 @@
6711 8027 * @param string $section Section being saved.
6712 8028 * @param array $processed Section data, edited in place.
6713 8029 * @return array Entries that were dropped, for the message back to the user.
6714 8030 */
6715 - private function filter_ip_lists( $section, &$processed ) {
8031 + private function filter_ip_lists( $section, &$processed, &$rejected_proxies = array() ) {
8032 + $rejected_proxies = array();
8033 +
8034 + // Trusted proxies feed an identity decision, so only exact addresses and
8035 + // CIDR ranges belong there: a wildcard is stripped with its own message,
8036 + // never stored looking effective. The matcher ignores it anyway (see
8037 + // Vigilante_IP_Utils::in_list_ip_or_cidr), this stops it persisting.
8038 + if ( 'firewall' === $section && isset( $processed['trusted_proxies'] ) && is_array( $processed['trusted_proxies'] ) ) {
8039 + $split = Vigilante_IP_Utils::split_list_ip_or_cidr( $processed['trusted_proxies'] );
8040 + $processed['trusted_proxies'] = $split['valid'];
8041 + $rejected_proxies = $split['rejected'];
8042 + }
8043 +
6716 8044 $lists = array(
6717 8045 'firewall' => array( 'ip_whitelist', 'ip_blacklist' ),
6718 8046 'login_security' => array( 'ip_whitelist' ),
6719 8047 );
@@ -6879,8 +8207,48 @@
6879 8207 return array( 'sent' => $sent, 'failed' => $failed );
6880 8208 }
6881 8209
6882 8210 /**
8211 + * Sanitize the settings a tab posted, once the serialized form is parsed
8212 + *
8213 + * Every value goes through sanitize_textarea_field() except the lists of
8214 + * file paths (Vigilante_Settings::path_list_keys()). That one deletes every
8215 + * %XX octet and, when it finds one, collapses repeated spaces, so an
8216 + * exclusion for a folder called foto%20x was saved as fotox and excluded
8217 + * nothing (measured on 1 oct 2026 against 3.0.1). The paths are read as
8218 + * paths before the generic sanitizer runs and put back after it.
8219 + *
8220 + * @since 3.0.2
8221 + *
8222 + * @param array $data Parsed form: section => key => value.
8223 + * @return array
8224 + */
8225 + private function sanitize_posted_settings( $data ) {
8226 + $paths = Vigilante_Settings::take_path_lists( $data );
8227 + $data = map_deep( $data, 'sanitize_textarea_field' );
8228 +
8229 + return Vigilante_Settings::restore_path_lists( $data, $paths );
8230 + }
8231 +
8232 + /**
8233 + * Sanitize an imported settings file, once it is decoded
8234 + *
8235 + * Same rule as sanitize_posted_settings(): the lists of paths keep what the
8236 + * export wrote, so an exclusion survives an export and an import.
8237 + *
8238 + * @since 3.0.2
8239 + *
8240 + * @param array $imported Decoded export.
8241 + * @return array
8242 + */
8243 + private function sanitize_imported_settings( $imported ) {
8244 + $paths = Vigilante_Settings::take_path_lists( $imported );
8245 + $imported = map_deep( $imported, 'sanitize_text_field' );
8246 +
8247 + return Vigilante_Settings::restore_path_lists( $imported, $paths );
8248 + }
8249 +
8250 + /**
6883 8251 * Process section data maintaining proper types from defaults
6884 8252 *
6885 8253 * @param array $submitted_data Data submitted from form.
6886 8254 * @param array $defaults Default values for this section.
@@ -6888,16 +8256,51 @@
6888 8256 * @param string $section_name Section name for special handling.
6889 8257 * @return array Processed data.
6890 8258 */
6891 8259 private function process_section_data( $submitted_data, $defaults, $current, $section_name = '' ) {
6892 - // Start with defaults, then merge current saved values
8260 + // Start with defaults, then merge current saved values. Lists are put
8261 + // back whole afterwards: array_replace_recursive() merges them index by
8262 + // index, so a saved list shorter than the default kept the default's
8263 + // tail (see below, and Vigilante_Settings::array_merge_deep()).
6893 8264 $result = array_replace_recursive( $defaults, $current );
6894 -
8265 + $result = $this->restore_lists( $result, $current );
8266 +
6895 8267 // Process each submitted value
6896 8268 foreach ( $submitted_data as $key => $value ) {
6897 8269 $key = sanitize_key( $key );
6898 -
6899 - if ( is_array( $value ) ) {
8270 +
8271 + if ( is_array( $value ) && $this->is_list_field( $key, $value, $defaults, $current ) ) {
8272 + /*
8273 + * A list of values, not a group of settings: the roles of a
8274 + * checkbox group, the excluded user ids. What the form sent IS
8275 + * the list, so it replaces the stored one outright.
8276 + *
8277 + * Until 3.0.1 this went through the recursion below, which
8278 + * writes position by position and left every stored entry the
8279 + * new list was too short to overwrite. Unchecking Editor in
8280 + * "Enforce for roles" saved administrator over position 0 and
8281 + * kept editor in position 1, so the box came back ticked
8282 + * (reported on the support forum for 3.0.0, and the same bug
8283 + * merge_preset() was fixed for in 2.9.8).
8284 + *
8285 + * Keys are dropped on purpose: a list is data, so anything the
8286 + * request tried to name is not carried over. The empty string
8287 + * is dropped too, which is what the hidden field that marks an
8288 + * empty group sends.
8289 + */
8290 + $list = array();
8291 + foreach ( $value as $entry ) {
8292 + if ( ! is_scalar( $entry ) ) {
8293 + continue;
8294 + }
8295 + $entry = sanitize_text_field( (string) $entry );
8296 + if ( '' === $entry ) {
8297 + continue;
8298 + }
8299 + $list[] = $entry;
8300 + }
8301 + $result[ $key ] = $list;
8302 + } elseif ( is_array( $value ) ) {
6900 8303 // Nested array (like rate_limiting)
6901 8304 $nested_defaults = isset( $defaults[ $key ] ) && is_array( $defaults[ $key ] ) ? $defaults[ $key ] : array();
6902 8305 $nested_current = isset( $result[ $key ] ) && is_array( $result[ $key ] ) ? $result[ $key ] : array();
6903 8306 $result[ $key ] = $this->process_section_data( $value, $nested_defaults, $nested_current, $key );
@@ -6983,10 +8386,26 @@
6983 8386 }
6984 8387 }
6985 8388
6986 8389 if ( $is_value_list ) {
6987 - // All items removed - reset to empty array
6988 - $result[ $key ] = array();
8390 + /*
8391 + * A list whose key did not travel is left as it was. Until
8392 + * 3.0.1 it was blanked here, which read "the user unticked
8393 + * everything" into something the form never asked about:
8394 + * saving the Firewall tab blanked allowed_http_methods,
8395 + * which has no field on any screen, and saving Users blanked
8396 + * insecure_usernames. Both survived only because reading the
8397 + * option put the defaults back on top, and that is exactly
8398 + * the merge being removed in this release: with lists
8399 + * replaced whole, blanking here would have left a site
8400 + * answering 403 to every HTTP method.
8401 + *
8402 + * Emptying a group is still possible, and is now said out
8403 + * loud: each checkbox group prints a hidden field with an
8404 + * empty value, so an empty group arrives as an empty list
8405 + * instead of as silence. See list_sentinel().
8406 + */
8407 + continue;
6989 8408 } else {
6990 8409 // Nested settings group - handle boolean children
6991 8410 foreach ( $default_value as $nested_key => $nested_default ) {
6992 8411 if ( is_bool( $nested_default ) && isset( $result[ $key ] ) && is_array( $result[ $key ] ) ) {
@@ -7005,8 +8424,106 @@
7005 8424 return $result;
7006 8425 }
7007 8426
7008 8427 /**
8428 + * Whether a submitted value is a list of values rather than a group of settings
8429 + *
8430 + * The difference decides whether what arrived replaces the stored value or
8431 + * is merged into it key by key, so it is answered from what the setting is
8432 + * (its default, or what is stored today), not from the shape of the
8433 + * request. A request that sends named keys where a list belongs gets them
8434 + * dropped, which is one way this is narrower than what it replaces.
8435 + *
8436 + * @since 3.0.1
8437 + *
8438 + * @param string $key Setting key, already through sanitize_key().
8439 + * @param array $value Value as submitted.
8440 + * @param array $defaults Defaults of the section being processed.
8441 + * @param array $current Stored values of the section being processed.
8442 + * @return bool
8443 + */
8444 + private function is_list_field( $key, $value, $defaults, $current ) {
8445 + $default_known = array_key_exists( $key, $defaults ) && is_array( $defaults[ $key ] );
8446 +
8447 + // The default has the first and the last word, the same rule that
8448 + // array_merge_deep() and restore_lists() follow. Letting the stored
8449 + // value decide, as this did until the fourth cross review of 3.0.1,
8450 + // disagrees with them where it matters: an option corrupted into
8451 + // two_factor = array( 'a', 'b' ) would have been treated as a list and
8452 + // saving the tab would have replaced the whole group of settings with
8453 + // the two values that arrived.
8454 + if ( $default_known && array() !== $defaults[ $key ] ) {
8455 + return Vigilante_Settings::is_list( $defaults[ $key ] );
8456 + }
8457 +
8458 + // Nothing declares it either way (an empty default, or a key with no
8459 + // default at all), so what is stored, and then what was submitted, is
8460 + // all there is to go on.
8461 + if ( isset( $current[ $key ] ) && is_array( $current[ $key ] ) && array() !== $current[ $key ] && Vigilante_Settings::is_list( $current[ $key ] ) ) {
8462 + return true;
8463 + }
8464 +
8465 + return Vigilante_Settings::is_list( $value );
8466 + }
8467 +
8468 + /**
8469 + * Put stored lists back after array_replace_recursive()
8470 + *
8471 + * array_replace_recursive() merges a list by position, so a stored list
8472 + * shorter than the default comes out with the default's tail attached:
8473 + * ['administrator'] over ['administrator','editor'] gives both back. Every
8474 + * list that is stored is therefore restored whole before anything else is
8475 + * processed, and only associative arrays are walked into.
8476 + *
8477 + * @since 3.0.1
8478 + *
8479 + * @param array $result Defaults with the stored values merged over them.
8480 + * @param array $current Stored values.
8481 + * @return array
8482 + */
8483 + private function restore_lists( $result, $current ) {
8484 + if ( ! is_array( $current ) ) {
8485 + return $result;
8486 + }
8487 +
8488 + foreach ( $current as $key => $value ) {
8489 + if ( ! is_array( $value ) || ! isset( $result[ $key ] ) || ! is_array( $result[ $key ] ) ) {
8490 + continue;
8491 + }
8492 +
8493 + // Decided by what is in $result, which comes from the defaults, and
8494 + // never by the stored value: array() is a list by any definition, so
8495 + // a section stored empty would have replaced a whole group of
8496 + // settings with nothing. Same reasoning as in array_merge_deep().
8497 + if ( Vigilante_Settings::is_list( $result[ $key ] ) ) {
8498 + $result[ $key ] = $value;
8499 + continue;
8500 + }
8501 +
8502 + $result[ $key ] = $this->restore_lists( $result[ $key ], $value );
8503 + }
8504 +
8505 + return $result;
8506 + }
8507 +
8508 + /**
8509 + * Print the hidden field that lets a checkbox group arrive empty
8510 + *
8511 + * A checkbox group sends nothing at all when every box is unticked, which
8512 + * is indistinguishable from a form that never had that field. Since 3.0.1
8513 + * the save path leaves an absent list alone, so the difference has to be
8514 + * stated: this empty entry always travels, and the save path drops empty
8515 + * strings, so an untouched group arrives as an empty list.
8516 + *
8517 + * @since 3.0.1
8518 + *
8519 + * @param string $name Field name without the trailing [].
8520 + */
8521 + private function list_sentinel( $name ) {
8522 + printf( '<input type="hidden" name="%s[]" value="">', esc_attr( $name ) );
8523 + }
8524 +
8525 + /**
7009 8526 * AJAX: Export settings
7010 8527 */
7011 8528 public function ajax_export_settings() {
7012 8529 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
@@ -7058,15 +8575,29 @@
7058 8575 wp_send_json_error( __( 'Invalid JSON format.', 'vigilante' ) );
7059 8576 }
7060 8577
7061 8578 // Sanitize imported data recursively
7062 - $imported = map_deep( $imported, 'sanitize_text_field' );
8579 + $imported = $this->sanitize_imported_settings( $imported );
7063 8580
7064 - // Validate structure
7065 - $defaults = $this->settings->get_default_options();
7066 - $merged = array_replace_recursive( $defaults, $imported );
8581 + // Validate structure: only sections and keys of the schema survive, and
8582 + // every value takes the type of its default. Until 2.11.0 this was an
8583 + // array_replace_recursive() of the file over the defaults, so any key in
8584 + // the file, known or not, landed in vigilante_options (S7). Sections
8585 + // the file does not carry keep their defaults; a section it does carry
8586 + // replaces the default one whole, because validate_options() has
8587 + // already filled in whatever the file left out.
8588 + $defaults = $this->settings->get_default_options();
8589 + $validated = $this->settings->validate_options( $imported );
8590 + $merged = $defaults;
7067 8591
8592 + foreach ( $validated as $section => $data ) {
8593 + if ( is_array( $data ) ) {
8594 + $merged[ $section ] = $data;
8595 + }
8596 + }
8597 +
7068 8598 // Save
8599 + $merged = Vigilante_Settings::keep_locked_file_settings( $merged, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7069 8600 update_option( Vigilante_Settings::OPTION_NAME, $merged );
7070 8601 $this->settings->clear_cache();
7071 8602
7072 8603 // Re-evaluate the active preset marker. The imported config may match
@@ -7089,9 +8620,9 @@
7089 8620 if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
7090 8621 wp_schedule_single_event( time() + 5, 'vigilante_under_attack_post_scan' );
7091 8622 }
7092 8623
7093 - wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) );
8624 + wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
7094 8625 }
7095 8626
7096 8627 /**
7097 8628 * Detect whether a vigilante_options array matches a known preset.
@@ -7194,9 +8725,11 @@
7194 8725 $preset = isset( $_POST['preset'] ) ? sanitize_key( $_POST['preset'] ) : '';
7195 8726
7196 8727 // Handle reset to defaults
7197 8728 if ( 'reset' === $preset ) {
7198 - $defaults = Vigilante_Settings::get_defaults_preserving_user_data( get_option( Vigilante_Settings::OPTION_NAME, array() ) );
8729 + $stored_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
8730 + $defaults = Vigilante_Settings::get_defaults_preserving_user_data( $stored_options );
8731 + $defaults = Vigilante_Settings::keep_locked_file_settings( $defaults, $stored_options );
7199 8732 update_option( Vigilante_Settings::OPTION_NAME, $defaults );
7200 8733 $this->settings->clear_cache();
7201 8734
7202 8735 // Clear active preset
@@ -7204,9 +8737,9 @@
7204 8737
7205 8738 // Apply file changes after reset
7206 8739 $this->apply_all_file_changes( $defaults );
7207 8740
7208 - wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) );
8741 + wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) . $this->locked_file_settings_message() );
7209 8742 return;
7210 8743 }
7211 8744
7212 8745 $presets = $this->settings->get_presets();
@@ -7232,8 +8765,9 @@
7232 8765 // invent keys that are missing on both sides.
7233 8766 $current = Vigilante_Settings::merge_preset( $this->settings->get_default_options(), $current );
7234 8767
7235 8768 $merged = Vigilante_Settings::merge_preset( $current, $preset_options );
8769 + $merged = Vigilante_Settings::keep_locked_file_settings( $merged, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7236 8770
7237 8771 update_option( Vigilante_Settings::OPTION_NAME, $merged );
7238 8772 $this->settings->clear_cache();
7239 8773
@@ -7242,9 +8776,9 @@
7242 8776
7243 8777 // Apply file changes after preset
7244 8778 $this->apply_all_file_changes( $merged );
7245 8779
7246 - wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) );
8780 + wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
7247 8781 }
7248 8782
7249 8783 /**
7250 8784 * AJAX: Reset a specific section to defaults
@@ -7279,27 +8813,19 @@
7279 8813 * On a subsite, the settings written to wp-config.php and .htaccess are
7280 8814 * the main site's business. Resetting the local copy of those would only
7281 8815 * make this screen disagree with the file, so they are carried over
7282 8816 * untouched, and a section that is nothing but shared settings is not
7283 - * reset at all.
8817 + * reset at all. On the main site, a user without network rights keeps
8818 + * the ones the shared files are built from as well (2.11.6).
7284 8819 */
7285 - if ( ! Vigilante_Settings::can_write_shared_files() ) {
7286 - $shared = Vigilante_Settings::get_shared_file_settings();
8820 + $locked = Vigilante_Settings::get_locked_file_settings();
7287 8821
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 - }
8822 + if ( isset( $locked[ $section ] ) && true === $locked[ $section ] ) {
8823 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7299 8824 }
7300 8825
7301 8826 $current_options[ $section ] = $new_values;
8827 + $current_options = Vigilante_Settings::keep_locked_file_settings( $current_options, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7302 8828
7303 8829 // Save
7304 8830 update_option( Vigilante_Settings::OPTION_NAME, $current_options );
7305 8831 $this->settings->clear_cache();
@@ -7382,8 +8908,19 @@
7382 8908 // Save new results
7383 8909 update_option( 'vigilante_last_integrity_scan', time() );
7384 8910 update_option( 'vigilante_last_integrity_results', $results );
7385 8911
8912 + // On the main site the scan does compute the lines of wp-config.php and
8913 + // .htaccess, for the network administrator. Somebody without network
8914 + // rights gets the change and its sizes, not the lines.
8915 + if ( $this->critical_approval_locked() && ! empty( $results['modified'] ) && is_array( $results['modified'] ) ) {
8916 + foreach ( $results['modified'] as $index => $item ) {
8917 + if ( is_array( $item ) && 'critical_config' === ( $item['type'] ?? '' ) ) {
8918 + $results['modified'][ $index ]['diff'] = Vigilante_File_Integrity::network_only_diff();
8919 + }
8920 + }
8921 + }
8922 +
7386 8923 wp_send_json_success( array(
7387 8924 'message' => __( 'Scan completed.', 'vigilante' ),
7388 8925 'results' => $results,
7389 8926 'ignored_count' => count( get_option( 'vigilante_ignored_files', array() ) ),
@@ -7417,11 +8954,46 @@
7417 8954 if ( ! current_user_can( 'manage_options' ) ) {
7418 8955 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
7419 8956 }
7420 8957
8958 + $results = get_option( 'vigilante_last_integrity_results' );
8959 + $scanned_at = get_option( 'vigilante_last_integrity_scan' );
8960 +
7421 8961 delete_option( 'vigilante_last_integrity_results' );
7422 8962 delete_option( 'vigilante_last_integrity_scan' );
7423 8963
8964 + /*
8965 + * A pending change to wp-config.php or the root .htaccess is closed by
8966 + * approving it, which takes the network. Clearing the results was one
8967 + * more way to close it without, until the next scan: the ignore list was
8968 + * shut in 2.11.8 and this button was left open, found by the cross
8969 + * review of 2.11.8. So for somebody who cannot approve, those entries
8970 + * stay and everything else goes.
8971 + */
8972 + // The findings about Vigilant's own files stay too: they report for the
8973 + // whole network, and ignoring them already takes network rights.
8974 + if ( $this->critical_approval_locked() && is_array( $results ) ) {
8975 + $kept = array();
8976 + $found = false;
8977 + foreach ( array( 'modified', 'missing', 'suspicious', 'extra' ) as $bucket ) {
8978 + $kept[ $bucket ] = array_values(
8979 + array_filter(
8980 + isset( $results[ $bucket ] ) && is_array( $results[ $bucket ] ) ? $results[ $bucket ] : array(),
8981 + function ( $item ) {
8982 + return is_array( $item ) && in_array( $item['type'] ?? '', array( 'critical_config', 'vigilante_self' ), true );
8983 + }
8984 + )
8985 + );
8986 + $found = $found || ! empty( $kept[ $bucket ] );
8987 + }
8988 +
8989 + if ( $found ) {
8990 + $results = array_merge( $results, $kept );
8991 + update_option( 'vigilante_last_integrity_results', $results );
8992 + update_option( 'vigilante_last_integrity_scan', $scanned_at ? $scanned_at : time() );
8993 + }
8994 + }
8995 +
7424 8996 if ( $this->database ) {
7425 8997 $this->database->clear_file_hashes();
7426 8998 }
7427 8999
@@ -7441,14 +9013,49 @@
7441 9013 if ( ! current_user_can( 'manage_options' ) ) {
7442 9014 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
7443 9015 }
7444 9016
7445 - $file = isset( $_POST['file'] ) ? sanitize_text_field( wp_unslash( $_POST['file'] ) ) : '';
9017 + /*
9018 + * The path as the scan reported it, which is what the interface sends
9019 + * back. sanitize_text_field() is the wrong reader for it: it deletes
9020 + * every %XX octet and collapses repeated spaces, so a file named
9021 + * foto%20nueva.php was stored as fotonueva.php, the scan compared the
9022 + * list with the paths it finds one by one, never matched, and the
9023 + * warning came back (measured on 1 oct 2026 against 3.0.1). The four
9024 + * handlers of the ignore list read the path the same way.
9025 + *
9026 + * Known limits, found by the cross review, that only a file name with a
9027 + * stray < in it reaches. wp_strip_all_tags() shortens such a name at the
9028 + * < instead of refusing it, so the stored entry can match another file
9029 + * (a.php<b is stored as a.php), and the value ignore_file() returns is
9030 + * not read, so a refusal of its own is answered as success. The fix, if
9031 + * a site ever needs it, is to answer with an error when
9032 + * wp_strip_all_tags() changes what was sent and to check that return
9033 + * value. An entry the old reader stored for such a name (it wrote the
9034 + * < as &lt;) is listed but "Stop ignoring" cannot remove it; "Clear all
9035 + * ignored files" does.
9036 + */
9037 + $file = ( isset( $_POST['file'] ) && is_string( $_POST['file'] ) ) ? wp_strip_all_tags( wp_unslash( $_POST['file'] ) ) : '';
7446 9038
7447 9039 if ( empty( $file ) ) {
7448 9040 wp_send_json_error( __( 'No file specified.', 'vigilante' ) );
7449 9041 }
7450 9042
9043 + // A change to a shared file is closed by approving it, and approving it
9044 + // takes the network. Ignoring it would close the same warning without.
9045 + if ( $this->critical_approval_locked() && in_array( $file, array( 'wp-config.php', '.htaccess' ), true ) ) {
9046 + wp_send_json_error( $this->critical_approval_notice() );
9047 + }
9048 +
9049 + // Vigilant's own files are never ignored, on any site: silencing the
9050 + // check that says the security plugin was changed is the one button
9051 + // an attacker would want on this screen. Since 3.0.0 those findings
9052 + // are not rows of these tables either, so nothing in the interface
9053 + // sends them here; this is the door, not the label.
9054 + if ( Vigilante_Self_Integrity::is_own_file_path( $file ) ) {
9055 + wp_send_json_error( __( 'Findings about Vigilant own files cannot be ignored. File Integrity explains what each one means and how to repair it.', 'vigilante' ) );
9056 + }
9057 +
7451 9058 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database );
7452 9059 $file_integrity->ignore_file( $file );
7453 9060
7454 9061 // Also remove the file from stored scan results so UI updates
@@ -7481,9 +9088,9 @@
7481 9088 if ( ! current_user_can( 'manage_options' ) ) {
7482 9089 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
7483 9090 }
7484 9091
7485 - $file = isset( $_POST['file'] ) ? sanitize_text_field( wp_unslash( $_POST['file'] ) ) : '';
9092 + $file = ( isset( $_POST['file'] ) && is_string( $_POST['file'] ) ) ? wp_strip_all_tags( wp_unslash( $_POST['file'] ) ) : '';
7486 9093
7487 9094 if ( empty( $file ) ) {
7488 9095 wp_send_json_error( __( 'No file specified.', 'vigilante' ) );
7489 9096 }
@@ -7512,12 +9119,16 @@
7512 9119 if ( ! is_array( $raw_files ) ) {
7513 9120 wp_send_json_error( __( 'Invalid request.', 'vigilante' ) );
7514 9121 }
7515 9122
7516 - $files = array();
9123 + $files = array();
9124 + $locked = $this->critical_approval_locked();
9125 + $shared = $locked ? array( 'wp-config.php', '.htaccess' ) : array();
7517 9126 foreach ( $raw_files as $f ) {
7518 - $clean = sanitize_text_field( $f );
7519 - if ( '' !== $clean ) {
9127 + $clean = is_string( $f ) ? wp_strip_all_tags( $f ) : '';
9128 + // Same rule as ajax_ignore_file(): the two shared files when the
9129 + // network locks them, and Vigilant's own files always.
9130 + if ( '' !== $clean && ! in_array( $clean, $shared, true ) && ! Vigilante_Self_Integrity::is_own_file_path( $clean ) ) {
7520 9131 $files[] = $clean;
7521 9132 }
7522 9133 }
7523 9134
@@ -7581,9 +9192,9 @@
7581 9192 }
7582 9193
7583 9194 $files = array();
7584 9195 foreach ( $raw_files as $f ) {
7585 - $clean = sanitize_text_field( $f );
9196 + $clean = is_string( $f ) ? wp_strip_all_tags( $f ) : '';
7586 9197 if ( '' !== $clean ) {
7587 9198 $files[] = $clean;
7588 9199 }
7589 9200 }