PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.3
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.3
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 +1821 -114 2.11.2 → 3.0.3 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
@@ -444,11 +485,213 @@
444 485 $this->database->purge_for_2_11_0();
445 486
446 487 update_option( 'vigilante_db_version', '2.11.0' );
447 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 + }
448 603 }
449 604
450 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 + /**
451 694 * Migration: Remove orphaned email fields from saved options
452 695 *
453 696 * v1.10.0 centralized notification recipients into email section.
454 697 * Old per-module notify_email fields and dead email section fields
@@ -544,17 +787,30 @@
544 787 $pending_count = $this->get_pending_approvals_count();
545 788
546 789 // Get security issues with severity
547 790 $security_status = $this->get_security_status_for_badge();
548 -
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 +
549 805 // Total count for badge
550 - $total_badge = $pending_count + $security_status['count'];
551 -
806 + $total_badge = $pending_count + $security_status['count'] + $self_badge;
807 +
552 808 if ( $total_badge > 0 ) {
553 809 // Determine badge color:
554 810 // - Red (awaiting-mod): pending approvals OR critical modules disabled
555 811 // - Orange (update-plugins): only non-critical modules disabled
556 - 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 ) ) {
557 813 $badge_class = 'awaiting-mod';
558 814 } else {
559 815 $badge_class = 'update-plugins vigilante-badge-warning';
560 816 }
@@ -596,13 +852,21 @@
596 852 'vigilante-activity-log',
597 853 array( $this, 'redirect_to_tab' )
598 854 );
599 855
600 - // 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 + }
601 865 add_submenu_page(
602 866 'vigilante',
603 867 __( 'File Integrity', 'vigilante' ),
604 - __( 'File Integrity', 'vigilante' ),
868 + $fi_menu_title,
605 869 'manage_options',
606 870 'vigilante-file-integrity',
607 871 array( $this, 'redirect_to_tab' )
608 872 );
@@ -716,27 +980,56 @@
716 980 if ( ! did_action( 'plugins_loaded' ) ) {
717 981 return 0;
718 982 }
719 983
720 - $registration_approval = $this->settings->get_section( 'user_security' );
721 - $approval_settings = $registration_approval['registration_approval'] ?? array();
722 -
723 - if ( empty( $approval_settings['enabled'] ) ) {
724 - return 0;
725 - }
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 + */
726 990
727 991 // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Limited results in admin context.
728 - $pending_users = get_users( array(
729 - 'meta_key' => 'vigilante_pending_approval',
992 + $args = array(
993 + 'meta_key' => Vigilante_User_Security::site_user_meta_key( 'vigilante_pending_approval' ),
730 994 'meta_value' => '1',
731 995 'fields' => 'ID',
732 - ) );
996 + );
733 997 // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
734 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 +
735 1008 return count( $pending_users );
736 1009 }
737 1010
738 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 + /**
739 1032 * Calculate comprehensive security score (0-100)
740 1033 *
741 1034 * @param array $options Plugin options.
742 1035 * @return int Security score.
@@ -794,10 +1087,10 @@
794 1087 // Max attempts configured
795 1088 if ( isset( $login['max_attempts'] ) && $login['max_attempts'] <= 5 ) {
796 1089 $score += 3;
797 1090 }
798 - // XML-RPC disabled
799 - if ( ! empty( $login['disable_xmlrpc'] ) ) {
1091 + // XML-RPC blocked, whole or its pingback methods
1092 + if ( $this->is_xmlrpc_protected( $options ) ) {
800 1093 $score += 3;
801 1094 }
802 1095 // 2FA enabled
803 1096 if ( ! empty( $login['two_factor']['enabled'] ) ) {
@@ -856,8 +1149,28 @@
856 1149 $score += 3;
857 1150 }
858 1151 }
859 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 +
860 1173 // Environment checks (8 points) - penalize insecure server configuration
861 1174 $max_score += 8;
862 1175 $env_score = 8;
863 1176
@@ -873,9 +1186,14 @@
873 1186 }
874 1187
875 1188 $score += max( 0, $env_score );
876 1189
877 - 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;
878 1196 }
879 1197
880 1198 /**
881 1199 * Get security recommendations based on current settings
@@ -885,8 +1203,51 @@
885 1203 */
886 1204 private function get_security_recommendations( $options ) {
887 1205 $recommendations = array();
888 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 +
889 1250 // Critical: Firewall disabled
890 1251 if ( empty( $options['modules']['firewall'] ) ) {
891 1252 $recommendations[] = array(
892 1253 'icon' => 'warning',
@@ -969,14 +1330,14 @@
969 1330 );
970 1331 }
971 1332
972 1333 // Low: XML-RPC enabled (only if login security is active)
973 - if ( ! empty( $options['modules']['login_security'] ) && empty( $login['disable_xmlrpc'] ) ) {
1334 + if ( ! empty( $options['modules']['login_security'] ) && ! $this->is_xmlrpc_protected( $options ) ) {
974 1335 $recommendations[] = array(
975 1336 'icon' => 'info',
976 1337 'priority' => 'low',
977 1338 'message' => __( 'Disable XML-RPC if not needed (reduces attack surface).', 'vigilante' ),
978 - 'tab' => 'login',
1339 + 'tab' => 'wp-hardening',
979 1340 );
980 1341 }
981 1342
982 1343 // Low: Strong passwords not enforced (only if user security is active)
@@ -1289,8 +1650,9 @@
1289 1650 // File Integrity
1290 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' ) ),
1291 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' ) ),
1292 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' ) ),
1293 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' ) ),
1294 1656 // Security Audit
1295 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' ) ),
1296 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' ) ),
@@ -1443,13 +1805,17 @@
1443 1805 );
1444 1806
1445 1807 wp_localize_script( 'vigilante-admin', 'vigilanteAdmin', array(
1446 1808 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
1809 + 'selfBoxUrl' => esc_url( admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' ) ),
1447 1810 'nonce' => wp_create_nonce( 'vigilante_admin_nonce' ),
1448 1811 'currentUserId' => get_current_user_id(),
1449 1812 'logoutUrl' => wp_logout_url( wp_login_url() ),
1450 1813 'adminUrl' => admin_url( 'admin.php?page=vigilante' ),
1451 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(),
1452 1818 'underAttack' => array(
1453 1819 'active' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->is_active(),
1454 1820 'remaining' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->get_remaining_time(),
1455 1821 ),
@@ -1463,9 +1829,8 @@
1463 1829 'scanComplete' => __( 'Scan complete', 'vigilante' ),
1464 1830 'loading' => __( 'Loading...', 'vigilante' ),
1465 1831 'searching' => __( 'Searching...', 'vigilante' ),
1466 1832 'noUsersFound' => __( 'No users found', 'vigilante' ),
1467 - 'searchError' => __( 'Error searching users', 'vigilante' ),
1468 1833 'sending' => __( 'Sending...', 'vigilante' ),
1469 1834 'sendNotification' => __( 'Send notification now', 'vigilante' ),
1470 1835 'notificationsSent' => __( 'notifications sent', 'vigilante' ),
1471 1836 'skipped' => __( 'skipped', 'vigilante' ),
@@ -1484,19 +1849,24 @@
1484 1849 'modified' => __( 'Modified', 'vigilante' ),
1485 1850 'suspicious' => __( 'Suspicious', 'vigilante' ),
1486 1851 'totalScanned' => __( 'Total Scanned', 'vigilante' ),
1487 1852 'suspiciousFiles' => __( 'Suspicious Files', 'vigilante' ),
1488 - 'suspiciousWarning' => __( 'These files may contain malicious code or are in unexpected locations. Review immediately!', 'vigilante' ),
1853 + 'suspiciousWarning' => __( 'Files that can run code where none is expected, or that contain code written the way malware hides itself. Real infections show up here, and so do harmless files that plugins generate. Check the path and the reason before acting.', 'vigilante' ),
1489 1854 'file' => __( 'File', 'vigilante' ),
1490 1855 'reason' => __( 'Reason', 'vigilante' ),
1491 1856 'type' => __( 'Type', 'vigilante' ),
1492 1857 'unknown' => __( 'Unknown', 'vigilante' ),
1858 + 'selfType' => __( 'Vigilant (self)', 'vigilante' ),
1859 + 'logWhatHappened' => __( 'What happened', 'vigilante' ),
1860 + 'logWhatItMeans' => __( 'What it means', 'vigilante' ),
1861 + 'logWhatToDo' => __( 'What to do', 'vigilante' ),
1862 + 'logSelfSeeDetails' => __( 'Open File Integrity for the full detail', 'vigilante' ),
1493 1863 'modifiedFiles' => __( 'Modified Files', 'vigilante' ),
1494 - 'modifiedDescription' => __( 'These files (apparently) differ from the original WordPress or plugin versions.', 'vigilante' ),
1864 + 'modifiedDescription' => __( 'These files do not match the copy WordPress.org publishes for the installed version.', 'vigilante' ),
1495 1865 'extraFiles' => __( 'Extra Files', 'vigilante' ),
1496 1866 'extra' => __( 'Extra', 'vigilante' ),
1497 1867 'ignored' => __( 'Ignored', 'vigilante' ),
1498 - 'extraDescription' => __( 'PHP files found in plugins or themes that are not part of the original distribution from WordPress.org.', 'vigilante' ),
1868 + 'extraDescription' => __( 'Files that are not part of WordPress, or of the plugin or theme they sit in. Many are legitimate, and an injected file looks the same from here, so confirm where each one comes from.', 'vigilante' ),
1499 1869 'actions' => __( 'Actions', 'vigilante' ),
1500 1870 'ignore' => __( 'Ignore', 'vigilante' ),
1501 1871 'ignoring' => __( 'Ignoring...', 'vigilante' ),
1502 1872 'fileIgnored' => __( 'File added to ignored list.', 'vigilante' ),
@@ -1504,9 +1874,8 @@
1504 1874 'confirmClearIgnored' => __( 'Remove all files from the ignored list? They will appear in scan results again.', 'vigilante' ),
1505 1875 'ignoredCleared' => __( 'Ignored files list cleared. Page will reload...', 'vigilante' ),
1506 1876 'selectAll' => __( 'Select all', 'vigilante' ),
1507 1877 'bulkIgnoreSelected' => __( 'Ignore selected', 'vigilante' ),
1508 - 'bulkUnignoreSelected'=> __( 'Stop ignoring selected', 'vigilante' ),
1509 1878 'bulkNoSelection' => __( 'Select at least one file first.', 'vigilante' ),
1510 1879 'bulkConfirmIgnore' => __( 'Ignore the selected files? They will be hidden from future scan results until you remove them from the ignored list.', 'vigilante' ),
1511 1880 'bulkConfirmUnignore' => __( 'Remove the selected files from the ignored list? They will appear in scan results again.', 'vigilante' ),
1512 1881 'bulkProcessing' => __( 'Processing...', 'vigilante' ),
@@ -1511,18 +1880,22 @@
1511 1880 'bulkConfirmUnignore' => __( 'Remove the selected files from the ignored list? They will appear in scan results again.', 'vigilante' ),
1512 1881 'bulkProcessing' => __( 'Processing...', 'vigilante' ),
1513 1882 /* translators: %d: number of files selected for bulk action. */
1514 1883 'bulkSelectedCount' => __( '%d selected', 'vigilante' ),
1515 - 'allClear' => __( 'All files verified - no issues found!', 'vigilante' ),
1884 + 'allClear' => __( 'Good job! Nothing to report in the files that were checked.', 'vigilante' ),
1516 1885 'criticalConfigTitle' => __( 'Critical config files modified', 'vigilante' ),
1517 - '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' ),
1886 + 'criticalConfigDesc' => __( 'These files changed since you last approved them. They are a common target for code injection, and they are also rewritten by cache, security and redirection plugins, often on every update. Review the changes and approve them if they belong to something you use: approving records the current content as the reference and does not touch the file. Vigilant\'s own blocks are excluded from this check, and Vigilant never restores these files by itself.', 'vigilante' ),
1518 1887 'approve' => __( 'Approve', 'vigilante' ),
1519 1888 'approving' => __( 'Approving...', 'vigilante' ),
1889 + 'approvalLockedNotice' => $this->critical_approval_notice(),
1520 1890 'criticalApproved' => __( 'Change approved. Next scan will use the current state as baseline.', 'vigilante' ),
1521 1891 'reviewChanges' => __( 'Review changes', 'vigilante' ),
1522 1892 'hideChanges' => __( 'Hide changes', 'vigilante' ),
1523 1893 'changes' => __( 'Changes', 'vigilante' ),
1524 1894 'diffUnavailable' => __( 'Diff not available for this file (baseline was created before diff tracking was added). Approve to enable diff on future changes.', 'vigilante' ),
1895 + 'diffNetwork' => __( 'This file belongs to the whole network, so its line changes are only shown to network administrators, on the main site.', 'vigilante' ),
1896 + 'diffRescan' => __( 'Run a new scan to see the line changes of this file.', 'vigilante' ),
1897 + 'diffRedaction' => __( 'The line changes of this file are not shown because a value in it could not be hidden safely. The change itself is still detected.', 'vigilante' ),
1525 1898 'diffEmpty' => __( 'No line-level changes detected (may be whitespace or reordering).', 'vigilante' ),
1526 1899 'diffLines' => __( 'lines', 'vigilante' ),
1527 1900 // Under Attack mode strings
1528 1901 'underAttackConfirmActivate' => __( 'Activate Under Attack mode? All visitors will see a verification page for the next 4 hours.', 'vigilante' ),
@@ -1604,13 +1977,10 @@
1604 1977 'noLogsToExport' => __( 'No logs to export', 'vigilante' ),
1605 1978 'exportFailed' => __( 'Export failed', 'vigilante' ),
1606 1979 'exportLogs' => __( 'Export Logs', 'vigilante' ),
1607 1980 // Backup strings
1608 - 'backupCreated' => __( 'Backup created successfully.', 'vigilante' ),
1609 1981 'createBackupNow' => __( 'Download Backup', 'vigilante' ),
1610 1982 'downloadBackup' => __( 'Download Backup (.zip)', 'vigilante' ),
1611 - /* translators: %d: number of tables */
1612 - 'tablesCount' => __( '%d tables', 'vigilante' ),
1613 1983 /* translators: 1: table count, 2: human-readable size */
1614 1984 'dbTablesTotal' => __( '%1$d tables total (%2$s)', 'vigilante' ),
1615 1985 /* translators: 1: selected count, 2: human-readable size */
1616 1986 'dbTablesSelected' => __( '%1$d tables selected (%2$s)', 'vigilante' ),
@@ -1617,9 +1987,8 @@
1617 1987 // Settings search strings
1618 1988 'searchNoResults' => __( 'No matching settings found.', 'vigilante' ),
1619 1989 /* translators: %d: number of results that did not fit in the list. */
1620 1990 'searchMoreResults' => __( '%d more results. Refine the search to see them.', 'vigilante' ),
1621 - 'searchInTab' => __( 'in', 'vigilante' ),
1622 1991 // Modules string
1623 1992 /* translators: 1: enabled count, 2: total count */
1624 1993 'modulesEnabled' => __( '%1$d / %2$d modules enabled', 'vigilante' ),
1625 1994 // Activity log label maps for JS rendering
@@ -1624,8 +1993,9 @@
1624 1993 'modulesEnabled' => __( '%1$d / %2$d modules enabled', 'vigilante' ),
1625 1994 // Activity log label maps for JS rendering
1626 1995 'eventTypeLabels' => array(
1627 1996 'login' => __( 'Login', 'vigilante' ),
1997 + '2fa' => __( 'Two-factor', 'vigilante' ),
1628 1998 'user' => __( 'User', 'vigilante' ),
1629 1999 'content' => __( 'Content', 'vigilante' ),
1630 2000 'plugin' => __( 'Plugin', 'vigilante' ),
1631 2001 'theme' => __( 'Theme', 'vigilante' ),
@@ -1691,10 +2061,8 @@
1691 2061 /* translators: 1: first item number, 2: last item number, 3: total items */
1692 2062 'paginationOf' => __( '%1$d–%2$d of %3$d', 'vigilante' ),
1693 2063 'paginationEmpty' => __( '0 items', 'vigilante' ),
1694 2064 // Security Analyzer strings
1695 - 'analyzerScanNow' => __( 'Scan now', 'vigilante' ),
1696 - 'analyzerScanning' => __( 'Scanning…', 'vigilante' ),
1697 2065 'analyzerFastPhase' => __( 'Running fast checks…', 'vigilante' ),
1698 2066 'analyzerSlowPhase' => __( 'Running remote checks…', 'vigilante' ),
1699 2067 'analyzerScanComplete' => __( 'Security scan complete.', 'vigilante' ),
1700 2068 'analyzerScanFailed' => __( 'Security scan failed.', 'vigilante' ),
@@ -1700,11 +2068,8 @@
1700 2068 'analyzerScanFailed' => __( 'Security scan failed.', 'vigilante' ),
1701 2069 'analyzerShowDetails' => __( 'Show detailed breakdown', 'vigilante' ),
1702 2070 'analyzerHideDetails' => __( 'Hide detailed breakdown', 'vigilante' ),
1703 2071 'analyzerGoToSetting' => __( 'Go to setting', 'vigilante' ),
1704 - 'analyzerNoData' => __( 'No data yet — run a scan to populate this category.', 'vigilante' ),
1705 - 'analyzerJustNow' => __( 'just now', 'vigilante' ),
1706 - 'analyzerAgo' => __( 'ago', 'vigilante' ),
1707 2072 'analyzerSettingsSaved' => __( 'Analyzer settings saved.', 'vigilante' ),
1708 2073 'analyzerLastScanJustNow' => __( 'Last scan just now', 'vigilante' ),
1709 2074 'analyzerQualityExcellent' => __( 'Excellent', 'vigilante' ),
1710 2075 'analyzerQualityGood' => __( 'Good', 'vigilante' ),
@@ -1710,9 +2075,8 @@
1710 2075 'analyzerQualityGood' => __( 'Good', 'vigilante' ),
1711 2076 'analyzerQualityFair' => __( 'Fair', 'vigilante' ),
1712 2077 'analyzerQualityPoor' => __( 'Poor', 'vigilante' ),
1713 2078 'analyzerQualityCritical' => __( 'Critical', 'vigilante' ),
1714 - 'analyzerPts' => __( 'pts', 'vigilante' ),
1715 2079 'analyzerLearnMore' => __( 'Learn more', 'vigilante' ),
1716 2080 'analyzerInfoAllClear' => __( 'All clear', 'vigilante' ),
1717 2081 /* translators: %d: number of findings in an info-only category */
1718 2082 'analyzerInfoFindings' => __( '%d findings', 'vigilante' ),
@@ -1738,9 +2102,89 @@
1738 2102
1739 2103 /**
1740 2104 * Show admin notices
1741 2105 */
2106 + /**
2107 + * Self-protection notice.
2108 + *
2109 + * A change to Vigilant own files is shown on every admin screen, because
2110 + * waiting for someone to open the plugin is exactly what an attacker who
2111 + * patched it would want. A warning is shown only on Vigilant screens, so
2112 + * the notice that matters is not diluted. Neither is dismissible, both are
2113 + * for administrators only, and on a network the steps depend on whether
2114 + * the person can install plugins at all.
2115 + */
2116 + public function maybe_show_self_protection_notice() {
2117 + if ( ! current_user_can( 'manage_options' ) ) {
2118 + return;
2119 + }
2120 + $summary = $this->self_integrity_summary();
2121 + $tone = $summary['tone'];
2122 + if ( ! in_array( $tone, array( 'critical', 'off', 'warning' ), true ) ) {
2123 + return;
2124 + }
2125 +
2126 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the screen slug to decide where a notice is shown; no action taken.
2127 + $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
2128 + $on_vigilante = ( 0 === strpos( $page, 'vigilante' ) );
2129 + if ( 'warning' === $tone && ! $on_vigilante ) {
2130 + return;
2131 + }
2132 + $alarma = in_array( $tone, array( 'critical', 'off' ), true );
2133 +
2134 + $link = admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' );
2135 + $network = is_multisite() && ! current_user_can( 'update_plugins' );
2136 + ?>
2137 + <div class="notice notice-<?php echo $alarma ? 'error' : 'warning'; ?> vigilante-self-notice">
2138 + <p class="vigilante-self-notice-title">
2139 + <span class="dashicons dashicons-shield" aria-hidden="true"></span>
2140 + <strong>
2141 + <?php
2142 + if ( 'critical' === $tone ) {
2143 + esc_html_e( 'Vigilant detected changes in its own files', 'vigilante' );
2144 + } elseif ( 'off' === $tone ) {
2145 + esc_html_e( 'Vigilant self-protection is switched off by code', 'vigilante' );
2146 + } else {
2147 + echo esc_html( $this->self_integrity_headline( $tone, $summary['state'] ) );
2148 + }
2149 + ?>
2150 + </strong>
2151 + </p>
2152 + <p class="vigilante-self-notice-text">
2153 + <span>
2154 + <?php
2155 + if ( 'critical' === $tone ) {
2156 + esc_html_e( 'Your security plugin may have been tampered with, and while that is true nothing it reports can be trusted.', 'vigilante' );
2157 + } elseif ( 'off' === $tone ) {
2158 + esc_html_e( 'Nothing is checking that Vigilant own files are intact. File Integrity says which file switches it off.', 'vigilante' );
2159 + } else {
2160 + esc_html_e( 'File Integrity says what was found, what it means and what to do about it.', 'vigilante' );
2161 + }
2162 + if ( $network ) {
2163 + 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' );
2164 + }
2165 + ?>
2166 + </span>
2167 + <?php
2168 + /*
2169 + * One button, on the same line as the text it belongs to, and it
2170 + * leads to the whole story. Repairing from a notice, without
2171 + * seeing which files, what it means and what it will do, is
2172 + * asking someone to fix what they have not read: the repair
2173 + * button lives in the card, after all that.
2174 + */
2175 + ?>
2176 + <a class="button button-primary button-small" href="<?php echo esc_url( $link ); ?>">
2177 + <?php esc_html_e( 'See what to do', 'vigilante' ); ?>
2178 + </a>
2179 + </p>
2180 + </div>
2181 + <?php
2182 + }
2183 +
1742 2184 public function show_admin_notices() {
2185 + $this->maybe_show_self_protection_notice();
2186 +
1743 2187 // Activation notice
1744 2188 if ( get_transient( 'vigilante_activated' ) ) {
1745 2189 ?>
1746 2190 <div class="notice notice-success is-dismissible vigilante-activation-notice">
@@ -2031,8 +2475,41 @@
2031 2475 return ! Vigilante_Settings::can_write_shared_files();
2032 2476 }
2033 2477
2034 2478 /**
2479 + * Whether this is the main site and the user cannot change what it builds the shared files from
2480 + *
2481 + * See Vigilante_Settings::get_main_site_file_settings(). On a subsite those
2482 + * settings only act on that site, so they are never locked there.
2483 + *
2484 + * @since 2.11.6
2485 + *
2486 + * @return bool
2487 + */
2488 + private function main_site_files_locked() {
2489 + return $this->shared_files_locked() && Vigilante_Settings::owns_shared_files();
2490 + }
2491 +
2492 + /**
2493 + * Sentence added to a bulk change when some settings were left as they were
2494 + *
2495 + * Importing a file, applying a preset and restoring the defaults touch every
2496 + * section at once, so the user is told that the shared file settings did
2497 + * not move.
2498 + *
2499 + * @since 2.11.6
2500 + *
2501 + * @return string Empty when the user can change every setting.
2502 + */
2503 + private function locked_file_settings_message() {
2504 + if ( ! Vigilante_Settings::get_locked_file_settings() ) {
2505 + return '';
2506 + }
2507 +
2508 + return ' ' . __( 'The settings that end up in wp-config.php or .htaccess were left as they were.', 'vigilante' ) . ' ' . Vigilante_Settings::get_shared_files_notice();
2509 + }
2510 +
2511 + /**
2035 2512 * Print the shared-files notice for a section that cannot be edited here
2036 2513 *
2037 2514 * @since 2.9.8
2038 2515 */
@@ -2058,10 +2535,46 @@
2058 2535 *
2059 2536 * @since 2.10.4
2060 2537 * @return bool
2061 2538 */
2539 + private function forwarded_chain_readings() {
2540 + // Shown, not decided on: the firewall resolves the address elsewhere.
2541 + $chain = Vigilante_IP_Utils::trusted_forwarded_for();
2542 +
2543 + if ( '' === $chain ) {
2544 + return array();
2545 + }
2546 +
2547 + $public = array();
2548 +
2549 + foreach ( explode( ',', $chain ) as $entry ) {
2550 + $address = Vigilante_IP_Utils::unmap_ipv4( trim( $entry ) );
2551 +
2552 + if ( filter_var( $address, FILTER_VALIDATE_IP ) && ! Vigilante_IP_Utils::is_own_network( $address ) ) {
2553 + $public[] = $address;
2554 + }
2555 + }
2556 +
2557 + if ( count( $public ) < 2 ) {
2558 + return array();
2559 + }
2560 +
2561 + return array(
2562 + 'now' => Vigilante_IP_Utils::client_from_chain( $chain ),
2563 + 'before' => $public[0],
2564 + );
2565 + }
2566 +
2567 + /**
2568 + * Whether the user tools of this screen are out of reach for this user
2569 + *
2570 + * @return bool
2571 + */
2062 2572 private function user_actions_locked() {
2063 - return is_multisite() && ! current_user_can( 'manage_network_users' );
2573 + // On a single site edit_user maps to edit_users, which a custom role with
2574 + // manage_options may lack: since 2.11.8 approving and rejecting a pending
2575 + // registration ask for it, so the buttons have to say so there too.
2576 + return is_multisite() ? ! current_user_can( 'manage_network_users' ) : ! current_user_can( 'edit_users' );
2064 2577 }
2065 2578
2066 2579 /**
2067 2580 * Print the notice for user tools that cannot be used from this site
@@ -2073,14 +2586,47 @@
2073 2586 return;
2074 2587 }
2075 2588 ?>
2076 2589 <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;">
2590 + <?php if ( is_multisite() ) : ?>
2077 2591 <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>
2592 + <?php else : ?>
2593 + <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>
2594 + <?php endif; ?>
2078 2595 </div>
2079 2596 <?php
2080 2597 }
2081 2598
2082 2599 /**
2600 + * Approving a change to the shared config files needs the network
2601 + *
2602 + * Since 2.11.3 the handler behind the Approve button asks for
2603 + * manage_network_options, because the two files it approves, wp-config.php
2604 + * and the root .htaccess, belong to the installation, and so does the
2605 + * record of them. The button, though, went on being painted for everybody,
2606 + * so the administrator of a subsite saw the warning, saw the button,
2607 + * pressed it and got "Permission denied" with no explanation. That is
2608 + * exactly what user_actions_locked() above exists to avoid, one release
2609 + * later and one screen over. Flagged by @calzbert.
2610 + *
2611 + * @since 2.11.4
2612 + * @return bool
2613 + */
2614 + private function critical_approval_locked() {
2615 + return is_multisite() && ! current_user_can( 'manage_network_options' );
2616 + }
2617 +
2618 + /**
2619 + * The line that replaces the Approve button where it cannot be used
2620 + *
2621 + * @since 2.11.4
2622 + * @return string
2623 + */
2624 + private function critical_approval_notice() {
2625 + 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' );
2626 + }
2627 +
2628 + /**
2083 2629 * Check if module is disabled and render warning
2084 2630 *
2085 2631 * @param string $module_key Module key.
2086 2632 * @return bool True if disabled.
@@ -2141,8 +2687,22 @@
2141 2687 $categories = isset( $last_scan['categories'] ) && is_array( $last_scan['categories'] ) ? $last_scan['categories'] : array();
2142 2688 $weekly_enabled = ! isset( $analyzer_settings['weekly_scan_enabled'] ) || ! empty( $analyzer_settings['weekly_scan_enabled'] );
2143 2689 $email_enabled = ! empty( $analyzer_settings['email_on_regression'] );
2144 2690
2691 + /*
2692 + * Self-protection caps the score, and it does so here and not only in
2693 + * the stored report: the report is reused until the next Security
2694 + * Check, so tampering found after it ran would otherwise be shown next
2695 + * to the score the site earned before it happened.
2696 + */
2697 + $self_summary = $this->self_integrity_summary();
2698 + $self_capped = in_array( $self_summary['tone'], array( 'critical', 'off' ), true )
2699 + || ( 'self_integrity' === ( isset( $last_scan['capped_by'] ) ? $last_scan['capped_by'] : '' ) );
2700 + if ( $self_capped && $has_data && $score > Vigilante_Security_Analyzer::SCORE_CAP_ON_TAMPER ) {
2701 + $score = Vigilante_Security_Analyzer::SCORE_CAP_ON_TAMPER;
2702 + $grade = 'E';
2703 + }
2704 +
2145 2705 $quality = self::analyzer_quality_tag( $score );
2146 2706 ?>
2147 2707 <div class="vigilante-analyzer" id="vigilante-analyzer"
2148 2708 data-has-data="<?php echo $has_data ? '1' : '0'; ?>">
@@ -2152,9 +2712,9 @@
2152 2712 <span class="dashicons dashicons-shield-alt" aria-hidden="true"></span>
2153 2713 <?php esc_html_e( 'Security Check', 'vigilante' ); ?>
2154 2714 </h2>
2155 2715 <p class="description">
2156 - <?php esc_html_e( 'On-demand audit of what an attacker would see right now, plus 13 internal checks impossible from the outside.', 'vigilante' ); ?>
2716 + <?php esc_html_e( 'On-demand audit of what an attacker would see right now, plus internal checks impossible from the outside.', 'vigilante' ); ?>
2157 2717 </p>
2158 2718 <p class="vigilante-analyzer-last-scan" data-role="ran-at">
2159 2719 <span class="dashicons dashicons-clock" aria-hidden="true"></span>
2160 2720 <?php echo esc_html( $ran_at_human ); ?>
@@ -2167,8 +2727,19 @@
2167 2727 </button>
2168 2728 </div>
2169 2729 </div>
2170 2730
2731 + <?php if ( $self_capped ) : ?>
2732 + <p class="vigilante-analyzer-capped">
2733 + <span class="dashicons dashicons-shield" aria-hidden="true"></span>
2734 + <strong><?php esc_html_e( 'This score is not reliable right now.', 'vigilante' ); ?></strong>
2735 + <?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' ); ?>
2736 + <a href="<?php echo esc_url( admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' ) ); ?>">
2737 + <?php esc_html_e( 'See what to do', 'vigilante' ); ?>
2738 + </a>
2739 + </p>
2740 + <?php endif; ?>
2741 +
2171 2742 <div class="vigilante-analyzer-summary">
2172 2743 <div class="vigilante-analyzer-score-card">
2173 2744 <?php if ( $has_data && $grade ) : ?>
2174 2745 <div class="vigilante-score-circle vigilante-grade-<?php echo esc_attr( strtolower( $grade ) ); ?>">
@@ -2542,8 +3113,58 @@
2542 3113
2543 3114 /**
2544 3115 * Render dashboard tab
2545 3116 */
3117 + /**
3118 + * One line strip at the top of the Dashboard tab: the state of Vigilant own
3119 + * files, above the Configuration Score and the Security Check, because
3120 + * nothing else on this screen means much while it is red.
3121 + */
3122 + private function render_self_protection_strip() {
3123 + $summary = $this->self_integrity_summary();
3124 + $tone = $summary['tone'];
3125 + $state = $summary['state'];
3126 + $count = count( $summary['findings'] );
3127 + $link = admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' );
3128 + ?>
3129 + <div class="vigilante-self-strip vigilante-self-strip--<?php echo esc_attr( $tone ); ?>">
3130 + <span class="dashicons dashicons-shield" aria-hidden="true"></span>
3131 + <strong><?php esc_html_e( 'Vigilant self-protection', 'vigilante' ); ?></strong>
3132 + <span class="vigilante-self-strip-state"><?php echo esc_html( $this->self_integrity_headline( $tone, $state ) ); ?></span>
3133 + <?php if ( $count > 0 ) : ?>
3134 + <span class="vigilante-self-strip-count">
3135 + <?php
3136 + printf(
3137 + /* translators: %d: number of findings about Vigilant own files */
3138 + esc_html( _n( '%d finding', '%d findings', $count, 'vigilante' ) ),
3139 + (int) $count
3140 + );
3141 + ?>
3142 + </span>
3143 + <?php elseif ( ! empty( $state['last_check'] ) && 'off' !== $tone ) : ?>
3144 + <span class="vigilante-self-strip-count">
3145 + <?php
3146 + printf(
3147 + /* translators: %s: human time difference, like "2 hours" */
3148 + esc_html__( 'checked %s ago', 'vigilante' ),
3149 + esc_html( human_time_diff( (int) $state['last_check'], time() ) )
3150 + );
3151 + ?>
3152 + </span>
3153 + <?php endif; ?>
3154 + <a href="<?php echo esc_url( $link ); ?>">
3155 + <?php
3156 + if ( in_array( $tone, array( 'critical', 'off', 'warning' ), true ) ) {
3157 + esc_html_e( 'See what to do', 'vigilante' );
3158 + } else {
3159 + esc_html_e( 'See details', 'vigilante' );
3160 + }
3161 + ?>
3162 + </a>
3163 + </div>
3164 + <?php
3165 + }
3166 +
2546 3167 private function render_tab_dashboard() {
2547 3168 $options = $this->settings->get_all_options();
2548 3169 $module_labels = $this->settings->get_module_labels();
2549 3170 $module_descriptions = $this->settings->get_module_descriptions();
@@ -2563,8 +3184,9 @@
2563 3184 $analyzer_categories_def = Vigilante_Security_Analyzer::get_categories();
2564 3185 $analyzer_settings = isset( $options['security_analyzer'] ) ? $options['security_analyzer'] : array();
2565 3186 ?>
2566 3187 <div class="vigilante-dashboard">
3188 + <?php $this->render_self_protection_strip(); ?>
2567 3189 <div class="vigilante-status-card">
2568 3190 <h2><?php esc_html_e( 'Configuration Score', 'vigilante' ); ?></h2>
2569 3191 <p class="vigilante-score-kind description">
2570 3192 <?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' ); ?>
@@ -2630,8 +3252,9 @@
2630 3252 <div class="vigilante-modules-list">
2631 3253 <?php foreach ( $options['modules'] as $module => $enabled ) :
2632 3254 $label = isset( $module_labels[ $module ] ) ? $module_labels[ $module ] : ucwords( str_replace( '_', ' ', $module ) );
2633 3255 $description = isset( $module_descriptions[ $module ] ) ? $module_descriptions[ $module ] : '';
3256 + $vg_module_locked = $this->main_site_files_locked() && in_array( $module, Vigilante_Settings::get_main_site_file_settings()['modules'], true );
2634 3257 ?>
2635 3258 <div class="vigilante-module-item <?php echo $enabled ? 'enabled' : 'disabled'; ?>">
2636 3259 <div class="vigilante-module-header">
2637 3260 <span class="vigilante-module-status"></span>
@@ -2644,8 +3267,9 @@
2644 3267 <input type="checkbox"
2645 3268 name="modules[<?php echo esc_attr( $module ); ?>]"
2646 3269 value="1"
2647 3270 <?php checked( $enabled ); ?>
3271 + <?php disabled( $vg_module_locked ); ?>
2648 3272 aria-label="<?php echo esc_attr( $toggle_label ); ?>"
2649 3273 data-module="<?php echo esc_attr( $module ); ?>">
2650 3274 <span class="vigilante-toggle-slider"></span>
2651 3275 </label>
@@ -2652,8 +3276,11 @@
2652 3276 </div>
2653 3277 <?php if ( $description ) : ?>
2654 3278 <p class="vigilante-module-desc"><?php echo esc_html( $description ); ?></p>
2655 3279 <?php endif; ?>
3280 + <?php if ( $vg_module_locked ) : ?>
3281 + <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>
3282 + <?php endif; ?>
2656 3283 </div>
2657 3284 <?php endforeach; ?>
2658 3285 </div>
2659 3286 </div>
@@ -3043,14 +3670,21 @@
3043 3670 <?php esc_html_e( 'Full page caching systems that serve cached pages before PHP executes (Varnish, LiteSpeed Cache, NGINX FastCGI Cache, Cloudflare APO) may bypass PHP-level firewall rules for cached requests. The .htaccess rules will still apply on Apache/LiteSpeed servers.', 'vigilante' ); ?>
3044 3671 </p>
3045 3672 </div>
3046 3673
3674 + <?php $vg_main_locked = $this->main_site_files_locked(); ?>
3675 + <?php if ( $vg_main_locked ) : ?>
3676 + <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;">
3677 + <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>
3678 + </div>
3679 + <?php endif; ?>
3680 +
3047 3681 <table class="form-table">
3048 3682 <tr>
3049 3683 <th scope="row"><?php esc_html_e( 'Block Bad Query Strings', 'vigilante' ); ?></th>
3050 3684 <td>
3051 3685 <label>
3052 - <input type="checkbox" name="firewall[block_bad_query_strings]" value="1" <?php checked( ! empty( $options['block_bad_query_strings'] ) ); ?>>
3686 + <input type="checkbox" name="firewall[block_bad_query_strings]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( ! empty( $options['block_bad_query_strings'] ) ); ?>>
3053 3687 <?php esc_html_e( 'Block malicious query string patterns', 'vigilante' ); ?>
3054 3688 </label>
3055 3689 </td>
3056 3690 </tr>
@@ -3093,11 +3727,12 @@
3093 3727 <tr>
3094 3728 <th scope="row"><?php esc_html_e( 'Block Bad Bots', 'vigilante' ); ?></th>
3095 3729 <td>
3096 3730 <label>
3097 - <input type="checkbox" name="firewall[block_bad_bots]" value="1" <?php checked( ! empty( $options['block_bad_bots'] ) ); ?>>
3731 + <input type="checkbox" name="firewall[block_bad_bots]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( ! empty( $options['block_bad_bots'] ) ); ?>>
3098 3732 <?php esc_html_e( 'Block known malicious bots and scanners', 'vigilante' ); ?>
3099 3733 </label>
3734 + <p class="description"><?php esc_html_e( 'The list also stops SEO crawlers such as Ahrefs, Semrush, Majestic and Screaming Frog, and command line clients such as curl, wget and python-requests. To let one of them through, add its name to the User-Agent Whitelist below. On Apache part of the list is applied in .htaccess, before WordPress loads, so those requests get a 403 and do not appear in the Security Audit.', 'vigilante' ); ?></p>
3100 3735 </td>
3101 3736 </tr>
3102 3737 </table>
3103 3738
@@ -3209,8 +3844,29 @@
3209 3844 </table>
3210 3845 </div>
3211 3846 <?php endif; ?>
3212 3847
3848 + <?php
3849 + // Since 2.11.8 X-Forwarded-For is read from its end, where the proxy
3850 + // writes. The administrator's own request shows whether that end is
3851 + // a CDN or a balancer for everybody here. Cross review of 2.11.8.
3852 + $xff_readings = $this->forwarded_chain_readings();
3853 + if ( $xff_readings ) :
3854 + ?>
3855 + <div id="vigilante-xff-chain-notice" class="notice notice-warning inline" style="margin:10px 0 16px;padding:8px 12px;">
3856 + <p style="margin:0;">
3857 + <?php
3858 + printf(
3859 + /* translators: 1: last address in the header, the one Vigilant reads, 2: first address in the header, which a visitor can write */
3860 + 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' ),
3861 + esc_html( $xff_readings['now'] ),
3862 + esc_html( $xff_readings['before'] )
3863 + );
3864 + ?>
3865 + </p>
3866 + </div>
3867 + <?php endif; ?>
3868 +
3213 3869 <h3><?php esc_html_e( 'IP Lists', 'vigilante' ); ?></h3>
3214 3870 <p class="description">
3215 3871 <?php
3216 3872 printf(
@@ -3224,9 +3880,9 @@
3224 3880 <tr>
3225 3881 <th scope="row"><label for="vigilante-f-firewall-trusted-proxy-header"><?php esc_html_e( 'Visitor IP detection', 'vigilante' ); ?></label></th>
3226 3882 <td>
3227 3883 <?php $proxy_header = $options['trusted_proxy_header'] ?? ''; ?>
3228 - <select id="vigilante-f-firewall-trusted-proxy-header" name="firewall[trusted_proxy_header]">
3884 + <select id="vigilante-f-firewall-trusted-proxy-header" name="firewall[trusted_proxy_header]" <?php disabled( $vg_main_locked ); ?>>
3229 3885 <option value="" <?php selected( $proxy_header, '' ); ?>><?php esc_html_e( 'Direct connection, only REMOTE_ADDR (recommended)', 'vigilante' ); ?></option>
3230 3886 <option value="cf-connecting-ip" <?php selected( $proxy_header, 'cf-connecting-ip' ); ?>><?php esc_html_e( 'Behind Cloudflare (CF-Connecting-IP)', 'vigilante' ); ?></option>
3231 3887 <option value="x-forwarded-for" <?php selected( $proxy_header, 'x-forwarded-for' ); ?>><?php esc_html_e( 'Behind a reverse proxy or load balancer (X-Forwarded-For)', 'vigilante' ); ?></option>
3232 3888 <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>
@@ -3236,11 +3892,23 @@
3236 3892 </p>
3237 3893 </td>
3238 3894 </tr>
3239 3895 <tr>
3896 + <th scope="row"><label for="vigilante-f-firewall-trusted-proxies"><?php esc_html_e( 'Trusted proxy IPs', 'vigilante' ); ?></label></th>
3897 + <td>
3898 + <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>
3899 + <p class="description">
3900 + <?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' ); ?>
3901 + <?php if ( in_array( $proxy_header, array( 'x-forwarded-for', 'x-real-ip' ), true ) && empty( $options['trusted_proxies'] ) ) : ?>
3902 + <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>
3903 + <?php endif; ?>
3904 + </p>
3905 + </td>
3906 + </tr>
3907 + <tr>
3240 3908 <th scope="row"><label for="vigilante-f-firewall-ip-whitelist"><?php esc_html_e( 'IP Whitelist', 'vigilante' ); ?></label></th>
3241 3909 <td>
3242 - <textarea id="vigilante-f-firewall-ip-whitelist" name="firewall[ip_whitelist]" rows="4" class="large-text code" placeholder="192.168.1.50&#10;192.168.1.0/24&#10;192.168.1.*"><?php echo esc_textarea( implode( "\n", $options['ip_whitelist'] ?? array() ) ); ?></textarea>
3910 + <textarea id="vigilante-f-firewall-ip-whitelist" name="firewall[ip_whitelist]" <?php disabled( $vg_main_locked ); ?> rows="4" class="large-text code" placeholder="192.168.1.50&#10;192.168.1.0/24&#10;192.168.1.*"><?php echo esc_textarea( implode( "\n", $options['ip_whitelist'] ?? array() ) ); ?></textarea>
3243 3911 <p class="description">
3244 3912 <?php esc_html_e( 'One IP per line. These IPs bypass the firewall checks, and they also reach wp-admin when the login URL is hidden, so remote managers such as MainWP or ManageWP are not turned away with a 404. The hidden login form itself stays hidden for every IP, this one included.', 'vigilante' ); ?>
3245 3913 <br>
3246 3914 <?php
@@ -3279,9 +3947,9 @@
3279 3947 <table class="form-table">
3280 3948 <tr>
3281 3949 <th scope="row"><label for="vigilante-f-firewall-ua-whitelist"><?php esc_html_e( 'User-Agent Whitelist', 'vigilante' ); ?></label></th>
3282 3950 <td>
3283 - <textarea id="vigilante-f-firewall-ua-whitelist" name="firewall[ua_whitelist]" rows="4" class="large-text code"><?php echo esc_textarea( implode( "\n", $options['ua_whitelist'] ?? array() ) ); ?></textarea>
3951 + <textarea id="vigilante-f-firewall-ua-whitelist" name="firewall[ua_whitelist]" <?php disabled( $vg_main_locked ); ?> rows="4" class="large-text code"><?php echo esc_textarea( implode( "\n", $options['ua_whitelist'] ?? array() ) ); ?></textarea>
3284 3952 <p class="description"><?php esc_html_e( 'One User-Agent per line. These will bypass all firewall checks. Example: ManageWP, MainWP, UptimeRobot.', 'vigilante' ); ?></p>
3285 3953 </td>
3286 3954 </tr>
3287 3955 <tr>
@@ -3377,9 +4045,9 @@
3377 4045 <th scope="row"><?php esc_html_e( 'Limit HTTP Methods', 'vigilante' ); ?></th>
3378 4046 <td>
3379 4047 <label>
3380 4048 <input type="checkbox" name="firewall[limit_http_methods]" value="1" <?php checked( ! empty( $options['limit_http_methods'] ) ); ?>>
3381 - <?php esc_html_e( 'Allow only GET, POST, HEAD (blocks PUT, DELETE, TRACE, etc.)', 'vigilante' ); ?>
4049 + <?php esc_html_e( 'Block the HTTP methods WordPress does not use (TRACE, TRACK, CONNECT and any unknown one). GET, POST, HEAD, OPTIONS, PUT, PATCH and DELETE keep working.', 'vigilante' ); ?>
3382 4050 </label>
3383 4051 </td>
3384 4052 </tr>
3385 4053 </table>
@@ -3779,8 +4447,9 @@
3779 4447 <tr>
3780 4448 <th scope="row"><?php esc_html_e( 'Enforce for roles', 'vigilante' ); ?></th>
3781 4449 <td>
3782 4450 <div class="vigilante-2fa-roles">
4451 + <?php $this->list_sentinel( 'login_security[two_factor][enforced_roles]' ); ?>
3783 4452 <?php foreach ( $all_roles as $role_slug => $role_data ) :
3784 4453 $user_count = count( get_users( array( 'role' => $role_slug, 'fields' => 'ID' ) ) );
3785 4454 ?>
3786 4455 <label>
@@ -3808,8 +4477,9 @@
3808 4477 autocomplete="off">
3809 4478 <div class="vigilante-2fa-search-results"></div>
3810 4479 </div>
3811 4480 <div class="vigilante-2fa-excluded-users">
4481 + <?php $this->list_sentinel( 'login_security[two_factor][excluded_users]' ); ?>
3812 4482 <?php
3813 4483 foreach ( $excluded as $user_id ) :
3814 4484 $user = get_user_by( 'ID', $user_id );
3815 4485 if ( ! $user ) continue;
@@ -3990,9 +4660,9 @@
3990 4660 ?>
3991 4661 <div class="vigilante-settings-section" id="vigilante-headers-recovery">
3992 4662 <h2><?php esc_html_e( 'Recover your previous header settings', 'vigilante' ); ?></h2>
3993 4663 <p>
3994 - <?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' ); ?>
4664 + <?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' ); ?>
3995 4665 </p>
3996 4666 <?php if ( $taken ) : ?>
3997 4667 <p class="description">
3998 4668 <?php
@@ -4113,8 +4783,9 @@
4113 4783 <label>
4114 4784 <input type="checkbox" name="security_headers[csp][enabled]" value="1" <?php checked( ! empty( $options['csp']['enabled'] ) ); ?>>
4115 4785 <?php esc_html_e( 'Enable Content Security Policy', 'vigilante' ); ?>
4116 4786 </label>
4787 + <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>
4117 4788 </td>
4118 4789 </tr>
4119 4790 <tr>
4120 4791 <th scope="row"><?php esc_html_e( 'Report Only Mode', 'vigilante' ); ?></th>
@@ -4465,8 +5136,9 @@
4465 5136 </tr>
4466 5137 <tr>
4467 5138 <th scope="row"><?php esc_html_e( 'Apply Password Rules To', 'vigilante' ); ?></th>
4468 5139 <td>
5140 + <?php $this->list_sentinel( 'user_security[password_policy][affected_roles]' ); ?>
4469 5141 <?php foreach ( wp_roles()->get_names() as $role_slug => $role_name ) : ?>
4470 5142 <label style="display:block;margin-bottom:5px;">
4471 5143 <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 ) ); ?>>
4472 5144 <?php echo esc_html( translate_user_role( $role_name ) ); ?>
@@ -4601,8 +5273,16 @@
4601 5273 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
4602 5274 </h2>
4603 5275 <p><?php esc_html_e( 'Limit the number of simultaneous sessions per user.', 'vigilante' ); ?></p>
4604 5276
5277 + <?php if ( Vigilante_User_Security::session_limit_is_network_wide() ) : ?>
5278 + <div class="notice notice-warning inline">
5279 + <p>
5280 + <?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' ); ?>
5281 + </p>
5282 + </div>
5283 + <?php endif; ?>
5284 +
4605 5285 <table class="form-table">
4606 5286 <tr>
4607 5287 <th scope="row"><?php esc_html_e( 'Enable Session Limits', 'vigilante' ); ?></th>
4608 5288 <td>
@@ -4698,8 +5378,9 @@
4698 5378 <td>
4699 5379 <?php
4700 5380 $affected_roles = $password_exp['affected_roles'] ?? array( 'administrator', 'editor' );
4701 5381 $all_roles = wp_roles()->get_names();
5382 + $this->list_sentinel( 'user_security[password_expiration][affected_roles]' );
4702 5383 foreach ( $all_roles as $role_slug => $role_name ) :
4703 5384 ?>
4704 5385 <label style="display: block; margin-bottom: 5px;">
4705 5386 <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 ) ); ?>>
@@ -4721,8 +5402,9 @@
4721 5402 autocomplete="off">
4722 5403 <div class="vigilante-pwexp-search-results"></div>
4723 5404 </div>
4724 5405 <div class="vigilante-pwexp-excluded-users">
5406 + <?php $this->list_sentinel( 'user_security[password_expiration][excluded_users]' ); ?>
4725 5407 <?php
4726 5408 foreach ( $pwexp_excluded as $pwexp_excluded_id ) :
4727 5409 $excluded_user = get_user_by( 'ID', $pwexp_excluded_id );
4728 5410 if ( ! $excluded_user ) {
@@ -4942,9 +5624,17 @@
4942 5624 </div>
4943 5625
4944 5626 <!-- Pending Registrations -->
4945 5627 <?php
4946 - $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log );
5628 + // Enforcement-only: this instance exists to read the queue, and the
5629 + // flag keeps it from registering the module's own hooks a second
5630 + // time. It is not inert, and saying it was would be a false comment:
5631 + // init_enforcement_hooks() does add its three filters again, on top
5632 + // of the ones already registered. They are idempotent (the same
5633 + // methods of an equivalent instance, deciding on the same user meta),
5634 + // so running them twice in an admin request changes nothing, which is
5635 + // why this is accepted rather than worked around.
5636 + $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log, true );
4947 5637 $pending_users = $user_security->get_pending_users();
4948 5638 ?>
4949 5639 <div id="vigilante-section-users-pending" class="vigilante-tool-box vigilante-pending-users-section">
4950 5640 <h3>
@@ -4953,9 +5643,20 @@
4953 5643 <span class="vigilante-badge vigilante-badge-warning"><?php echo esc_html( count( $pending_users ) ); ?></span>
4954 5644 <?php endif; ?>
4955 5645 </h3>
4956 5646
4957 - <?php if ( empty( $registration['enabled'] ) ) : ?>
5647 + <?php
5648 + /*
5649 + * The queue is shown whenever there is somebody in it, even with
5650 + * the feature off. Since 2.11.10 an account already waiting stays
5651 + * blocked when the feature is switched off, which is the point:
5652 + * turning a setting off must not quietly let in people an
5653 + * administrator decided not to approve. But hiding the table then
5654 + * left them locked out with no button anywhere to approve or
5655 + * reject them. Found by the cross review of 2.11.10.
5656 + */
5657 + ?>
5658 + <?php if ( empty( $registration['enabled'] ) && empty( $pending_users ) ) : ?>
4958 5659 <p class="description">
4959 5660 <span class="dashicons dashicons-info" style="color: #72aee6;"></span>
4960 5661 <?php esc_html_e( 'Registration approval is disabled. Enable it in the settings above to require manual approval for new users.', 'vigilante' ); ?>
4961 5662 </p>
@@ -4964,8 +5665,9 @@
4964 5665 <span class="dashicons dashicons-yes-alt"></span>
4965 5666 <p><?php esc_html_e( 'No pending registrations.', 'vigilante' ); ?></p>
4966 5667 </div>
4967 5668 <?php else : ?>
5669 + <?php $this->render_user_actions_notice(); ?>
4968 5670 <table class="wp-list-table widefat fixed striped vigilante-pending-users-table">
4969 5671 <thead>
4970 5672 <tr>
4971 5673 <th><?php esc_html_e( 'User', 'vigilante' ); ?></th>
@@ -4975,9 +5677,9 @@
4975 5677 </tr>
4976 5678 </thead>
4977 5679 <tbody>
4978 5680 <?php foreach ( $pending_users as $pending_user ) :
4979 - $pending_since = get_user_meta( $pending_user->ID, 'vigilante_pending_since', true );
5681 + $pending_since = get_user_meta( $pending_user->ID, Vigilante_User_Security::site_user_meta_key( 'vigilante_pending_since' ), true );
4980 5682 ?>
4981 5683 <tr data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>">
4982 5684 <td>
4983 5685 <?php echo get_avatar( $pending_user->ID, 32 ); ?>
@@ -4994,12 +5696,12 @@
4994 5696 }
4995 5697 ?>
4996 5698 </td>
4997 5699 <td>
4998 - <button type="button" class="button button-small vigilante-approve-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>">
5700 + <button type="button" class="button button-small vigilante-approve-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>" <?php disabled( $this->user_actions_locked() ); ?>>
4999 5701 <?php esc_html_e( 'Approve', 'vigilante' ); ?>
5000 5702 </button>
5001 - <button type="button" class="button button-small vigilante-reject-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>" style="color: #d63638;">
5703 + <button type="button" class="button button-small vigilante-reject-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>" style="color: #d63638;" <?php disabled( $this->user_actions_locked() ); ?>>
5002 5704 <?php esc_html_e( 'Reject', 'vigilante' ); ?>
5003 5705 </button>
5004 5706 </td>
5005 5707 </tr>
@@ -5626,9 +6328,9 @@
5626 6328 <select id="vigilante-f-audit-alerts-immediate-min-severity" name="audit_alerts[immediate][min_severity]">
5627 6329 <option value="critical" <?php selected( $alert_severity, 'critical' ); ?>><?php esc_html_e( 'Critical only (recommended)', 'vigilante' ); ?></option>
5628 6330 <option value="warning" <?php selected( $alert_severity, 'warning' ); ?>><?php esc_html_e( 'Warning and Critical', 'vigilante' ); ?></option>
5629 6331 </select>
5630 - <p class="description"><?php esc_html_e( 'A new administrator, a closed plugin or a privilege escalation are all logged as Critical, so "Critical only" already covers them.', 'vigilante' ); ?></p>
6332 + <p class="description"><?php esc_html_e( 'A closed plugin is logged as Critical, so "Critical only" covers it. A new administrator and a change of role are logged as Warning: they have their own emails, in Users > Admin monitoring, and reach this alert only with "Warning and Critical".', 'vigilante' ); ?></p>
5631 6333 </td>
5632 6334 </tr>
5633 6335 <tr id="field-audit-alerts-threshold">
5634 6336 <th scope="row"><?php esc_html_e( 'Threshold alerts', 'vigilante' ); ?></th>
@@ -5721,8 +6423,9 @@
5721 6423
5722 6424 // Label maps for translated display
5723 6425 $type_labels = array(
5724 6426 'login' => __( 'Login', 'vigilante' ),
6427 + '2fa' => __( 'Two-factor', 'vigilante' ),
5725 6428 'user' => __( 'User', 'vigilante' ),
5726 6429 'content' => __( 'Content', 'vigilante' ),
5727 6430 'plugin' => __( 'Plugin', 'vigilante' ),
5728 6431 'theme' => __( 'Theme', 'vigilante' ),
@@ -5751,8 +6454,9 @@
5751 6454 <input type="text" id="vigilante-log-search" aria-label="<?php esc_attr_e( 'Search the activity log', 'vigilante' ); ?>" size="1" placeholder="<?php esc_attr_e( 'Search logs (min. 3 characters)...', 'vigilante' ); ?>" class="vigilante-log-search-input">
5752 6455 <select id="vigilante-log-type-filter" aria-label="<?php esc_attr_e( 'Filter the log by event type', 'vigilante' ); ?>">
5753 6456 <option value=""><?php esc_html_e( 'All Types', 'vigilante' ); ?></option>
5754 6457 <option value="login"><?php esc_html_e( 'Login', 'vigilante' ); ?></option>
6458 + <option value="2fa"><?php esc_html_e( 'Two-factor', 'vigilante' ); ?></option>
5755 6459 <option value="user"><?php esc_html_e( 'User', 'vigilante' ); ?></option>
5756 6460 <option value="content"><?php esc_html_e( 'Content', 'vigilante' ); ?></option>
5757 6461 <option value="plugin"><?php esc_html_e( 'Plugin', 'vigilante' ); ?></option>
5758 6462 <option value="theme"><?php esc_html_e( 'Theme', 'vigilante' ); ?></option>
@@ -5846,8 +6550,19 @@
5846 6550 'is_ip_blacklisted' => ( '' !== $ip_val && in_array( $ip_val, $ip_blacklist, true ) ),
5847 6551 'is_ua_whitelisted' => ( '' !== $ua_val && in_array( $ua_val, $ua_whitelist, true ) ),
5848 6552 'is_ua_blacklisted' => ( '' !== $ua_val && in_array( $ua_val, $ua_blacklist, true ) ),
5849 6553 );
6554 + // Self-protection entries carry what happened, what
6555 + // it means and what to do, from the same catalogue
6556 + // the File Integrity box uses.
6557 + $vg_self_event = Vigilante_Self_Integrity_Guidance::for_log_event(
6558 + (string) ( $log->event_action ?? '' ),
6559 + $log->extra_data ?? '',
6560 + (string) ( $log->severity ?? 'info' )
6561 + );
6562 + if ( null !== $vg_self_event ) {
6563 + $details['self'] = $vg_self_event;
6564 + }
5850 6565 $display_type = isset( $type_labels[ $log->event_type ] ) ? $type_labels[ $log->event_type ] : $log->event_type;
5851 6566 $display_severity = isset( $severity_labels[ $log->severity ] ) ? $severity_labels[ $log->severity ] : $log->severity;
5852 6567 ?>
5853 6568 <tr class="vigilante-severity-<?php echo esc_attr( $log->severity ); ?>">
@@ -5890,15 +6605,480 @@
5890 6605
5891 6606 /**
5892 6607 * Render File Integrity tab
5893 6608 */
6609 + /**
6610 + * Findings of the self-check grouped by the case that explains them, worst
6611 + * first: ten modified files are one case with ten paths, not ten copies of
6612 + * the same explanation.
6613 + *
6614 + * @param array $findings Findings from the state.
6615 + * @return array
6616 + */
6617 + private function self_findings_by_case( $findings ) {
6618 + $groups = array();
6619 + foreach ( (array) $findings as $finding ) {
6620 + if ( ! is_array( $finding ) || 'info' === ( $finding['severity'] ?? '' ) ) {
6621 + continue;
6622 + }
6623 + $guidance = Vigilante_Self_Integrity_Guidance::for_finding( $finding );
6624 + $key = $guidance['key'];
6625 + if ( ! isset( $groups[ $key ] ) ) {
6626 + $guidance['files'] = array();
6627 + $guidance['severity'] = 'warning';
6628 + $groups[ $key ] = $guidance;
6629 + }
6630 + $file = isset( $finding['file'] ) ? (string) $finding['file'] : '';
6631 + if ( '' !== $file && ! in_array( $file, $groups[ $key ]['files'], true ) ) {
6632 + $groups[ $key ]['files'][] = $file;
6633 + }
6634 + if ( 'critical' === ( $finding['severity'] ?? '' ) ) {
6635 + $groups[ $key ]['severity'] = 'critical';
6636 + }
6637 + }
6638 + uasort(
6639 + $groups,
6640 + function ( $a, $b ) {
6641 + $rank = array( 'critical' => 0, 'warning' => 1 );
6642 + $ra = isset( $rank[ $a['severity'] ] ) ? $rank[ $a['severity'] ] : 2;
6643 + $rb = isset( $rank[ $b['severity'] ] ) ? $rank[ $b['severity'] ] : 2;
6644 + return $ra - $rb;
6645 + }
6646 + );
6647 + return $groups;
6648 + }
6649 +
6650 + /**
6651 + * Human label for the context that ran the last self-check.
6652 + *
6653 + * @param string $context Stored context.
6654 + * @return string
6655 + */
6656 + private function self_context_label( $context ) {
6657 + switch ( (string) $context ) {
6658 + case 'scan':
6659 + return __( 'during a file integrity scan', 'vigilante' );
6660 + case 'upgrader':
6661 + return __( 'right after updating Vigilant', 'vigilante' );
6662 + case 'version_change':
6663 + return __( 'after a version change made outside the updater', 'vigilante' );
6664 + case 'migration':
6665 + return __( 'while updating to this version', 'vigilante' );
6666 + case 'activation':
6667 + return __( 'when Vigilant was activated', 'vigilante' );
6668 + case 'watchdog':
6669 + return __( 'from the scheduled task watchdog', 'vigilante' );
6670 + }
6671 + return '';
6672 + }
6673 +
6674 + /**
6675 + * Short line for the badge of the box, the notices and the Dashboard strip.
6676 + *
6677 + * @param string $tone Tone from Vigilante_Self_Integrity::tone().
6678 + * @param array $state State.
6679 + * @return string
6680 + */
6681 + private function self_integrity_headline( $tone, $state ) {
6682 + $files = isset( $state['files_checked'] ) ? (int) $state['files_checked'] : 0;
6683 + $anchors = ( isset( $state['anchors'] ) && is_array( $state['anchors'] ) ) ? $state['anchors'] : array();
6684 + switch ( $tone ) {
6685 + case 'critical':
6686 + return __( 'Changes detected in Vigilant own files', 'vigilante' );
6687 + case 'warning':
6688 + return ( $files < 1 )
6689 + ? __( 'Vigilant could not check its own files', 'vigilante' )
6690 + : __( 'Vigilant self-protection needs your attention', 'vigilante' );
6691 + case 'off':
6692 + return __( 'Self-protection is switched off by code', 'vigilante' );
6693 + case 'none':
6694 + return __( 'Vigilant has not checked its own files yet', 'vigilante' );
6695 + }
6696 + return sprintf(
6697 + /* translators: 1: number of files verified, 2: number of references available, out of three */
6698 + __( 'Verified: %1$d files, %2$d of 3 references', 'vigilante' ),
6699 + $files,
6700 + count( array_filter( $anchors ) )
6701 + );
6702 + }
6703 +
6704 + /**
6705 + * Vigilant self-protection box: the first block of the File Integrity
6706 + * results, with its own colour by severity, what each finding means and
6707 + * how to fix it.
6708 + *
6709 + * It renders whether or not a scan has been stored, because the self-check
6710 + * also runs after every update and once a day: before 3.0.0 this lived in
6711 + * a line above the numeric cards of the last scan, where it was invisible
6712 + * and, without a stored scan, absent.
6713 + *
6714 + * @param array $fi_options File Integrity settings section.
6715 + */
6716 + /**
6717 + * Everything the screens need to say about self-protection, read once:
6718 + * the state, its findings grouped by case, and the tone that colours the
6719 + * box, the menu counter and the notices.
6720 + *
6721 + * @param array|null $fi_options File Integrity settings, read if not given.
6722 + * @return array { state, findings, groups, tone, enabled }
6723 + */
6724 + private function self_integrity_summary( $fi_options = null ) {
6725 + // Four to six screens ask for this in the same page load (menu, notice,
6726 + // box, strip, both scores). The option is cached by the core options
6727 + // layer, but the grouping and the tone are not: memoize per request.
6728 + static $cached = null;
6729 + if ( null !== $cached && ! is_array( $fi_options ) ) {
6730 + return $cached;
6731 + }
6732 + if ( ! is_array( $fi_options ) ) {
6733 + $fi_options = (array) $this->settings->get_section( 'file_integrity' );
6734 + }
6735 + $enabled = Vigilante_Self_Integrity::is_on();
6736 + $state = Vigilante_Self_Integrity::display_state();
6737 + $findings = Vigilante_Self_Integrity::state_findings( $state, $enabled );
6738 + $summary = array(
6739 + 'state' => $state,
6740 + 'findings' => $findings,
6741 + 'groups' => $this->self_findings_by_case( $findings ),
6742 + 'tone' => Vigilante_Self_Integrity::tone( $state, $enabled ),
6743 + 'enabled' => $enabled,
6744 + );
6745 + $cached = $summary;
6746 + return $summary;
6747 + }
6748 +
6749 + private function render_self_protection_box( $fi_options ) {
6750 + $summary = $this->self_integrity_summary( $fi_options );
6751 + $enabled = $summary['enabled'];
6752 + $state = $summary['state'];
6753 + $findings = $summary['findings'];
6754 + $groups = $summary['groups'];
6755 + $tone = $summary['tone'];
6756 + $total = isset( $state['last_findings_total'] ) ? (int) $state['last_findings_total'] : count( $findings );
6757 + $status = array(
6758 + 'status' => isset( $state['last_status'] ) ? (string) $state['last_status'] : '',
6759 + 'files' => isset( $state['files_checked'] ) ? (int) $state['files_checked'] : 0,
6760 + 'anchors' => ( isset( $state['anchors'] ) && is_array( $state['anchors'] ) ) ? $state['anchors'] : array(),
6761 + 'enabled' => $enabled,
6762 + 'has_run' => ! empty( $state['last_check'] ),
6763 + );
6764 +
6765 + /*
6766 + * Nothing to do, nothing to open: one line. A card with a big icon and
6767 + * folded sections for "everything is fine" is furniture, and furniture
6768 + * is what made the previous version of this invisible.
6769 + */
6770 + if ( empty( $groups ) ) {
6771 + $this->render_self_line( $tone, $state, $status );
6772 + return;
6773 + }
6774 +
6775 + $datetime_format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' );
6776 + $context_label = $this->self_context_label( isset( $state['last_context'] ) ? $state['last_context'] : '' );
6777 + $icon = ( 'critical' === $tone ) ? 'dashicons-shield' : 'dashicons-warning';
6778 + ?>
6779 + <div id="vigilante-section-fi-self" class="vigilante-settings-section vigilante-self-box vigilante-self-box--<?php echo esc_attr( $tone ); ?>">
6780 + <div class="vigilante-self-card">
6781 + <div class="vigilante-self-head">
6782 + <span class="vigilante-self-icon"><span class="dashicons <?php echo esc_attr( $icon ); ?>" aria-hidden="true"></span></span>
6783 + <div class="vigilante-self-head-text">
6784 + <h2><?php echo esc_html( $this->self_integrity_headline( $tone, $state ) ); ?></h2>
6785 + <p class="vigilante-self-meta">
6786 + <?php
6787 + printf(
6788 + /* translators: %s: date and time of the last self-check */
6789 + esc_html__( 'Checked on %s', 'vigilante' ),
6790 + esc_html( wp_date( $datetime_format, (int) $state['last_check'] ) )
6791 + );
6792 + if ( '' !== $context_label ) {
6793 + echo ', ' . esc_html( $context_label );
6794 + }
6795 + if ( $status['files'] > 0 ) {
6796 + echo ', ';
6797 + printf(
6798 + /* translators: 1: number of files checked, 2: number of references available, out of three */
6799 + esc_html__( '%1$d files against %2$d of 3 references', 'vigilante' ),
6800 + (int) $status['files'],
6801 + (int) count( array_filter( $status['anchors'] ) )
6802 + );
6803 + }
6804 + ?>
6805 + </p>
6806 + </div>
6807 + </div>
6808 +
6809 + <div class="vigilante-self-cases">
6810 + <?php
6811 + $first = true;
6812 + foreach ( $groups as $group ) {
6813 + $this->render_self_case( $group, $group['files'], $group['severity'], $first );
6814 + $first = false;
6815 + }
6816 +
6817 + if ( in_array( $tone, array( 'critical', 'warning' ), true ) ) {
6818 + $can_repair = class_exists( 'Vigilante_Self_Repair' ) && Vigilante_Self_Repair::can_repair();
6819 + if ( ! $can_repair ) {
6820 + if ( class_exists( 'Vigilante_Self_Repair' ) && ! Vigilante_Self_Repair::folder_is_the_distributed_one() ) {
6821 + $vg_reason = 'renamed';
6822 + } elseif ( ! wp_is_file_mod_allowed( 'capability_update_core' ) ) {
6823 + // With DISALLOW_FILE_MODS nobody can do it from the admin,
6824 + // not even a network administrator: telling a subsite admin
6825 + // to ask theirs would send them to someone equally blocked.
6826 + $vg_reason = 'no_caps';
6827 + } elseif ( is_multisite() && ! current_user_can( 'update_plugins' ) ) {
6828 + $vg_reason = 'network';
6829 + } else {
6830 + $vg_reason = 'no_caps';
6831 + }
6832 + $this->render_self_case(
6833 + array(
6834 + 'title' => __( 'How to repair it here', 'vigilante' ),
6835 + 'meaning' => __( 'This site does not let Vigilant replace its own files from this screen.', 'vigilante' ),
6836 + 'steps' => Vigilante_Self_Integrity_Guidance::manual_steps( $vg_reason ),
6837 + ),
6838 + array(),
6839 + '',
6840 + false
6841 + );
6842 + }
6843 + }
6844 + ?>
6845 + </div>
6846 +
6847 + <?php if ( $total > count( $findings ) ) : ?>
6848 + <p class="description">
6849 + <?php
6850 + printf(
6851 + /* translators: 1: findings shown, 2: findings found in total */
6852 + esc_html__( 'Showing %1$d of %2$d findings: the rest are of the same kind.', 'vigilante' ),
6853 + (int) count( $findings ),
6854 + (int) $total
6855 + );
6856 + ?>
6857 + </p>
6858 + <?php endif; ?>
6859 +
6860 + <?php
6861 + /*
6862 + * Reinstalling fixes files, not a filter someone wrote or a hook
6863 + * someone removed: the button only appears when at least one of the
6864 + * cases on screen is one a clean copy solves. Offering it next to
6865 + * "switched off by code" was telling the person to fix something
6866 + * else.
6867 + */
6868 + $vg_offer_repair = false;
6869 + foreach ( $groups as $vg_group ) {
6870 + if ( ! empty( $vg_group['repair'] ) ) {
6871 + $vg_offer_repair = true;
6872 + break;
6873 + }
6874 + }
6875 + ?>
6876 + <p class="vigilante-self-actions">
6877 + <?php if ( $vg_offer_repair && class_exists( 'Vigilante_Self_Repair' ) && Vigilante_Self_Repair::can_repair() ) : ?>
6878 + <a class="button button-primary" href="<?php echo esc_url( Vigilante_Self_Repair::action_url() ); ?>">
6879 + <?php esc_html_e( 'Repair Vigilant', 'vigilante' ); ?>
6880 + </a>
6881 + <span class="description">
6882 + <?php esc_html_e( 'It downloads a clean copy from WordPress.org, asks before changing anything, and keeps your settings and log.', 'vigilante' ); ?>
6883 + </span>
6884 + <?php else : ?>
6885 + <span class="description">
6886 + <?php esc_html_e( 'After fixing it, run a new scan with the Run Scan Now button above.', 'vigilante' ); ?>
6887 + </span>
6888 + <?php endif; ?>
6889 + </p>
6890 + </div>
6891 + </div>
6892 + <?php
6893 + }
6894 +
6895 + /**
6896 + * The same state when there is nothing to act on: one line inside the same
6897 + * card, so the first block of the results is always the same object.
6898 + *
6899 + * @param string $tone Tone from Vigilante_Self_Integrity::tone().
6900 + * @param array $state State.
6901 + * @param array $status Arguments for the guidance catalogue.
6902 + */
6903 + private function render_self_line( $tone, $state, $status ) {
6904 + $guidance = Vigilante_Self_Integrity_Guidance::for_status( $status );
6905 + $icon = ( 'ok' === $tone ) ? 'dashicons-yes-alt' : 'dashicons-shield';
6906 + ?>
6907 + <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">
6908 + <div class="vigilante-self-card vigilante-self-card--quiet">
6909 + <p class="vigilante-self-line">
6910 + <span class="dashicons <?php echo esc_attr( $icon ); ?>" aria-hidden="true"></span>
6911 + <strong><?php esc_html_e( 'Vigilant self-protection:', 'vigilante' ); ?></strong>
6912 + <span class="vigilante-self-line-state"><?php echo esc_html( $this->self_integrity_headline( $tone, $state ) ); ?></span>
6913 + <?php if ( ! empty( $state['last_check'] ) ) : ?>
6914 + <span class="vigilante-self-line-meta">
6915 + <?php
6916 + printf(
6917 + /* translators: %s: human time difference, like "2 hours" */
6918 + esc_html__( 'checked %s ago', 'vigilante' ),
6919 + esc_html( human_time_diff( (int) $state['last_check'], time() ) )
6920 + );
6921 + ?>
6922 + </span>
6923 + <?php endif; ?>
6924 + </p>
6925 + <?php if ( ! empty( $guidance['steps'] ) && 'none' === $tone ) : ?>
6926 + <p class="description vigilante-self-line-help"><?php echo esc_html( $guidance['steps'][0] ); ?></p>
6927 + <?php endif; ?>
6928 + </div>
6929 + </div>
6930 + <?php
6931 + }
6932 +
6933 + /**
6934 + * One case of the self-protection box, as a row that opens: what it is, how
6935 + * many files, its severity, and inside, the files, what it means and what to
6936 + * do. Plain HTML details, so there is no JavaScript between a finding about
6937 + * the security plugin and the person reading it.
6938 + *
6939 + * @param array $guidance Guidance entry (title, meaning, steps).
6940 + * @param array $files Paths this case was found in.
6941 + * @param string $severity critical|warning, empty for the how-to-repair row.
6942 + * @param bool $open Whether the row starts open.
6943 + */
6944 + private function render_self_case( $guidance, $files = array(), $severity = '', $open = false ) {
6945 + $shown = array_slice( (array) $files, 0, 20 );
6946 + $hidden = count( (array) $files ) - count( $shown );
6947 + ?>
6948 + <details class="vigilante-self-case vigilante-self-case--<?php echo esc_attr( '' !== $severity ? $severity : 'plain' ); ?>"<?php echo $open ? ' open' : ''; ?>>
6949 + <summary>
6950 + <span class="vigilante-self-case-title"><?php echo esc_html( $guidance['title'] ); ?></span>
6951 + <?php if ( ! empty( $files ) ) : ?>
6952 + <span class="vigilante-self-case-count">
6953 + <?php
6954 + printf(
6955 + /* translators: %d: number of files of this finding */
6956 + esc_html( _n( '%d file', '%d files', count( (array) $files ), 'vigilante' ) ),
6957 + (int) count( (array) $files )
6958 + );
6959 + ?>
6960 + </span>
6961 + <?php endif; ?>
6962 + <?php if ( '' !== $severity ) : ?>
6963 + <span class="vigilante-self-case-severity">
6964 + <?php echo esc_html( 'critical' === $severity ? __( 'critical', 'vigilante' ) : __( 'warning', 'vigilante' ) ); ?>
6965 + </span>
6966 + <?php endif; ?>
6967 + </summary>
6968 + <div class="vigilante-self-case-body">
6969 + <?php if ( ! empty( $shown ) ) : ?>
6970 + <ul class="vigilante-self-paths">
6971 + <?php foreach ( $shown as $file ) : ?>
6972 + <li><code><?php echo esc_html( $file ); ?></code></li>
6973 + <?php endforeach; ?>
6974 + <?php if ( $hidden > 0 ) : ?>
6975 + <li>
6976 + <?php
6977 + printf(
6978 + /* translators: %d: number of additional files */
6979 + esc_html__( 'and %d more', 'vigilante' ),
6980 + (int) $hidden
6981 + );
6982 + ?>
6983 + </li>
6984 + <?php endif; ?>
6985 + </ul>
6986 + <?php endif; ?>
6987 + <p class="vigilante-self-meaning">
6988 + <strong><?php esc_html_e( 'What it means:', 'vigilante' ); ?></strong>
6989 + <?php echo esc_html( $guidance['meaning'] ); ?>
6990 + </p>
6991 + <?php if ( ! empty( $guidance['steps'] ) ) : ?>
6992 + <p class="vigilante-self-todo"><strong><?php esc_html_e( 'What to do:', 'vigilante' ); ?></strong></p>
6993 + <ol class="vigilante-self-steps">
6994 + <?php foreach ( $guidance['steps'] as $step ) : ?>
6995 + <li><?php echo esc_html( $step ); ?></li>
6996 + <?php endforeach; ?>
6997 + </ol>
6998 + <?php endif; ?>
6999 + </div>
7000 + </details>
7001 + <?php
7002 + }
7003 +
7004 + /**
7005 + * Print what each kind of finding in a list usually is and what to do
7006 + *
7007 + * One line per kind actually present in the list, from the single source
7008 + * of that text, Vigilante_File_Integrity::finding_guidance().
7009 + *
7010 + * @param array $items Findings of one section of the last scan.
7011 + */
7012 + private function render_fi_guidance( $items ) {
7013 + $seen = array();
7014 + $guides = array();
7015 +
7016 + foreach ( (array) $items as $item ) {
7017 + $type = is_array( $item ) ? (string) ( $item['type'] ?? '' ) : '';
7018 +
7019 + if ( '' === $type || 'vigilante_self' === $type || isset( $seen[ $type ] ) ) {
7020 + continue;
7021 + }
7022 + $seen[ $type ] = true;
7023 +
7024 + $guide = Vigilante_File_Integrity::finding_guidance( $type );
7025 +
7026 + if ( '' !== $guide['advice'] ) {
7027 + // A plugin and a theme share their text: one line for both,
7028 + // under the two names.
7029 + $guides[ $guide['advice'] ][] = $guide['label'];
7030 + }
7031 + }
7032 +
7033 + if ( empty( $guides ) ) {
7034 + return;
7035 + }
7036 + ?>
7037 + <ul class="vigilante-fi-guidance" style="margin: 8px 0 12px 18px; list-style: disc;">
7038 + <?php foreach ( $guides as $advice => $labels ) : ?>
7039 + <li><strong><?php echo esc_html( implode( ', ', $labels ) ); ?>:</strong> <?php echo esc_html( $advice ); ?></li>
7040 + <?php endforeach; ?>
7041 + </ul>
7042 + <?php
7043 + }
7044 +
5894 7045 private function render_tab_file_integrity() {
5895 7046 $is_disabled = $this->render_module_disabled_notice( 'file_integrity' );
5896 7047 $options = $this->settings->get_section( 'file_integrity' );
7048 + // On the main site of a network the critical-file scan is the network's
7049 + // canary for a change to wp-config.php or the root .htaccess, so a
7050 + // main-site admin without network rights cannot turn it off. Since
7051 + // 2.11.8; see Vigilante_Settings::get_main_site_file_settings().
7052 + $vg_main_locked = $this->main_site_files_locked();
5897 7053 $last_scan = get_option( 'vigilante_last_integrity_scan' );
5898 7054 $last_results = get_option( 'vigilante_last_integrity_results' );
5899 7055 $ignored_files = get_option( 'vigilante_ignored_files', array() );
5900 7056
7057 + /*
7058 + * Vigilant own findings are shown in their own box, the first of the
7059 + * results, with what each one means and how to fix it. They are taken
7060 + * out of the generic tables here (and out of the counters above them)
7061 + * so the same finding is not reported twice and so no row of the
7062 + * security plugin's own files offers an Ignore button. The stored
7063 + * results keep them: the scan email reads its own section from there.
7064 + */
7065 + if ( is_array( $last_results ) ) {
7066 + foreach ( array( 'modified', 'suspicious', 'extra', 'missing' ) as $vg_bucket ) {
7067 + if ( empty( $last_results[ $vg_bucket ] ) || ! is_array( $last_results[ $vg_bucket ] ) ) {
7068 + continue;
7069 + }
7070 + $last_results[ $vg_bucket ] = array_values(
7071 + array_filter(
7072 + $last_results[ $vg_bucket ],
7073 + function ( $vg_item ) {
7074 + return ! ( is_array( $vg_item ) && 'vigilante_self' === ( $vg_item['type'] ?? '' ) );
7075 + }
7076 + )
7077 + );
7078 + }
7079 + }
7080 +
5901 7081 // Backward compat: convert old notify_on_changes to notify_level
5902 7082 $notify_level = $options['notify_level'] ?? '';
5903 7083 if ( empty( $notify_level ) ) {
5904 7084 $notify_level = ! empty( $options['notify_on_changes'] ) ? 'all' : 'disabled';
@@ -5934,9 +7114,10 @@
5934 7114 <h2>
5935 7115 <?php esc_html_e( 'File Integrity Monitoring', 'vigilante' ); ?>
5936 7116 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
5937 7117 </h2>
5938 - <p><?php esc_html_e( 'Detects file modifications using WordPress.org checksums.', 'vigilante' ); ?></p>
7118 + <p><?php esc_html_e( 'Compares WordPress, and the plugins and themes that come from WordPress.org, with the official copy of each file, looks for code where there should be none (uploads and the site root) and watches wp-config.php and .htaccess for changes.', 'vigilante' ); ?></p>
7119 + <p class="description"><?php esc_html_e( 'What it cannot compare: plugins and themes that are not on WordPress.org, must-use plugins, drop-ins (advanced-cache.php, object-cache.php and the like) and the other folders of wp-content have no official copy. They are searched for hidden code, which finds malware that disguises itself and misses most code written in the open. The results name what could not be compared.', 'vigilante' ); ?></p>
5939 7120
5940 7121 <table class="form-table">
5941 7122 <tr>
5942 7123 <th scope="row"><?php esc_html_e( 'Automatic Scans', 'vigilante' ); ?></th>
@@ -5959,13 +7140,13 @@
5959 7140 <tr>
5960 7141 <th scope="row"><label for="vigilante-f-file-integrity-notify-level"><?php esc_html_e( 'Email Notifications', 'vigilante' ); ?></label></th>
5961 7142 <td>
5962 7143 <select id="vigilante-f-file-integrity-notify-level" name="file_integrity[notify_level]">
5963 - <option value="all" <?php selected( $notify_level, 'all' ); ?>><?php esc_html_e( 'All issues (modified + suspicious)', 'vigilante' ); ?></option>
5964 - <option value="suspicious_only" <?php selected( $notify_level, 'suspicious_only' ); ?>><?php esc_html_e( 'Suspicious files only', 'vigilante' ); ?></option>
7144 + <option value="all" <?php selected( $notify_level, 'all' ); ?>><?php esc_html_e( 'Every finding, modified files included', 'vigilante' ); ?></option>
7145 + <option value="suspicious_only" <?php selected( $notify_level, 'suspicious_only' ); ?>><?php esc_html_e( 'Serious findings only', 'vigilante' ); ?></option>
5965 7146 <option value="disabled" <?php selected( $notify_level, 'disabled' ); ?>><?php esc_html_e( 'Disabled', 'vigilante' ); ?></option>
5966 7147 </select>
5967 - <p class="description"><?php esc_html_e( '"Suspicious files only" reduces noise by skipping modified file notifications. Recommended for most sites.', 'vigilante' ); ?></p>
7148 + <p class="description"><?php esc_html_e( 'Serious findings are suspicious and extra files, changes in wp-config.php or .htaccess and closed plugins. That is the recommended choice: a file that is only modified is rarely more than an update that did not finish. The email goes out when a scan finds something new: a finding you were already told about is not sent again, unless the file changes once more.', 'vigilante' ); ?></p>
5968 7149 </td>
5969 7150 </tr>
5970 7151 <tr>
5971 7152 <th scope="row"><?php esc_html_e( 'Instant Alert', 'vigilante' ); ?></th>
@@ -5971,11 +7152,11 @@
5971 7152 <th scope="row"><?php esc_html_e( 'Instant Alert', 'vigilante' ); ?></th>
5972 7153 <td>
5973 7154 <label>
5974 7155 <input type="checkbox" name="file_integrity[instant_alert]" value="1" <?php checked( ! empty( $options['instant_alert'] ) ); ?>>
5975 - <?php esc_html_e( 'Send immediate alert when modified, suspicious or additional files are detected, or when a closed plugin is found', 'vigilante' ); ?>
7156 + <?php esc_html_e( 'Email every finding when a scan ends, modified files included, and email a closed plugin as soon as the daily check finds it', 'vigilante' ); ?>
5976 7157 </label>
5977 - <p class="description"><?php esc_html_e( 'Fires even if the Email Notifications setting above is set to Disabled.', 'vigilante' ); ?></p>
7158 + <p class="description"><?php esc_html_e( 'This takes precedence over the choice above, Disabled included.', 'vigilante' ); ?></p>
5978 7159 <p class="description">
5979 7160 <?php
5980 7161 printf(
5981 7162 /* translators: %s: Link to notification settings */
@@ -6001,29 +7182,32 @@
6001 7182 <td>
6002 7183 <fieldset>
6003 7184 <label>
6004 7185 <input type="checkbox" name="file_integrity[scan_core]" value="1" <?php checked( $options['scan_core'] ?? true ); ?>>
6005 - <?php esc_html_e( 'Core files (compare against WordPress.org checksums)', 'vigilante' ); ?>
7186 + <?php esc_html_e( 'WordPress core (compared with WordPress.org) and the other files in the site root', 'vigilante' ); ?>
6006 7187 </label>
6007 7188 <br>
6008 7189 <label>
6009 7190 <input type="checkbox" name="file_integrity[scan_plugins]" value="1" <?php checked( $options['scan_plugins'] ?? true ); ?>>
6010 - <?php esc_html_e( 'Plugins (WordPress.org repository plugins)', 'vigilante' ); ?>
7191 + <?php esc_html_e( 'Plugins (compared with WordPress.org; the rest, and the other folders of wp-content, are only searched for hidden code)', 'vigilante' ); ?>
6011 7192 </label>
6012 7193 <br>
6013 7194 <label>
6014 7195 <input type="checkbox" name="file_integrity[scan_themes]" value="1" <?php checked( $options['scan_themes'] ?? true ); ?>>
6015 - <?php esc_html_e( 'Themes (WordPress.org repository themes)', 'vigilante' ); ?>
7196 + <?php esc_html_e( 'Themes (compared with WordPress.org; the rest are only searched for hidden code)', 'vigilante' ); ?>
6016 7197 </label>
6017 7198 <br>
6018 7199 <label>
6019 7200 <input type="checkbox" name="file_integrity[scan_uploads]" value="1" <?php checked( $options['scan_uploads'] ?? true ); ?>>
6020 - <?php esc_html_e( 'Uploads directory (detect PHP files, double extensions, .htaccess)', 'vigilante' ); ?>
7201 + <?php esc_html_e( 'Uploads directory (PHP and other scripts, double extensions, .htaccess and PHP configuration files)', 'vigilante' ); ?>
6021 7202 </label>
6022 7203 <br>
6023 7204 <label>
6024 - <input type="checkbox" name="file_integrity[scan_critical_config]" value="1" <?php checked( $options['scan_critical_config'] ?? true ); ?>>
6025 - <?php esc_html_e( 'Critical config files (wp-config.php, .htaccess baseline monitoring)', 'vigilante' ); ?>
7205 + <input type="checkbox" name="file_integrity[scan_critical_config]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( $options['scan_critical_config'] ?? true ); ?>>
7206 + <?php esc_html_e( 'wp-config.php and .htaccess (any change since you last approved them)', 'vigilante' ); ?>
7207 + <?php if ( $vg_main_locked ) : ?>
7208 + <span class="description" style="display:block;margin-left:24px;"><?php echo esc_html( Vigilante_Settings::get_shared_files_notice() ); ?></span>
7209 + <?php endif; ?>
6026 7210 </label>
6027 7211 <br>
6028 7212 <label>
6029 7213 <input type="checkbox" name="file_integrity[check_closed_plugins]" value="1" <?php checked( $options['check_closed_plugins'] ?? true ); ?>>
@@ -6034,10 +7218,10 @@
6034 7218 </tr>
6035 7219 <tr>
6036 7220 <th scope="row"><label for="vigilante-f-file-integrity-excluded-paths"><?php esc_html_e( 'Excluded Paths', 'vigilante' ); ?></label></th>
6037 7221 <td>
6038 - <textarea id="vigilante-f-file-integrity-excluded-paths" name="file_integrity[excluded_paths]" rows="4" class="large-text code" placeholder="wp-content/cache&#10;wp-content/languages"><?php echo esc_textarea( implode( "\n", $options['excluded_paths'] ?? array() ) ); ?></textarea>
6039 - <p class="description"><?php esc_html_e( 'One path per line, relative to the WordPress root. A path such as wp-content/cache excludes exactly that folder and everything under it. A name on its own, such as cache, excludes any folder called exactly that, wherever it is.', 'vigilante' ); ?></p>
7222 + <textarea id="vigilante-f-file-integrity-excluded-paths" name="file_integrity[excluded_paths]" rows="4" class="large-text code" placeholder="wp-content/backups"><?php echo esc_textarea( implode( "\n", $options['excluded_paths'] ?? array() ) ); ?></textarea>
7223 + <p class="description"><?php esc_html_e( 'One path per line, relative to the WordPress root. A path such as wp-content/backups excludes exactly that folder and everything under it. A name on its own, such as backups, excludes any folder called exactly that, wherever it is. Nothing in an excluded folder is looked at, so keep the list short.', 'vigilante' ); ?></p>
6040 7224 </td>
6041 7225 </tr>
6042 7226 <tr>
6043 7227 <th scope="row"><label for="vigilante-f-file-integrity-excluded-extensions"><?php esc_html_e( 'Excluded Extensions', 'vigilante' ); ?></label></th>
@@ -6078,11 +7262,51 @@
6078 7262 </form>
6079 7263
6080 7264 <div id="vigilante-scan-results" class="vigilante-settings-section" style="display:none;"></div>
6081 7265
7266 + <?php $this->render_self_protection_box( $options ); ?>
7267 +
7268 + <?php
7269 + // Whole folders of code left out of every scan. A partial scan tells
7270 + // how to check the rest by excluding what it did check, and those
7271 + // lines have to come out afterwards: with them in place a scan comes
7272 + // out complete and clean without having looked there. Said for as
7273 + // long as they are in the list, whoever put them there and why.
7274 + $vg_fi_tool = new Vigilante_File_Integrity( $this->settings, $this->database );
7275 + $vg_excluded_code = $vg_fi_tool->excluded_code_folders();
7276 + $vg_folders_to_skip = $vg_fi_tool->folders_to_exclude( $last_results );
7277 + ?>
7278 + <?php if ( ! empty( $vg_excluded_code ) ) : ?>
7279 + <div id="vigilante-fi-excluded-code" class="notice notice-warning inline">
7280 + <p>
7281 + <strong>
7282 + <?php
7283 + printf(
7284 + /* translators: %s: Comma separated list of folders */
7285 + esc_html__( 'Excluded Paths holds whole folders of plugins or themes, or the uploads folder: %s.', 'vigilante' ),
7286 + esc_html( implode( ', ', $vg_excluded_code ) )
7287 + );
7288 + ?>
7289 + </strong>
7290 + <?php esc_html_e( 'No scan looks inside them, the scheduled one included, and a scan can come out complete and clean without having checked them. If you added them to check the rest of a large site, take them out once that scan is done.', 'vigilante' ); ?>
7291 + </p>
7292 + </div>
7293 + <?php endif; ?>
7294 +
6082 7295 <?php if ( $last_scan || $has_closed || $closed_last_check > 0 ) : ?>
6083 7296 <div id="vigilante-section-fi-last-scan" class="vigilante-settings-section">
6084 7297 <h2><?php esc_html_e( 'Last Scan Results', 'vigilante' ); ?></h2>
7298 + <?php
7299 + // A scan the server stops halfway stores nothing, and neither
7300 + // does one WordPress never starts: the tab kept showing the
7301 + // last result that did finish, with nothing to say that scans
7302 + // had stopped. Its date is the only sign.
7303 + if ( $last_scan && $this->settings->is_module_enabled( 'file_integrity' ) && Vigilante_File_Integrity::is_scan_overdue( $last_scan, $options['scan_frequency'] ?? 'daily' ) ) :
7304 + ?>
7305 + <div class="notice notice-warning inline">
7306 + <p><strong><?php esc_html_e( 'Scans are scheduled, and none has finished since the date below.', 'vigilante' ); ?></strong> <?php esc_html_e( 'Either WordPress is not running its scheduled tasks, or the server stops the scan before it ends, and a scan that is stopped stores no result. What you see here is old. Run a scan now: if it finishes, the scheduled tasks are what is failing. If it does not, the site is too large for the time the server gives a request, and adding large folders that do not need checking to Excluded Paths leaves more time for the rest.', 'vigilante' ); ?></p>
7307 + </div>
7308 + <?php endif; ?>
6085 7309 <?php if ( $last_scan ) : ?>
6086 7310 <p>
6087 7311 <?php
6088 7312 // Build the "X files scanned" hint inline with the date so it doesn't
@@ -6165,12 +7389,112 @@
6165 7389 </div>
6166 7390 <?php endif; ?>
6167 7391 </div>
6168 7392
7393 + <?php
7394 + // A scan that could not finish, or could not get the official
7395 + // copy to compare with, checked less than it seems. Until now
7396 + // its errors were stored and never shown, and the tab could
7397 + // say that every file had passed.
7398 + $vg_scan_errors = ( $last_results && ! empty( $last_results['errors'] ) && is_array( $last_results['errors'] ) ) ? $last_results['errors'] : array();
7399 + $vg_scan_partial = ! empty( $vg_scan_errors ) || ( $last_results && ! empty( $last_results['incomplete'] ) );
7400 +
7401 + // Files of the official copies that are not on the server. Core
7402 + // files under wp-content are left out: results stored before
7403 + // the bundled themes and plugins got their own turn list
7404 + // there the ones people delete on purpose.
7405 + $vg_missing_core = array();
7406 + if ( $last_results && ! empty( $last_results['missing'] ) && is_array( $last_results['missing'] ) ) {
7407 + foreach ( $last_results['missing'] as $item ) {
7408 + if ( ! is_array( $item ) || ! isset( $item['file'] ) ) {
7409 + continue;
7410 + }
7411 +
7412 + $vg_missing_type = (string) ( $item['type'] ?? '' );
7413 +
7414 + if ( in_array( $vg_missing_type, array( 'plugin', 'theme' ), true )
7415 + || ( 'core' === $vg_missing_type && 0 !== strpos( (string) $item['file'], 'wp-content/' ) ) ) {
7416 + $vg_missing_core[] = (string) $item['file'];
7417 + }
7418 + }
7419 + }
7420 + ?>
7421 +
7422 + <?php if ( $last_results && ! empty( $last_results['unverified'] ) && is_array( $last_results['unverified'] ) ) : ?>
7423 + <p class="description">
7424 + <?php
7425 + printf(
7426 + /* translators: %s: Comma separated list of plugin and theme names */
7427 + esc_html__( 'No official copy to compare with, so only searched for hidden code: %s.', 'vigilante' ),
7428 + esc_html( implode( ', ', array_unique( array_map( 'strval', $last_results['unverified'] ) ) ) )
7429 + );
7430 + ?>
7431 + </p>
7432 + <?php endif; ?>
7433 +
7434 + <?php
7435 + // What the scan did not get to, by name. A scan starts from
7436 + // the beginning every time, so on a site too large for it
7437 + // these are the same on every scan, and whoever reads the
7438 + // result has to know that nothing here was looked at.
7439 + $vg_scan_notes = array_merge( $vg_scan_errors, Vigilante_File_Integrity::not_checked_lines( $last_results ) );
7440 + ?>
7441 + <?php if ( $vg_scan_partial ) : ?>
7442 + <div class="notice notice-warning inline">
7443 + <p><strong><?php esc_html_e( 'The last scan could not check everything, so its result is partial.', 'vigilante' ); ?></strong></p>
7444 + <?php if ( ! empty( $vg_scan_notes ) ) : ?>
7445 + <ul style="margin: 0 0 8px 18px; list-style: disc;">
7446 + <?php foreach ( $vg_scan_notes as $vg_scan_note ) : ?>
7447 + <li><?php echo esc_html( (string) $vg_scan_note ); ?></li>
7448 + <?php endforeach; ?>
7449 + </ul>
7450 + <?php endif; ?>
7451 + <p>
7452 + <?php
7453 + printf(
7454 + /* translators: %d: Seconds a scan runs for at most */
7455 + esc_html__( 'Run the scan again: a second scan is faster, because what it downloads from WordPress.org is kept for a while. If the result is still partial, either the server cannot reach WordPress.org or the site is larger than a scan can check in its %d seconds, and then every scan leaves the same part unchecked. Adding large folders that do not need checking to Excluded Paths leaves more time for the rest.', 'vigilante' ),
7456 + (int) Vigilante_File_Integrity::MAX_SCAN_SECONDS
7457 + );
7458 + ?>
7459 + </p>
7460 + <?php if ( ! empty( $vg_folders_to_skip ) ) : ?>
7461 + <p><strong><?php esc_html_e( 'To check what was left out:', 'vigilante' ); ?></strong></p>
7462 + <ol style="margin: 0 0 8px 18px;">
7463 + <li><?php esc_html_e( 'Deal first with what this scan found in the plugins and themes it did check: the next result will not list it. Then add the lines below, which are their folders, to Excluded Paths, higher up on this page, and save.', 'vigilante' ); ?></li>
7464 + <li><?php esc_html_e( 'Press Run Scan Now. The scan spends its time on the rest. If it comes out partial again, repeat with the lines it gives then.', 'vigilante' ); ?></li>
7465 + <li><?php esc_html_e( 'Take those lines out of Excluded Paths. While they are there no scan looks inside those folders, the scheduled one included.', 'vigilante' ); ?></li>
7466 + </ol>
7467 + <textarea id="vigilante-fi-folders-to-exclude" readonly rows="<?php echo (int) min( 12, max( 3, count( $vg_folders_to_skip ) ) ); ?>" class="large-text code" style="margin-bottom: 8px;"><?php echo esc_textarea( implode( "\n", $vg_folders_to_skip ) ); ?></textarea>
7468 + <?php endif; ?>
7469 + </div>
7470 + <?php endif; ?>
7471 +
7472 + <?php if ( ! empty( $vg_missing_core ) ) : ?>
7473 + <div class="vigilante-file-list">
7474 + <h3><?php esc_html_e( 'Missing Files', 'vigilante' ); ?></h3>
7475 + <p class="description"><?php esc_html_e( 'These files are in the official copy and not on the server. Reinstall what they belong to (WordPress from Dashboard > Updates, a plugin or a theme from its own screen) and scan again.', 'vigilante' ); ?></p>
7476 + <ul style="margin: 8px 0 12px 18px; list-style: disc;">
7477 + <?php foreach ( array_slice( $vg_missing_core, 0, 20 ) as $vg_missing_file ) : ?>
7478 + <li><code><?php echo esc_html( $vg_missing_file ); ?></code></li>
7479 + <?php endforeach; ?>
7480 + </ul>
7481 + <?php if ( count( $vg_missing_core ) > 20 ) : ?>
7482 + <p class="description">
7483 + <?php
7484 + /* translators: %d: Number of additional files */
7485 + printf( esc_html__( '... and %d more', 'vigilante' ), (int) ( count( $vg_missing_core ) - 20 ) );
7486 + ?>
7487 + </p>
7488 + <?php endif; ?>
7489 + </div>
7490 + <?php endif; ?>
7491 +
6169 7492 <?php if ( ! empty( $last_results['suspicious'] ) ) : ?>
6170 7493 <div class="vigilante-file-list vigilante-suspicious-files vigilante-paginated-section" data-bulk-mode="ignore">
6171 7494 <h3 style="color: #d63638;"><?php esc_html_e( 'Suspicious Files', 'vigilante' ); ?></h3>
6172 - <p class="description" style="color: #d63638;"><?php esc_html_e( '&#9888; Warning: These files may contain malicious code or are in unexpected locations. Review immediately!', 'vigilante' ); ?></p>
7495 + <p class="description" style="color: #d63638;"><?php esc_html_e( '&#9888; Files that can run code where none is expected, or that contain code written the way malware hides itself. Real infections show up here, and so do harmless files that plugins generate. Check the path and the reason before acting.', 'vigilante' ); ?></p>
7496 + <?php $this->render_fi_guidance( $last_results['suspicious'] ); ?>
6173 7497 <div class="vigilante-fi-bulk-bar">
6174 7498 <button type="button" class="button vigilante-bulk-ignore" disabled><?php esc_html_e( 'Ignore selected', 'vigilante' ); ?></button>
6175 7499 <span class="vigilante-fi-bulk-count" aria-live="polite"></span>
6176 7500 </div>
@@ -6204,8 +7528,14 @@
6204 7528 }
6205 7529 } else {
6206 7530 $file_path = (string) $item;
6207 7531 }
7532 + if ( 'vigilante_self' === $file_type ) {
7533 + $file_type = __( 'Vigilant (self)', 'vigilante' );
7534 + } else {
7535 + // A name a person can read, instead of the internal slug.
7536 + $file_type = Vigilante_File_Integrity::finding_guidance( $file_type )['label'];
7537 + }
6208 7538 ?>
6209 7539 <tr>
6210 7540 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
6211 7541 <td><code style="color: #d63638;"><?php echo esc_html( $file_path ); ?></code></td>
@@ -6223,9 +7553,10 @@
6223 7553
6224 7554 <?php if ( ! empty( $last_results['extra'] ) ) : ?>
6225 7555 <div class="vigilante-file-list vigilante-extra-files vigilante-paginated-section" data-bulk-mode="ignore">
6226 7556 <h3 style="color: #b32d2e;"><?php esc_html_e( 'Extra Files', 'vigilante' ); ?></h3>
6227 - <p class="description"><?php esc_html_e( 'PHP files found in plugins or themes that are not part of the original distribution from WordPress.org. May be legitimate customizations or injected backdoors.', 'vigilante' ); ?></p>
7557 + <p class="description"><?php esc_html_e( 'Files that are not part of WordPress, or of the plugin or theme they sit in. Many are legitimate, and an injected file looks the same from here, so confirm where each one comes from.', 'vigilante' ); ?></p>
7558 + <?php $this->render_fi_guidance( $last_results['extra'] ); ?>
6228 7559 <div class="vigilante-fi-bulk-bar">
6229 7560 <button type="button" class="button vigilante-bulk-ignore" disabled><?php esc_html_e( 'Ignore selected', 'vigilante' ); ?></button>
6230 7561 <span class="vigilante-fi-bulk-count" aria-live="polite"></span>
6231 7562 </div>
@@ -6245,8 +7576,14 @@
6245 7576 foreach ( $last_results['extra'] as $item ) {
6246 7577 $file_path = is_array( $item ) ? ( $item['file'] ?? '' ) : (string) $item;
6247 7578 $file_reason = is_array( $item ) ? ( $item['reason'] ?? __( 'Unknown', 'vigilante' ) ) : __( 'Unknown', 'vigilante' );
6248 7579 $file_type = is_array( $item ) ? ( $item['type'] ?? 'unknown' ) : 'unknown';
7580 + if ( 'vigilante_self' === $file_type ) {
7581 + $file_type = __( 'Vigilant (self)', 'vigilante' );
7582 + } else {
7583 + // A name a person can read, instead of the internal slug.
7584 + $file_type = Vigilante_File_Integrity::finding_guidance( $file_type )['label'];
7585 + }
6249 7586 ?>
6250 7587 <tr>
6251 7588 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
6252 7589 <td><code style="color: #b32d2e;"><?php echo esc_html( $file_path ); ?></code></td>
@@ -6277,8 +7614,19 @@
6277 7614 $regular_modified[] = $item;
6278 7615 }
6279 7616 }
6280 7617 }
7618 + // A missing file of Vigilant is critical, and the missing
7619 + // files of the generic scan have no table: it is listed with
7620 + // the modified files, or the tab said "All files passed" with
7621 + // a module deleted.
7622 + if ( $last_results && ! empty( $last_results['missing'] ) && is_array( $last_results['missing'] ) ) {
7623 + foreach ( $last_results['missing'] as $item ) {
7624 + if ( is_array( $item ) && 'vigilante_self' === ( $item['type'] ?? '' ) ) {
7625 + $regular_modified[] = $item;
7626 + }
7627 + }
7628 + }
6281 7629 ?>
6282 7630
6283 7631 <?php if ( ! empty( $critical_modified ) ) : ?>
6284 7632 <div class="vigilante-file-list vigilante-critical-config-files">
@@ -6283,9 +7631,9 @@
6283 7631 <?php if ( ! empty( $critical_modified ) ) : ?>
6284 7632 <div class="vigilante-file-list vigilante-critical-config-files">
6285 7633 <h3 style="color: #e36210;"><?php esc_html_e( 'Critical config files modified', 'vigilante' ); ?></h3>
6286 7634 <p class="description">
6287 - <?php esc_html_e( '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' ); ?>
7635 + <?php esc_html_e( 'These files changed since you last approved them. They are a common target for code injection, and they are also rewritten by cache, security and redirection plugins, often on every update. Review the changes and approve them if they belong to something you use: approving records the current content as the reference and does not touch the file. Vigilant\'s own blocks are excluded from this check, and Vigilant never restores these files by itself.', 'vigilante' ); ?>
6288 7636 </p>
6289 7637 <table class="wp-list-table widefat fixed striped">
6290 7638 <thead>
6291 7639 <tr>
@@ -6302,9 +7650,15 @@
6302 7650 $crit_diff = $crit_item['diff'] ?? array();
6303 7651 $crit_id = sanitize_html_class( $crit_file );
6304 7652 $added_count = is_array( $crit_diff ) ? count( $crit_diff['added'] ?? array() ) : 0;
6305 7653 $removed_count = is_array( $crit_diff ) ? count( $crit_diff['removed'] ?? array() ) : 0;
6306 - $diff_unavailable = is_array( $crit_diff ) && ! empty( $crit_diff['unavailable'] );
7654 + // The lines of a shared file are for whoever approves it. Results
7655 + // stored before 2.11.8 on the main site still carry them, so the
7656 + // screen asks too, not only the scan that wrote them.
7657 + $diff_network = ( is_array( $crit_diff ) && ! empty( $crit_diff['network'] ) ) || $this->critical_approval_locked();
7658 + $diff_rescan = is_array( $crit_diff ) && ! empty( $crit_diff['rescan'] );
7659 + $diff_redaction = is_array( $crit_diff ) && ! empty( $crit_diff['redaction'] );
7660 + $diff_unavailable = $diff_network || ( is_array( $crit_diff ) && ! empty( $crit_diff['unavailable'] ) );
6307 7661 ?>
6308 7662 <tr>
6309 7663 <td><code style="color: #e36210;"><?php echo esc_html( $crit_file ); ?></code></td>
6310 7664 <td>
@@ -6327,18 +7681,36 @@
6327 7681 <td>
6328 7682 <button type="button" class="button button-small vigilante-toggle-critical-content" data-target="vigilante-critical-content-<?php echo esc_attr( $crit_id ); ?>" data-label-show="<?php esc_attr_e( 'Review changes', 'vigilante' ); ?>" data-label-hide="<?php esc_attr_e( 'Hide changes', 'vigilante' ); ?>">
6329 7683 <?php esc_html_e( 'Review changes', 'vigilante' ); ?>
6330 7684 </button>
6331 - <button type="button" class="button button-small button-primary vigilante-approve-critical-file" data-file="<?php echo esc_attr( $crit_file ); ?>">
6332 - <?php esc_html_e( 'Approve', 'vigilante' ); ?>
6333 - </button>
7685 + <?php if ( $this->critical_approval_locked() ) : ?>
7686 + <span class="description" style="display:block;margin-top:4px;">
7687 + <?php echo esc_html( $this->critical_approval_notice() ); ?>
7688 + </span>
7689 + <?php else : ?>
7690 + <button type="button" class="button button-small button-primary vigilante-approve-critical-file" data-file="<?php echo esc_attr( $crit_file ); ?>">
7691 + <?php esc_html_e( 'Approve', 'vigilante' ); ?>
7692 + </button>
7693 + <?php endif; ?>
6334 7694 </td>
6335 7695 </tr>
6336 7696 <tr id="vigilante-critical-content-<?php echo esc_attr( $crit_id ); ?>" class="vigilante-critical-content-row" style="display:none;">
6337 7697 <td colspan="3" style="padding: 0;">
6338 7698 <div class="vigilante-critical-content" style="max-height: 400px; overflow: auto; background: #fff; padding: 10px; font-size: 12px; line-height: 1.5; font-family: Consolas, Monaco, monospace; border-top: 1px solid #c3c4c7;">
6339 - <?php if ( $diff_unavailable ) : ?>
7699 + <?php if ( $diff_network ) : ?>
6340 7700 <p style="color: #50575e; font-style: italic; margin: 0;">
7701 + <?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' ); ?>
7702 + </p>
7703 + <?php elseif ( $diff_rescan ) : ?>
7704 + <p style="color: #50575e; font-style: italic; margin: 0;">
7705 + <?php esc_html_e( 'Run a new scan to see the line changes of this file.', 'vigilante' ); ?>
7706 + </p>
7707 + <?php elseif ( $diff_redaction ) : ?>
7708 + <p style="color: #50575e; font-style: italic; margin: 0;">
7709 + <?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' ); ?>
7710 + </p>
7711 + <?php elseif ( $diff_unavailable ) : ?>
7712 + <p style="color: #50575e; font-style: italic; margin: 0;">
6341 7713 <?php esc_html_e( 'Diff not available for this file (baseline was created before diff tracking was added). Approve to enable diff on future changes.', 'vigilante' ); ?>
6342 7714 </p>
6343 7715 <?php elseif ( empty( $crit_diff['added'] ) && empty( $crit_diff['removed'] ) ) : ?>
6344 7716 <p style="color: #50575e; font-style: italic; margin: 0;">
@@ -6422,9 +7794,10 @@
6422 7794
6423 7795 <?php if ( ! empty( $regular_modified ) ) : ?>
6424 7796 <div class="vigilante-file-list vigilante-paginated-section" data-bulk-mode="ignore">
6425 7797 <h3><?php esc_html_e( 'Modified Files', 'vigilante' ); ?></h3>
6426 - <p class="description"><?php esc_html_e( 'These files (apparently) differ from the original WordPress or plugin versions.', 'vigilante' ); ?></p>
7798 + <p class="description"><?php esc_html_e( 'These files do not match the copy WordPress.org publishes for the installed version.', 'vigilante' ); ?></p>
7799 + <?php $this->render_fi_guidance( $regular_modified ); ?>
6427 7800 <div class="vigilante-fi-bulk-bar">
6428 7801 <button type="button" class="button vigilante-bulk-ignore" disabled><?php esc_html_e( 'Ignore selected', 'vigilante' ); ?></button>
6429 7802 <span class="vigilante-fi-bulk-count" aria-live="polite"></span>
6430 7803 </div>
@@ -6453,8 +7826,14 @@
6453 7826 }
6454 7827 } else {
6455 7828 $file_path = (string) $item;
6456 7829 }
7830 + if ( 'vigilante_self' === $file_type ) {
7831 + $file_type = __( 'Vigilant (self)', 'vigilante' );
7832 + } else {
7833 + // A name a person can read, instead of the internal slug.
7834 + $file_type = Vigilante_File_Integrity::finding_guidance( $file_type )['label'];
7835 + }
6457 7836 ?>
6458 7837 <tr>
6459 7838 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
6460 7839 <td><code><?php echo esc_html( $file_path ); ?></code></td>
@@ -6468,11 +7847,18 @@
6468 7847 </table>
6469 7848 </div>
6470 7849 <?php endif; ?>
6471 7850
6472 - <?php if ( $last_results && empty( $last_results['modified'] ) && empty( $last_results['suspicious'] ) && empty( $last_results['extra'] ) && ! $has_closed ) : ?>
7851 + <?php
7852 + // "All files passed" is about every file, and Vigilant's own
7853 + // are files too: with the self-protection box in red or amber
7854 + // above, this line contradicted it (its findings no longer
7855 + // travel in the tables below).
7856 + $vg_self_alarm = in_array( $this->self_integrity_summary( $options )['tone'], array( 'critical', 'off', 'warning' ), true );
7857 + ?>
7858 + <?php if ( $last_results && ! $vg_self_alarm && empty( $critical_modified ) && empty( $regular_modified ) && empty( $last_results['suspicious'] ) && empty( $last_results['extra'] ) && ! $has_closed && ! $vg_scan_partial && empty( $vg_missing_core ) ) : ?>
6473 7859 <p class="vigilante-all-clear" style="color: #00a32a; font-weight: bold;">
6474 - <?php esc_html_e( 'Good Job! All files passed integrity check. No issues found.', 'vigilante' ); ?>
7860 + <?php esc_html_e( 'Good job! Nothing to report in the files that were checked.', 'vigilante' ); ?>
6475 7861 </p>
6476 7862 <?php endif; ?>
6477 7863 <?php endif; ?>
6478 7864 </div>
@@ -6521,9 +7907,9 @@
6521 7907
6522 7908 <?php if ( ! empty( $ignored_files ) ) : ?>
6523 7909 <div id="vigilante-section-fi-ignored" class="vigilante-settings-section">
6524 7910 <h2><?php esc_html_e( 'Ignored Files', 'vigilante' ); ?></h2>
6525 - <p class="description"><?php esc_html_e( 'These files are excluded from scan results and email notifications. They will still be scanned but any findings will be hidden.', 'vigilante' ); ?></p>
7911 + <p class="description"><?php esc_html_e( 'These files are excluded from scan results and email notifications. They will still be scanned but any findings will be hidden. To leave a whole folder out, add it to Excluded Paths instead.', 'vigilante' ); ?></p>
6526 7912 <div class="vigilante-paginated-section" data-bulk-mode="unignore">
6527 7913 <div class="vigilante-fi-bulk-bar">
6528 7914 <button type="button" class="button vigilante-bulk-unignore" disabled><?php esc_html_e( 'Stop ignoring selected', 'vigilante' ); ?></button>
6529 7915 <span class="vigilante-fi-bulk-count" aria-live="polite"></span>
@@ -6612,9 +7998,9 @@
6612 7998 } elseif ( isset( $_POST['data'] ) ) {
6613 7999 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
6614 8000 $raw_data = wp_unslash( $_POST['data'] );
6615 8001 parse_str( $raw_data, $data );
6616 - $data = map_deep( $data, 'sanitize_textarea_field' );
8002 + $data = $this->sanitize_posted_settings( $data );
6617 8003 } else {
6618 8004 $data = array();
6619 8005 }
6620 8006
@@ -6674,10 +8060,30 @@
6674 8060
6675 8061 // Read ONLY saved options from database (not merged with defaults)
6676 8062 $saved_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
6677 8063
6678 - $rejected_ips = array();
8064 + // What is stored before this request changes anything: the shared file
8065 + // settings this user may not change are put back from here (2.11.6).
8066 + $stored_options = $saved_options;
8067 + $locked = Vigilante_Settings::get_locked_file_settings();
6679 8068
8069 + if ( isset( $locked[ $section ] ) && true === $locked[ $section ] ) {
8070 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
8071 + }
8072 +
8073 + // A module switch is a single key, so refusing says more than a success
8074 + // that changed nothing, and the dashboard puts the toggle back.
8075 + if ( 'modules' === $section && isset( $locked['modules'], $data['modules'] ) && is_array( $locked['modules'] ) && is_array( $data['modules'] ) ) {
8076 + foreach ( array_keys( $data['modules'] ) as $vg_module ) {
8077 + if ( in_array( sanitize_key( $vg_module ), $locked['modules'], true ) ) {
8078 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
8079 + }
8080 + }
8081 + }
8082 +
8083 + $rejected_ips = array();
8084 + $rejected_proxies = array();
8085 +
6680 8086 // Handle modules
6681 8087 if ( 'modules' === $section && isset( $data['modules'] ) ) {
6682 8088 if ( ! isset( $saved_options['modules'] ) ) {
6683 8089 $saved_options['modules'] = array();
@@ -6701,9 +8107,9 @@
6701 8107 // went straight into the option. An entry the matcher can never
6702 8108 // match still sits in a security list looking like protection,
6703 8109 // so the ones that cannot match are dropped and reported back
6704 8110 // instead of being stored in silence.
6705 - $rejected_ips = $this->filter_ip_lists( $section, $processed );
8111 + $rejected_ips = $this->filter_ip_lists( $section, $processed, $rejected_proxies );
6706 8112
6707 8113 // Save the processed section
6708 8114 $saved_options[ $section ] = $processed;
6709 8115
@@ -6714,8 +8120,10 @@
6714 8120
6715 8121 // Clear cache before saving
6716 8122 wp_cache_delete( Vigilante_Settings::OPTION_NAME, 'options' );
6717 8123
8124 + $saved_options = Vigilante_Settings::keep_locked_file_settings( $saved_options, $stored_options );
8125 +
6718 8126 // Save to database
6719 8127 update_option( Vigilante_Settings::OPTION_NAME, $saved_options );
6720 8128
6721 8129 // Clear the settings cache
@@ -6777,8 +8185,21 @@
6777 8185 implode( ', ', array_map( 'esc_html', $rejected_ips ) )
6778 8186 );
6779 8187 }
6780 8188
8189 + if ( ! empty( $rejected_proxies ) ) {
8190 + $message .= ' ' . sprintf(
8191 + /* translators: %s: comma separated list of the trusted proxy entries that were not saved. */
8192 + _n(
8193 + 'A trusted proxy must be an exact IP or a CIDR range, not a wildcard, so this entry was not saved: %s',
8194 + 'A trusted proxy must be an exact IP or a CIDR range, not a wildcard, so these entries were not saved: %s',
8195 + count( $rejected_proxies ),
8196 + 'vigilante'
8197 + ),
8198 + implode( ', ', array_map( 'esc_html', $rejected_proxies ) )
8199 + );
8200 + }
8201 +
6781 8202 wp_send_json_success( $message );
6782 8203 }
6783 8204
6784 8205 /**
@@ -6789,9 +8210,21 @@
6789 8210 * @param string $section Section being saved.
6790 8211 * @param array $processed Section data, edited in place.
6791 8212 * @return array Entries that were dropped, for the message back to the user.
6792 8213 */
6793 - private function filter_ip_lists( $section, &$processed ) {
8214 + private function filter_ip_lists( $section, &$processed, &$rejected_proxies = array() ) {
8215 + $rejected_proxies = array();
8216 +
8217 + // Trusted proxies feed an identity decision, so only exact addresses and
8218 + // CIDR ranges belong there: a wildcard is stripped with its own message,
8219 + // never stored looking effective. The matcher ignores it anyway (see
8220 + // Vigilante_IP_Utils::in_list_ip_or_cidr), this stops it persisting.
8221 + if ( 'firewall' === $section && isset( $processed['trusted_proxies'] ) && is_array( $processed['trusted_proxies'] ) ) {
8222 + $split = Vigilante_IP_Utils::split_list_ip_or_cidr( $processed['trusted_proxies'] );
8223 + $processed['trusted_proxies'] = $split['valid'];
8224 + $rejected_proxies = $split['rejected'];
8225 + }
8226 +
6794 8227 $lists = array(
6795 8228 'firewall' => array( 'ip_whitelist', 'ip_blacklist' ),
6796 8229 'login_security' => array( 'ip_whitelist' ),
6797 8230 );
@@ -6957,8 +8390,48 @@
6957 8390 return array( 'sent' => $sent, 'failed' => $failed );
6958 8391 }
6959 8392
6960 8393 /**
8394 + * Sanitize the settings a tab posted, once the serialized form is parsed
8395 + *
8396 + * Every value goes through sanitize_textarea_field() except the lists of
8397 + * file paths (Vigilante_Settings::path_list_keys()). That one deletes every
8398 + * %XX octet and, when it finds one, collapses repeated spaces, so an
8399 + * exclusion for a folder called foto%20x was saved as fotox and excluded
8400 + * nothing (measured on 1 oct 2026 against 3.0.1). The paths are read as
8401 + * paths before the generic sanitizer runs and put back after it.
8402 + *
8403 + * @since 3.0.2
8404 + *
8405 + * @param array $data Parsed form: section => key => value.
8406 + * @return array
8407 + */
8408 + private function sanitize_posted_settings( $data ) {
8409 + $paths = Vigilante_Settings::take_path_lists( $data );
8410 + $data = map_deep( $data, 'sanitize_textarea_field' );
8411 +
8412 + return Vigilante_Settings::restore_path_lists( $data, $paths );
8413 + }
8414 +
8415 + /**
8416 + * Sanitize an imported settings file, once it is decoded
8417 + *
8418 + * Same rule as sanitize_posted_settings(): the lists of paths keep what the
8419 + * export wrote, so an exclusion survives an export and an import.
8420 + *
8421 + * @since 3.0.2
8422 + *
8423 + * @param array $imported Decoded export.
8424 + * @return array
8425 + */
8426 + private function sanitize_imported_settings( $imported ) {
8427 + $paths = Vigilante_Settings::take_path_lists( $imported );
8428 + $imported = map_deep( $imported, 'sanitize_text_field' );
8429 +
8430 + return Vigilante_Settings::restore_path_lists( $imported, $paths );
8431 + }
8432 +
8433 + /**
6961 8434 * Process section data maintaining proper types from defaults
6962 8435 *
6963 8436 * @param array $submitted_data Data submitted from form.
6964 8437 * @param array $defaults Default values for this section.
@@ -6966,16 +8439,51 @@
6966 8439 * @param string $section_name Section name for special handling.
6967 8440 * @return array Processed data.
6968 8441 */
6969 8442 private function process_section_data( $submitted_data, $defaults, $current, $section_name = '' ) {
6970 - // Start with defaults, then merge current saved values
8443 + // Start with defaults, then merge current saved values. Lists are put
8444 + // back whole afterwards: array_replace_recursive() merges them index by
8445 + // index, so a saved list shorter than the default kept the default's
8446 + // tail (see below, and Vigilante_Settings::array_merge_deep()).
6971 8447 $result = array_replace_recursive( $defaults, $current );
6972 -
8448 + $result = $this->restore_lists( $result, $current );
8449 +
6973 8450 // Process each submitted value
6974 8451 foreach ( $submitted_data as $key => $value ) {
6975 8452 $key = sanitize_key( $key );
6976 -
6977 - if ( is_array( $value ) ) {
8453 +
8454 + if ( is_array( $value ) && $this->is_list_field( $key, $value, $defaults, $current ) ) {
8455 + /*
8456 + * A list of values, not a group of settings: the roles of a
8457 + * checkbox group, the excluded user ids. What the form sent IS
8458 + * the list, so it replaces the stored one outright.
8459 + *
8460 + * Until 3.0.1 this went through the recursion below, which
8461 + * writes position by position and left every stored entry the
8462 + * new list was too short to overwrite. Unchecking Editor in
8463 + * "Enforce for roles" saved administrator over position 0 and
8464 + * kept editor in position 1, so the box came back ticked
8465 + * (reported on the support forum for 3.0.0, and the same bug
8466 + * merge_preset() was fixed for in 2.9.8).
8467 + *
8468 + * Keys are dropped on purpose: a list is data, so anything the
8469 + * request tried to name is not carried over. The empty string
8470 + * is dropped too, which is what the hidden field that marks an
8471 + * empty group sends.
8472 + */
8473 + $list = array();
8474 + foreach ( $value as $entry ) {
8475 + if ( ! is_scalar( $entry ) ) {
8476 + continue;
8477 + }
8478 + $entry = sanitize_text_field( (string) $entry );
8479 + if ( '' === $entry ) {
8480 + continue;
8481 + }
8482 + $list[] = $entry;
8483 + }
8484 + $result[ $key ] = $list;
8485 + } elseif ( is_array( $value ) ) {
6978 8486 // Nested array (like rate_limiting)
6979 8487 $nested_defaults = isset( $defaults[ $key ] ) && is_array( $defaults[ $key ] ) ? $defaults[ $key ] : array();
6980 8488 $nested_current = isset( $result[ $key ] ) && is_array( $result[ $key ] ) ? $result[ $key ] : array();
6981 8489 $result[ $key ] = $this->process_section_data( $value, $nested_defaults, $nested_current, $key );
@@ -7061,10 +8569,26 @@
7061 8569 }
7062 8570 }
7063 8571
7064 8572 if ( $is_value_list ) {
7065 - // All items removed - reset to empty array
7066 - $result[ $key ] = array();
8573 + /*
8574 + * A list whose key did not travel is left as it was. Until
8575 + * 3.0.1 it was blanked here, which read "the user unticked
8576 + * everything" into something the form never asked about:
8577 + * saving the Firewall tab blanked allowed_http_methods,
8578 + * which has no field on any screen, and saving Users blanked
8579 + * insecure_usernames. Both survived only because reading the
8580 + * option put the defaults back on top, and that is exactly
8581 + * the merge being removed in this release: with lists
8582 + * replaced whole, blanking here would have left a site
8583 + * answering 403 to every HTTP method.
8584 + *
8585 + * Emptying a group is still possible, and is now said out
8586 + * loud: each checkbox group prints a hidden field with an
8587 + * empty value, so an empty group arrives as an empty list
8588 + * instead of as silence. See list_sentinel().
8589 + */
8590 + continue;
7067 8591 } else {
7068 8592 // Nested settings group - handle boolean children
7069 8593 foreach ( $default_value as $nested_key => $nested_default ) {
7070 8594 if ( is_bool( $nested_default ) && isset( $result[ $key ] ) && is_array( $result[ $key ] ) ) {
@@ -7083,8 +8607,106 @@
7083 8607 return $result;
7084 8608 }
7085 8609
7086 8610 /**
8611 + * Whether a submitted value is a list of values rather than a group of settings
8612 + *
8613 + * The difference decides whether what arrived replaces the stored value or
8614 + * is merged into it key by key, so it is answered from what the setting is
8615 + * (its default, or what is stored today), not from the shape of the
8616 + * request. A request that sends named keys where a list belongs gets them
8617 + * dropped, which is one way this is narrower than what it replaces.
8618 + *
8619 + * @since 3.0.1
8620 + *
8621 + * @param string $key Setting key, already through sanitize_key().
8622 + * @param array $value Value as submitted.
8623 + * @param array $defaults Defaults of the section being processed.
8624 + * @param array $current Stored values of the section being processed.
8625 + * @return bool
8626 + */
8627 + private function is_list_field( $key, $value, $defaults, $current ) {
8628 + $default_known = array_key_exists( $key, $defaults ) && is_array( $defaults[ $key ] );
8629 +
8630 + // The default has the first and the last word, the same rule that
8631 + // array_merge_deep() and restore_lists() follow. Letting the stored
8632 + // value decide, as this did until the fourth cross review of 3.0.1,
8633 + // disagrees with them where it matters: an option corrupted into
8634 + // two_factor = array( 'a', 'b' ) would have been treated as a list and
8635 + // saving the tab would have replaced the whole group of settings with
8636 + // the two values that arrived.
8637 + if ( $default_known && array() !== $defaults[ $key ] ) {
8638 + return Vigilante_Settings::is_list( $defaults[ $key ] );
8639 + }
8640 +
8641 + // Nothing declares it either way (an empty default, or a key with no
8642 + // default at all), so what is stored, and then what was submitted, is
8643 + // all there is to go on.
8644 + if ( isset( $current[ $key ] ) && is_array( $current[ $key ] ) && array() !== $current[ $key ] && Vigilante_Settings::is_list( $current[ $key ] ) ) {
8645 + return true;
8646 + }
8647 +
8648 + return Vigilante_Settings::is_list( $value );
8649 + }
8650 +
8651 + /**
8652 + * Put stored lists back after array_replace_recursive()
8653 + *
8654 + * array_replace_recursive() merges a list by position, so a stored list
8655 + * shorter than the default comes out with the default's tail attached:
8656 + * ['administrator'] over ['administrator','editor'] gives both back. Every
8657 + * list that is stored is therefore restored whole before anything else is
8658 + * processed, and only associative arrays are walked into.
8659 + *
8660 + * @since 3.0.1
8661 + *
8662 + * @param array $result Defaults with the stored values merged over them.
8663 + * @param array $current Stored values.
8664 + * @return array
8665 + */
8666 + private function restore_lists( $result, $current ) {
8667 + if ( ! is_array( $current ) ) {
8668 + return $result;
8669 + }
8670 +
8671 + foreach ( $current as $key => $value ) {
8672 + if ( ! is_array( $value ) || ! isset( $result[ $key ] ) || ! is_array( $result[ $key ] ) ) {
8673 + continue;
8674 + }
8675 +
8676 + // Decided by what is in $result, which comes from the defaults, and
8677 + // never by the stored value: array() is a list by any definition, so
8678 + // a section stored empty would have replaced a whole group of
8679 + // settings with nothing. Same reasoning as in array_merge_deep().
8680 + if ( Vigilante_Settings::is_list( $result[ $key ] ) ) {
8681 + $result[ $key ] = $value;
8682 + continue;
8683 + }
8684 +
8685 + $result[ $key ] = $this->restore_lists( $result[ $key ], $value );
8686 + }
8687 +
8688 + return $result;
8689 + }
8690 +
8691 + /**
8692 + * Print the hidden field that lets a checkbox group arrive empty
8693 + *
8694 + * A checkbox group sends nothing at all when every box is unticked, which
8695 + * is indistinguishable from a form that never had that field. Since 3.0.1
8696 + * the save path leaves an absent list alone, so the difference has to be
8697 + * stated: this empty entry always travels, and the save path drops empty
8698 + * strings, so an untouched group arrives as an empty list.
8699 + *
8700 + * @since 3.0.1
8701 + *
8702 + * @param string $name Field name without the trailing [].
8703 + */
8704 + private function list_sentinel( $name ) {
8705 + printf( '<input type="hidden" name="%s[]" value="">', esc_attr( $name ) );
8706 + }
8707 +
8708 + /**
7087 8709 * AJAX: Export settings
7088 8710 */
7089 8711 public function ajax_export_settings() {
7090 8712 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
@@ -7136,9 +8758,9 @@
7136 8758 wp_send_json_error( __( 'Invalid JSON format.', 'vigilante' ) );
7137 8759 }
7138 8760
7139 8761 // Sanitize imported data recursively
7140 - $imported = map_deep( $imported, 'sanitize_text_field' );
8762 + $imported = $this->sanitize_imported_settings( $imported );
7141 8763
7142 8764 // Validate structure: only sections and keys of the schema survive, and
7143 8765 // every value takes the type of its default. Until 2.11.0 this was an
7144 8766 // array_replace_recursive() of the file over the defaults, so any key in
@@ -7156,8 +8778,9 @@
7156 8778 }
7157 8779 }
7158 8780
7159 8781 // Save
8782 + $merged = Vigilante_Settings::keep_locked_file_settings( $merged, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7160 8783 update_option( Vigilante_Settings::OPTION_NAME, $merged );
7161 8784 $this->settings->clear_cache();
7162 8785
7163 8786 // Re-evaluate the active preset marker. The imported config may match
@@ -7180,9 +8803,9 @@
7180 8803 if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
7181 8804 wp_schedule_single_event( time() + 5, 'vigilante_under_attack_post_scan' );
7182 8805 }
7183 8806
7184 - wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) );
8807 + wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
7185 8808 }
7186 8809
7187 8810 /**
7188 8811 * Detect whether a vigilante_options array matches a known preset.
@@ -7285,9 +8908,11 @@
7285 8908 $preset = isset( $_POST['preset'] ) ? sanitize_key( $_POST['preset'] ) : '';
7286 8909
7287 8910 // Handle reset to defaults
7288 8911 if ( 'reset' === $preset ) {
7289 - $defaults = Vigilante_Settings::get_defaults_preserving_user_data( get_option( Vigilante_Settings::OPTION_NAME, array() ) );
8912 + $stored_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
8913 + $defaults = Vigilante_Settings::get_defaults_preserving_user_data( $stored_options );
8914 + $defaults = Vigilante_Settings::keep_locked_file_settings( $defaults, $stored_options );
7290 8915 update_option( Vigilante_Settings::OPTION_NAME, $defaults );
7291 8916 $this->settings->clear_cache();
7292 8917
7293 8918 // Clear active preset
@@ -7295,9 +8920,9 @@
7295 8920
7296 8921 // Apply file changes after reset
7297 8922 $this->apply_all_file_changes( $defaults );
7298 8923
7299 - wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) );
8924 + wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) . $this->locked_file_settings_message() );
7300 8925 return;
7301 8926 }
7302 8927
7303 8928 $presets = $this->settings->get_presets();
@@ -7323,8 +8948,9 @@
7323 8948 // invent keys that are missing on both sides.
7324 8949 $current = Vigilante_Settings::merge_preset( $this->settings->get_default_options(), $current );
7325 8950
7326 8951 $merged = Vigilante_Settings::merge_preset( $current, $preset_options );
8952 + $merged = Vigilante_Settings::keep_locked_file_settings( $merged, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7327 8953
7328 8954 update_option( Vigilante_Settings::OPTION_NAME, $merged );
7329 8955 $this->settings->clear_cache();
7330 8956
@@ -7333,9 +8959,9 @@
7333 8959
7334 8960 // Apply file changes after preset
7335 8961 $this->apply_all_file_changes( $merged );
7336 8962
7337 - wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) );
8963 + wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
7338 8964 }
7339 8965
7340 8966 /**
7341 8967 * AJAX: Reset a specific section to defaults
@@ -7370,27 +8996,19 @@
7370 8996 * On a subsite, the settings written to wp-config.php and .htaccess are
7371 8997 * the main site's business. Resetting the local copy of those would only
7372 8998 * make this screen disagree with the file, so they are carried over
7373 8999 * untouched, and a section that is nothing but shared settings is not
7374 - * reset at all.
9000 + * reset at all. On the main site, a user without network rights keeps
9001 + * the ones the shared files are built from as well (2.11.6).
7375 9002 */
7376 - if ( ! Vigilante_Settings::can_write_shared_files() ) {
7377 - $shared = Vigilante_Settings::get_shared_file_settings();
9003 + $locked = Vigilante_Settings::get_locked_file_settings();
7378 9004
7379 - if ( isset( $shared[ $section ] ) ) {
7380 - if ( true === $shared[ $section ] ) {
7381 - wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7382 - }
7383 -
7384 - foreach ( $shared[ $section ] as $shared_key ) {
7385 - if ( array_key_exists( $shared_key, (array) $current_options[ $section ] ) ) {
7386 - $new_values[ $shared_key ] = $current_options[ $section ][ $shared_key ];
7387 - }
7388 - }
7389 - }
9005 + if ( isset( $locked[ $section ] ) && true === $locked[ $section ] ) {
9006 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7390 9007 }
7391 9008
7392 9009 $current_options[ $section ] = $new_values;
9010 + $current_options = Vigilante_Settings::keep_locked_file_settings( $current_options, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7393 9011
7394 9012 // Save
7395 9013 update_option( Vigilante_Settings::OPTION_NAME, $current_options );
7396 9014 $this->settings->clear_cache();
@@ -7473,8 +9091,19 @@
7473 9091 // Save new results
7474 9092 update_option( 'vigilante_last_integrity_scan', time() );
7475 9093 update_option( 'vigilante_last_integrity_results', $results );
7476 9094
9095 + // On the main site the scan does compute the lines of wp-config.php and
9096 + // .htaccess, for the network administrator. Somebody without network
9097 + // rights gets the change and its sizes, not the lines.
9098 + if ( $this->critical_approval_locked() && ! empty( $results['modified'] ) && is_array( $results['modified'] ) ) {
9099 + foreach ( $results['modified'] as $index => $item ) {
9100 + if ( is_array( $item ) && 'critical_config' === ( $item['type'] ?? '' ) ) {
9101 + $results['modified'][ $index ]['diff'] = Vigilante_File_Integrity::network_only_diff();
9102 + }
9103 + }
9104 + }
9105 +
7477 9106 wp_send_json_success( array(
7478 9107 'message' => __( 'Scan completed.', 'vigilante' ),
7479 9108 'results' => $results,
7480 9109 'ignored_count' => count( get_option( 'vigilante_ignored_files', array() ) ),
@@ -7508,11 +9137,50 @@
7508 9137 if ( ! current_user_can( 'manage_options' ) ) {
7509 9138 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
7510 9139 }
7511 9140
9141 + $results = get_option( 'vigilante_last_integrity_results' );
9142 + $scanned_at = get_option( 'vigilante_last_integrity_scan' );
9143 +
7512 9144 delete_option( 'vigilante_last_integrity_results' );
7513 9145 delete_option( 'vigilante_last_integrity_scan' );
7514 9146
9147 + // Starting over also means being told again: the next scan emails
9148 + // whatever it finds, as if nothing had been reported before.
9149 + delete_option( Vigilante_File_Integrity::NOTIFIED_OPTION );
9150 +
9151 + /*
9152 + * A pending change to wp-config.php or the root .htaccess is closed by
9153 + * approving it, which takes the network. Clearing the results was one
9154 + * more way to close it without, until the next scan: the ignore list was
9155 + * shut in 2.11.8 and this button was left open, found by the cross
9156 + * review of 2.11.8. So for somebody who cannot approve, those entries
9157 + * stay and everything else goes.
9158 + */
9159 + // The findings about Vigilant's own files stay too: they report for the
9160 + // whole network, and ignoring them already takes network rights.
9161 + if ( $this->critical_approval_locked() && is_array( $results ) ) {
9162 + $kept = array();
9163 + $found = false;
9164 + foreach ( array( 'modified', 'missing', 'suspicious', 'extra' ) as $bucket ) {
9165 + $kept[ $bucket ] = array_values(
9166 + array_filter(
9167 + isset( $results[ $bucket ] ) && is_array( $results[ $bucket ] ) ? $results[ $bucket ] : array(),
9168 + function ( $item ) {
9169 + return is_array( $item ) && in_array( $item['type'] ?? '', array( 'critical_config', 'vigilante_self' ), true );
9170 + }
9171 + )
9172 + );
9173 + $found = $found || ! empty( $kept[ $bucket ] );
9174 + }
9175 +
9176 + if ( $found ) {
9177 + $results = array_merge( $results, $kept );
9178 + update_option( 'vigilante_last_integrity_results', $results );
9179 + update_option( 'vigilante_last_integrity_scan', $scanned_at ? $scanned_at : time() );
9180 + }
9181 + }
9182 +
7515 9183 if ( $this->database ) {
7516 9184 $this->database->clear_file_hashes();
7517 9185 }
7518 9186
@@ -7532,14 +9200,49 @@
7532 9200 if ( ! current_user_can( 'manage_options' ) ) {
7533 9201 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
7534 9202 }
7535 9203
7536 - $file = isset( $_POST['file'] ) ? sanitize_text_field( wp_unslash( $_POST['file'] ) ) : '';
9204 + /*
9205 + * The path as the scan reported it, which is what the interface sends
9206 + * back. sanitize_text_field() is the wrong reader for it: it deletes
9207 + * every %XX octet and collapses repeated spaces, so a file named
9208 + * foto%20nueva.php was stored as fotonueva.php, the scan compared the
9209 + * list with the paths it finds one by one, never matched, and the
9210 + * warning came back (measured on 1 oct 2026 against 3.0.1). The four
9211 + * handlers of the ignore list read the path the same way.
9212 + *
9213 + * Known limits, found by the cross review, that only a file name with a
9214 + * stray < in it reaches. wp_strip_all_tags() shortens such a name at the
9215 + * < instead of refusing it, so the stored entry can match another file
9216 + * (a.php<b is stored as a.php), and the value ignore_file() returns is
9217 + * not read, so a refusal of its own is answered as success. The fix, if
9218 + * a site ever needs it, is to answer with an error when
9219 + * wp_strip_all_tags() changes what was sent and to check that return
9220 + * value. An entry the old reader stored for such a name (it wrote the
9221 + * < as &lt;) is listed but "Stop ignoring" cannot remove it; "Clear all
9222 + * ignored files" does.
9223 + */
9224 + $file = ( isset( $_POST['file'] ) && is_string( $_POST['file'] ) ) ? wp_strip_all_tags( wp_unslash( $_POST['file'] ) ) : '';
7537 9225
7538 9226 if ( empty( $file ) ) {
7539 9227 wp_send_json_error( __( 'No file specified.', 'vigilante' ) );
7540 9228 }
7541 9229
9230 + // A change to a shared file is closed by approving it, and approving it
9231 + // takes the network. Ignoring it would close the same warning without.
9232 + if ( $this->critical_approval_locked() && in_array( $file, array( 'wp-config.php', '.htaccess' ), true ) ) {
9233 + wp_send_json_error( $this->critical_approval_notice() );
9234 + }
9235 +
9236 + // Vigilant's own files are never ignored, on any site: silencing the
9237 + // check that says the security plugin was changed is the one button
9238 + // an attacker would want on this screen. Since 3.0.0 those findings
9239 + // are not rows of these tables either, so nothing in the interface
9240 + // sends them here; this is the door, not the label.
9241 + if ( Vigilante_Self_Integrity::is_own_file_path( $file ) ) {
9242 + 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' ) );
9243 + }
9244 +
7542 9245 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database );
7543 9246 $file_integrity->ignore_file( $file );
7544 9247
7545 9248 // Also remove the file from stored scan results so UI updates
@@ -7572,9 +9275,9 @@
7572 9275 if ( ! current_user_can( 'manage_options' ) ) {
7573 9276 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
7574 9277 }
7575 9278
7576 - $file = isset( $_POST['file'] ) ? sanitize_text_field( wp_unslash( $_POST['file'] ) ) : '';
9279 + $file = ( isset( $_POST['file'] ) && is_string( $_POST['file'] ) ) ? wp_strip_all_tags( wp_unslash( $_POST['file'] ) ) : '';
7577 9280
7578 9281 if ( empty( $file ) ) {
7579 9282 wp_send_json_error( __( 'No file specified.', 'vigilante' ) );
7580 9283 }
@@ -7603,12 +9306,16 @@
7603 9306 if ( ! is_array( $raw_files ) ) {
7604 9307 wp_send_json_error( __( 'Invalid request.', 'vigilante' ) );
7605 9308 }
7606 9309
7607 - $files = array();
9310 + $files = array();
9311 + $locked = $this->critical_approval_locked();
9312 + $shared = $locked ? array( 'wp-config.php', '.htaccess' ) : array();
7608 9313 foreach ( $raw_files as $f ) {
7609 - $clean = sanitize_text_field( $f );
7610 - if ( '' !== $clean ) {
9314 + $clean = is_string( $f ) ? wp_strip_all_tags( $f ) : '';
9315 + // Same rule as ajax_ignore_file(): the two shared files when the
9316 + // network locks them, and Vigilant's own files always.
9317 + if ( '' !== $clean && ! in_array( $clean, $shared, true ) && ! Vigilante_Self_Integrity::is_own_file_path( $clean ) ) {
7611 9318 $files[] = $clean;
7612 9319 }
7613 9320 }
7614 9321
@@ -7672,9 +9379,9 @@
7672 9379 }
7673 9380
7674 9381 $files = array();
7675 9382 foreach ( $raw_files as $f ) {
7676 - $clean = sanitize_text_field( $f );
9383 + $clean = is_string( $f ) ? wp_strip_all_tags( $f ) : '';
7677 9384 if ( '' !== $clean ) {
7678 9385 $files[] = $clean;
7679 9386 }
7680 9387 }