PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.5
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.5
3.0.6 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 All 94 releases
← All changes | admin/class-admin.php +2214 -209 2.10.4 → 3.0.5 View file →
@@ -107,8 +107,11 @@
107 107 add_action( 'admin_init', array( $this, 'redirect_submenu_shortcuts' ) );
108 108 add_action( 'admin_init', array( $this, 'register_settings' ) );
109 109 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
110 110 add_action( 'admin_notices', array( $this, 'show_admin_notices' ) );
111 + // Self-protection speaks in the network admin too: on a network its
112 + // files are shared, and the super administrator is who can repair them.
113 + add_action( 'network_admin_notices', array( $this, 'maybe_show_self_protection_notice' ) );
111 114
112 115 // Highlight correct submenu based on active tab
113 116 add_filter( 'submenu_file', array( $this, 'highlight_submenu_tab' ) );
114 117
@@ -199,8 +202,26 @@
199 202 /**
200 203 * Run database migrations based on stored version
201 204 */
202 205 public function run_migrations() {
206 + /*
207 + * admin-ajax.php fires admin_init before it decides who is asking
208 + * (wp-admin/admin-ajax.php:45), so until 2.11.10 an anonymous POST to
209 + * admin-ajax.php with any action ran every pending migration. That is
210 + * not a read: the migrations rewrite wp-config.php through
211 + * apply_security_constants(), rewrite the root .htaccess, move user meta
212 + * of the whole network and can rebuild the file integrity baseline,
213 + * taking whatever is on disk as approved. Reproduced on 12 sep 2026 with
214 + * curl and no cookies, and found by the file-by-file review of 2.11.10.
215 + *
216 + * Migrations are maintenance for whoever administers the site, so they
217 + * wait for an administrator to load a screen. Nothing is lost by
218 + * waiting: every migration is idempotent and version gated.
219 + */
220 + if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {
221 + return;
222 + }
223 +
203 224 $db_version = get_option( 'vigilante_db_version', '0' );
204 225
205 226 // 1.2.3: Fix IP lists corrupted by sanitize_text_field stripping newlines
206 227 if ( version_compare( $db_version, '1.2.3', '<' ) ) {
@@ -273,9 +294,29 @@
273 294 if ( ! class_exists( 'Vigilante_File_Integrity' ) ) {
274 295 require_once VIGILANTE_INCLUDES_DIR . 'class-file-integrity.php';
275 296 }
276 297 $fi = new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
277 - $fi->regenerate_all_baselines();
298 +
299 + /*
300 + * Only when there is nothing on record. This migration exists to
301 + * create the baseline that did not exist, never to discard the one
302 + * the owner approved: rebuilding it from the files takes whatever
303 + * is on disk right now as approved, so a wp-config.php modified and
304 + * awaiting review would be blessed in silence.
305 + *
306 + * And this is not theory. vigilante_db_version is written on two
307 + * different scales into the same option: this file counts in plugin
308 + * versions (2.11.0) and Vigilante_Database counts in schema
309 + * versions, currently 1.4.0 (class-database.php:322 and :380). For
310 + * version_compare, 1.4.0 is LOWER than 1.14.0, so any site whose
311 + * option was last written by the schema runs this migration again.
312 + * Measured on the Multisite install on 10 sep 2026: one of the three
313 + * sites was sitting on 1.4.0.
314 + */
315 + if ( ! $fi->get_critical_files_baseline() ) {
316 + $fi->regenerate_all_baselines();
317 + }
318 +
278 319 update_option( 'vigilante_db_version', '1.14.0' );
279 320 }
280 321
281 322 // 2.0.0: Move hide_server_signature and remove_fingerprinting_headers
@@ -425,11 +466,232 @@
425 466 }
426 467
427 468 update_option( 'vigilante_db_version', '2.9.9' );
428 469 }
470 +
471 + /*
472 + * 2.11.0: security release (audit of 28 Aug 2026). Runs here and not
473 + * from Vigilante_Database::needs_update(): this option is shared with
474 + * that class, and on any updated site it already holds a plugin version
475 + * (2.9.9 or later), so a bump of DB_VERSION would never fire.
476 + * create_tables() widens the email code column through dbDelta (varchar
477 + * 6 to 64, the code is stored hashed since 2.11.0) and purge_for_2_11_0()
478 + * does what dbDelta cannot: it empties the trusted devices, which were
479 + * identified by User-Agent until now (S1), and the pending email codes,
480 + * stored in clear until now (S11). Every remembered device asks for the
481 + * second factor once more after this update, and the changelog says so.
482 + */
483 + if ( version_compare( $db_version, '2.11.0', '<' ) ) {
484 + $this->database->create_tables();
485 + $this->database->purge_for_2_11_0();
486 +
487 + update_option( 'vigilante_db_version', '2.11.0' );
488 + }
489 +
490 + /*
491 + * 2.11.9: clear the raw .htaccess copies that older versions left in
492 + * options, on the first admin load after the update. Uninstall already
493 + * removes them, but that only fires when the plugin is deleted, so a
494 + * site that keeps the plugin carried them until now. Three stores, each
495 + * a copy of a file that can hold secrets (a SetEnv token, an
496 + * Authorization header): the same exposure the wp.org review flagged as
497 + * 4.4, on the paths its fix did not reach.
498 + *
499 + * - vigilante_htaccess_history: up to five raw copies, by design, until
500 + * 2.11.8. The writer is gone, nothing reads it, so it is deleted.
501 + * - vigilante_htaccess_backup: the single rollback buffer, normally
502 + * cleared in the finally of each write; a copy only lingers if a write
503 + * crashed mid-operation. Nothing outside one write reads it, so a
504 + * leftover is deleted.
505 + * - vigilante_htaccess_pre_migration: still read by the header recovery,
506 + * but older versions stored the whole file where only our own block is
507 + * ever used. Truncated to that block, so the feature keeps working and
508 + * nothing outside our markers stays in the option.
509 + */
510 + if ( version_compare( $db_version, '2.11.9', '<' ) ) {
511 + delete_option( 'vigilante_htaccess_history' );
512 + delete_option( 'vigilante_htaccess_backup' );
513 +
514 + $snapshot = get_option( 'vigilante_htaccess_pre_migration' );
515 + if ( is_array( $snapshot ) && isset( $snapshot['content'] ) && '' !== (string) $snapshot['content'] ) {
516 + require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-recovery.php';
517 + $block = Vigilante_Htaccess_Recovery::get_raw_block();
518 +
519 + if ( '' === $block ) {
520 + delete_option( 'vigilante_htaccess_pre_migration' );
521 + } elseif ( $block !== $snapshot['content'] ) {
522 + $snapshot['content'] = $block;
523 + update_option( 'vigilante_htaccess_pre_migration', $snapshot, false );
524 + }
525 + }
526 +
527 + update_option( 'vigilante_db_version', '2.11.9' );
528 + }
529 +
530 + /*
531 + * 2.11.10: the pending-approval flag becomes one per site on a network.
532 + * Until 2.11.9 it was a single global user meta, so the queue was shared
533 + * across the whole network. Moving the key is not enough: the accounts
534 + * already waiting carry the old key, and reading only the new one would
535 + * let them log in. So they are moved here, each to the site it belongs
536 + * to, and the old key is removed only once the new one is written.
537 + */
538 + if ( version_compare( $db_version, '2.11.10', '<' ) ) {
539 + $this->migrate_pending_approval_per_site();
540 +
541 + update_option( 'vigilante_db_version', '2.11.10' );
542 + }
543 +
544 + /*
545 + * 3.0.0: self-protection package. This block must stay the LAST one of
546 + * run_migrations(): every block compares against the same $db_version
547 + * captured at the top, so the last update_option() that runs is the
548 + * one that sticks, and on a jump from any older version it has to be
549 + * this one.
550 + *
551 + * 1. Capture the self-integrity anchor (the manifest fingerprint) and
552 + * run an inline self-check. It covers every update the old code never
553 + * saw through the upgrader hook: from any 2.x, and manual or FTP
554 + * uploads. A plain new is enough, the constructor registers no hooks.
555 + * 2. Drop the cached Security Check report: the Internal category grows
556 + * from 30 to 33 points (the self_integrity check), and the cached
557 + * report would keep the old denominator until the next full scan.
558 + * Same reason and same background refresh as the 2.6.1 block above.
559 + * 3. Remove Vigilant's own files from the ignore list before that check.
560 + * Until 2.11.11 the generic scan listed them like any plugin, and any
561 + * administrator could ignore one (after a false alarm while
562 + * WordPress.org published new checksums, for example). Kept, those
563 + * entries would silence the self-check of those files for good,
564 + * PHP included.
565 + */
566 + if ( version_compare( $db_version, '3.0.0', '<' ) ) {
567 + if ( ! class_exists( 'Vigilante_Self_Integrity' ) ) {
568 + require_once VIGILANTE_INCLUDES_DIR . 'class-self-integrity.php';
569 + }
570 + $ignored_files = get_option( 'vigilante_ignored_files', array() );
571 + if ( is_array( $ignored_files ) && $ignored_files ) {
572 + $kept_files = array_values(
573 + array_filter(
574 + $ignored_files,
575 + function ( $ignored_file ) {
576 + return ! Vigilante_Self_Integrity::is_own_file_path( (string) $ignored_file );
577 + }
578 + )
579 + );
580 + if ( count( $kept_files ) !== count( $ignored_files ) ) {
581 + update_option( 'vigilante_ignored_files', $kept_files );
582 + if ( $this->activity_log ) {
583 + $this->activity_log->log(
584 + 'system',
585 + 'self_ignored_files_removed',
586 + __( 'Vigilant files were removed from the File Integrity ignore list: self-protection checks them now.', 'vigilante' ),
587 + array( 'removed' => count( $ignored_files ) - count( $kept_files ) ),
588 + 'info'
589 + );
590 + }
591 + }
592 + }
593 + $self_integrity = new Vigilante_Self_Integrity( $this->settings, $this->activity_log );
594 + $self_integrity->run_check( 'migration' );
595 +
596 + delete_option( 'vigilante_analyzer_last_scan' );
597 + if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
598 + wp_schedule_single_event( time() + 5, 'vigilante_under_attack_post_scan' );
599 + }
600 +
601 + update_option( 'vigilante_db_version', '3.0.0' );
602 + }
429 603 }
430 604
431 605 /**
606 + * Move the pending-approval flag of a network to a key per site
607 + *
608 + * Runs once for the whole network, not once per site: the data it moves is
609 + * global, so the guard is a network option and any site may be the one that
610 + * does it. On a single site the key does not change and there is nothing to
611 + * do.
612 + *
613 + * Each waiting account goes to its primary site, or to the only site it
614 + * belongs to; one that belongs to none goes to the main site rather than
615 + * nowhere, because losing the flag would silently approve it.
616 + *
617 + * @since 2.11.10
618 + */
619 + private function migrate_pending_approval_per_site() {
620 + global $wpdb;
621 +
622 + if ( ! is_multisite() ) {
623 + return;
624 + }
625 +
626 + if ( get_site_option( 'vigilante_pending_per_site_done' ) ) {
627 + return;
628 + }
629 +
630 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- One-off migration of the plugin's own user meta; the meta API has no "list every user with this key".
631 + $user_ids = $wpdb->get_col(
632 + $wpdb->prepare( "SELECT DISTINCT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s", 'vigilante_pending_approval' )
633 + );
634 +
635 + foreach ( (array) $user_ids as $user_id ) {
636 + $user_id = (int) $user_id;
637 + if ( ! $user_id ) {
638 + continue;
639 + }
640 +
641 + $pending = get_user_meta( $user_id, 'vigilante_pending_approval', true );
642 + $since = get_user_meta( $user_id, 'vigilante_pending_since', true );
643 +
644 + /*
645 + * Every site the account belongs to, not its primary one. The global
646 + * flag does not say where the registration happened, and the first
647 + * version of this guessed the primary blog: an account that
648 + * registered on B while its primary was A came out pending on A and
649 + * free to log in on B, which is the very site it had never been
650 + * approved on. Found by the cross review of 2.11.10.
651 + *
652 + * Marking every site it belongs to fails closed instead: the account
653 + * stays blocked wherever it can log in, and shows up in the queue of
654 + * each of those sites so somebody can actually act on it. An account
655 + * that belongs to no site goes to the main one rather than nowhere,
656 + * because losing the flag would silently approve it.
657 + */
658 + /*
659 + * With $all true, because the default leaves out archived, spam and
660 + * deleted sites (wp-includes/user.php:1113-1117): a site archived on
661 + * the day this runs would lose the flag, and the account would walk
662 + * in unapproved the moment it was brought back. Found by the second
663 + * cross review of 2.11.10.
664 + */
665 + $blog_ids = array();
666 +
667 + foreach ( get_blogs_of_user( $user_id, true ) as $blog ) {
668 + if ( ! empty( $blog->userblog_id ) ) {
669 + $blog_ids[] = (int) $blog->userblog_id;
670 + }
671 + }
672 +
673 + if ( empty( $blog_ids ) ) {
674 + $blog_ids[] = (int) get_main_site_id();
675 + }
676 +
677 + foreach ( array_unique( $blog_ids ) as $blog_id ) {
678 + $prefix = $wpdb->get_blog_prefix( $blog_id );
679 +
680 + update_user_meta( $user_id, $prefix . 'vigilante_pending_approval', $pending );
681 + if ( '' !== $since && false !== $since ) {
682 + update_user_meta( $user_id, $prefix . 'vigilante_pending_since', $since );
683 + }
684 + }
685 +
686 + delete_user_meta( $user_id, 'vigilante_pending_approval' );
687 + delete_user_meta( $user_id, 'vigilante_pending_since' );
688 + }
689 +
690 + update_site_option( 'vigilante_pending_per_site_done', 1 );
691 + }
692 +
693 + /**
432 694 * Migration: Remove orphaned email fields from saved options
433 695 *
434 696 * v1.10.0 centralized notification recipients into email section.
435 697 * Old per-module notify_email fields and dead email section fields
@@ -525,17 +787,30 @@
525 787 $pending_count = $this->get_pending_approvals_count();
526 788
527 789 // Get security issues with severity
528 790 $security_status = $this->get_security_status_for_badge();
529 -
791 +
792 + /*
793 + * Self-protection: a change to Vigilant own files is the one finding
794 + * that has to be visible from any screen of WordPress, so it adds to
795 + * the counter and paints it red. A verified state, a state with fewer
796 + * references than usual and the check turned off add nothing: a counter
797 + * that is always on is a counter nobody reads.
798 + */
799 + $self_tone = Vigilante_Self_Integrity::tone(
800 + Vigilante_Self_Integrity::display_state(),
801 + Vigilante_Self_Integrity::is_on()
802 + );
803 + $self_badge = in_array( $self_tone, array( 'critical', 'off', 'warning' ), true ) ? 1 : 0;
804 +
530 805 // Total count for badge
531 - $total_badge = $pending_count + $security_status['count'];
532 -
806 + $total_badge = $pending_count + $security_status['count'] + $self_badge;
807 +
533 808 if ( $total_badge > 0 ) {
534 809 // Determine badge color:
535 810 // - Red (awaiting-mod): pending approvals OR critical modules disabled
536 811 // - Orange (update-plugins): only non-critical modules disabled
537 - if ( $pending_count > 0 || $security_status['has_critical'] ) {
812 + if ( $pending_count > 0 || $security_status['has_critical'] || in_array( $self_tone, array( 'critical', 'off' ), true ) ) {
538 813 $badge_class = 'awaiting-mod';
539 814 } else {
540 815 $badge_class = 'update-plugins vigilante-badge-warning';
541 816 }
@@ -577,13 +852,21 @@
577 852 'vigilante-activity-log',
578 853 array( $this, 'redirect_to_tab' )
579 854 );
580 855
581 - // File Integrity shortcut
856 + // File Integrity shortcut, with its own counter when the self-check has
857 + // something to say: that is the screen that explains it.
858 + $fi_menu_title = __( 'File Integrity', 'vigilante' );
859 + if ( $self_badge > 0 ) {
860 + $fi_menu_title .= sprintf(
861 + ' <span class="%s count-1"><span class="pending-count">1</span></span>',
862 + in_array( $self_tone, array( 'critical', 'off' ), true ) ? 'awaiting-mod' : 'update-plugins vigilante-badge-warning'
863 + );
864 + }
582 865 add_submenu_page(
583 866 'vigilante',
584 867 __( 'File Integrity', 'vigilante' ),
585 - __( 'File Integrity', 'vigilante' ),
868 + $fi_menu_title,
586 869 'manage_options',
587 870 'vigilante-file-integrity',
588 871 array( $this, 'redirect_to_tab' )
589 872 );
@@ -697,29 +980,66 @@
697 980 if ( ! did_action( 'plugins_loaded' ) ) {
698 981 return 0;
699 982 }
700 983
701 - $registration_approval = $this->settings->get_section( 'user_security' );
702 - $approval_settings = $registration_approval['registration_approval'] ?? array();
703 -
704 - if ( empty( $approval_settings['enabled'] ) ) {
705 - return 0;
706 - }
984 + /*
985 + * Counted whether the feature is on or off. An account already waiting
986 + * stays blocked when it is switched off (see init_enforcement_hooks()),
987 + * so reporting zero there hid people who cannot log in and whom nobody
988 + * could see to approve. Found by the cross review of 2.11.10.
989 + */
707 990
708 991 // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Limited results in admin context.
709 - $pending_users = get_users( array(
710 - 'meta_key' => 'vigilante_pending_approval',
992 + $args = array(
993 + 'meta_key' => Vigilante_User_Security::site_user_meta_key( 'vigilante_pending_approval' ),
711 994 'meta_value' => '1',
712 995 'fields' => 'ID',
713 - ) );
996 + );
714 997 // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
715 998
999 + // Same query as Vigilante_User_Security::get_pending_users(), and for the
1000 + // same reason: the meta key already scopes this to the site, and adding
1001 + // core's membership filter on top hid the accounts that have no role yet.
1002 + if ( is_multisite() ) {
1003 + $args['blog_id'] = 0;
1004 + }
1005 +
1006 + $pending_users = get_users( $args );
1007 +
716 1008 return count( $pending_users );
717 1009 }
718 1010
719 1011 /**
1012 + * Whether XML-RPC is blocked, whole or its pingback methods
1013 + *
1014 + * Both levels have to say so: the three-way setting under WP Hardening, read
1015 + * through the module's own resolver, and the WP Hardening module that
1016 + * enforces it. Until 3.0.2 the Dashboard read login_security[disable_xmlrpc],
1017 + * the checkbox that moved to WP Hardening in 2.9.7 and is never written any
1018 + * more, so the score never granted its three points and the recommendation
1019 + * to disable XML-RPC stayed on for good.
1020 + *
1021 + * @since 3.0.2
1022 + *
1023 + * @param array $options Plugin options.
1024 + * @return bool
1025 + */
1026 + private function is_xmlrpc_protected( $options ) {
1027 + return ! empty( $options['modules']['wp_hardening'] )
1028 + && 'none' !== Vigilante_Comment_Security::resolve_xmlrpc_mode( $this->settings );
1029 + }
1030 +
1031 + /**
720 1032 * Calculate comprehensive security score (0-100)
721 1033 *
1034 + * The maximum is the same for every site, 129 points. A module that is
1035 + * switched off loses its weight and the points of its details, which stay
1036 + * in the total. Until 3.0.4 the details of a module only counted, on both
1037 + * sides of the fraction, while the module was on: a site with almost
1038 + * everything off kept a middling score, because what it had switched off
1039 + * left the total as well, and switching Security Audit off with no alert
1040 + * configured raised the score.
1041 + *
722 1042 * @param array $options Plugin options.
723 1043 * @return int Security score.
724 1044 */
725 1045 private function calculate_security_score( $options ) {
@@ -745,11 +1065,11 @@
745 1065 }
746 1066 }
747 1067
748 1068 // Firewall details (10 points)
1069 + $max_score += 10;
749 1070 if ( ! empty( $options['modules']['firewall'] ) ) {
750 1071 $firewall = $options['firewall'] ?? array();
751 - $max_score += 10;
752 1072
753 1073 $firewall_checks = array(
754 1074 'block_sql_injection',
755 1075 'block_xss_attacks',
@@ -767,18 +1087,18 @@
767 1087 $score += min( 10, $firewall_enabled * 2 );
768 1088 }
769 1089
770 1090 // Login security details (10 points)
1091 + $max_score += 10;
771 1092 if ( ! empty( $options['modules']['login_security'] ) ) {
772 1093 $login = $options['login_security'] ?? array();
773 - $max_score += 10;
774 1094
775 1095 // Max attempts configured
776 1096 if ( isset( $login['max_attempts'] ) && $login['max_attempts'] <= 5 ) {
777 1097 $score += 3;
778 1098 }
779 - // XML-RPC disabled
780 - if ( ! empty( $login['disable_xmlrpc'] ) ) {
1099 + // XML-RPC blocked, whole or its pingback methods
1100 + if ( $this->is_xmlrpc_protected( $options ) ) {
781 1101 $score += 3;
782 1102 }
783 1103 // 2FA enabled
784 1104 if ( ! empty( $login['two_factor']['enabled'] ) ) {
@@ -786,11 +1106,11 @@
786 1106 }
787 1107 }
788 1108
789 1109 // Security headers details (10 points)
1110 + $max_score += 10;
790 1111 if ( ! empty( $options['modules']['security_headers'] ) ) {
791 1112 $headers = $options['security_headers'] ?? array();
792 - $max_score += 10;
793 1113
794 1114 if ( ! empty( $headers['x_frame_options'] ) ) {
795 1115 $score += 2;
796 1116 }
@@ -805,11 +1125,11 @@
805 1125 }
806 1126 }
807 1127
808 1128 // User security details (10 points)
1129 + $max_score += 10;
809 1130 if ( ! empty( $options['modules']['user_security'] ) ) {
810 1131 $user = $options['user_security'] ?? array();
811 - $max_score += 10;
812 1132
813 1133 if ( ! empty( $user['block_insecure_usernames'] ) ) {
814 1134 $score += 3;
815 1135 }
@@ -823,14 +1143,14 @@
823 1143 $score += 2;
824 1144 }
825 1145 }
826 1146
827 - // Audit alerts details (6 points) - only when Security Audit is on,
828 - // because the alerting layer rides on top of the activity log. Leaving
829 - // both alert legs off keeps these points unearned.
1147 + // Audit alerts details (6 points) - earned only when Security Audit is
1148 + // on, because the alerting layer rides on top of the activity log.
1149 + // Leaving both alert legs off keeps these points unearned.
1150 + $max_score += 6;
830 1151 if ( ! empty( $options['modules']['activity_log'] ) ) {
831 1152 $alerts = isset( $options['audit_alerts'] ) ? (array) $options['audit_alerts'] : array();
832 - $max_score += 6;
833 1153 if ( Vigilante_Audit_Alerts::immediate_is_active( $alerts ) ) {
834 1154 $score += 3;
835 1155 }
836 1156 if ( Vigilante_Audit_Alerts::threshold_is_active( $alerts ) ) {
@@ -837,8 +1157,31 @@
837 1157 $score += 3;
838 1158 }
839 1159 }
840 1160
1161 + /*
1162 + * Self-protection (15 points). Having it on is configuration, which is
1163 + * what this card measures; the state of the last check caps the card,
1164 + * because a plugin whose own files were changed is not well configured
1165 + * in any useful sense, whatever the rest of the settings say. The
1166 + * environment block below already mixes measured state into this score
1167 + * (WP_DEBUG, insecure usernames), so the card keeps its meaning.
1168 + */
1169 + $max_score += 15;
1170 + $self_enabled = Vigilante_Self_Integrity::is_on();
1171 + $self_tone = Vigilante_Self_Integrity::tone( Vigilante_Self_Integrity::display_state(), $self_enabled );
1172 + if ( $self_enabled ) {
1173 + $score += 10;
1174 + // The five points are for a check that ran and came out clean: a
1175 + // site that has never checked itself has not earned them. Tasks
1176 + // that Vigilant had to schedule again, while that is only a
1177 + // warning, do not take them away: the files are as clean as they
1178 + // were (Vigilante_Self_Integrity::score_tone()).
1179 + if ( in_array( Vigilante_Self_Integrity::score_tone( Vigilante_Self_Integrity::display_state(), $self_enabled ), array( 'ok', 'info' ), true ) ) {
1180 + $score += 5;
1181 + }
1182 + }
1183 +
841 1184 // Environment checks (8 points) - penalize insecure server configuration
842 1185 $max_score += 8;
843 1186 $env_score = 8;
844 1187
@@ -854,12 +1197,33 @@
854 1197 }
855 1198
856 1199 $score += max( 0, $env_score );
857 1200
858 - return $max_score > 0 ? round( ( $score / $max_score ) * 100 ) : 0;
1201 + $percent = $max_score > 0 ? (int) round( ( $score / $max_score ) * 100 ) : 0;
1202 + if ( in_array( $self_tone, array( 'critical', 'off' ), true ) ) {
1203 + // Same cap and same reason as the Security Check score.
1204 + $percent = min( $percent, Vigilante_Security_Analyzer::SCORE_CAP_ON_TAMPER );
1205 + }
1206 + return $percent;
859 1207 }
860 1208
861 1209 /**
1210 + * Recommendation of the Dashboard for a critical self-protection state
1211 + *
1212 + * @param string $kind What the alarm is about, from Vigilante_Self_Integrity::alarm_kind().
1213 + * @return string
1214 + */
1215 + private function self_critical_recommendation( $kind ) {
1216 + if ( 'tasks' === $kind ) {
1217 + return __( 'Something keeps removing Vigilant scheduled tasks: File Integrity says what to look for.', 'vigilante' );
1218 + }
1219 + if ( 'hooks' === $kind ) {
1220 + return __( 'Something removed the hooks of Vigilant self-protection: File Integrity says what to look for.', 'vigilante' );
1221 + }
1222 + return __( 'Vigilant own files have been changed: repair Vigilant from File Integrity before anything else.', 'vigilante' );
1223 + }
1224 +
1225 + /**
862 1226 * Get security recommendations based on current settings
863 1227 *
864 1228 * @param array $options Plugin options.
865 1229 * @return array Array of recommendations.
@@ -866,8 +1230,46 @@
866 1230 */
867 1231 private function get_security_recommendations( $options ) {
868 1232 $recommendations = array();
869 1233
1234 + // Self-protection first: if Vigilant itself cannot be trusted, nothing
1235 + // else on this card means much.
1236 + $vg_self_enabled = Vigilante_Self_Integrity::is_on();
1237 + $vg_self_tone = Vigilante_Self_Integrity::tone( Vigilante_Self_Integrity::display_state(), $vg_self_enabled );
1238 + // Switched off has no setting to turn back on: it takes code on the
1239 + // site, and the recommendation says where to look for it.
1240 + if ( 'off' === $vg_self_tone ) {
1241 + $recommendations[] = array(
1242 + 'icon' => 'warning',
1243 + 'priority' => 'critical',
1244 + 'tab' => 'file-integrity',
1245 + 'message' => __( 'Something on this site switched Vigilant self-protection off: File Integrity says which file does it.', 'vigilante' ),
1246 + );
1247 + } elseif ( 'critical' === $vg_self_tone ) {
1248 + $recommendations[] = array(
1249 + 'icon' => 'warning',
1250 + 'priority' => 'critical',
1251 + 'tab' => 'file-integrity',
1252 + 'message' => $this->self_critical_recommendation( Vigilante_Self_Integrity::alarm_kind( Vigilante_Self_Integrity::display_state(), $vg_self_enabled ) ),
1253 + );
1254 + } elseif ( 'warning' === $vg_self_tone ) {
1255 + $recommendations[] = array(
1256 + 'icon' => 'shield',
1257 + 'priority' => 'high',
1258 + 'tab' => 'file-integrity',
1259 + 'message' => __( 'Vigilant self-protection needs your attention: File Integrity says what it found and what to do.', 'vigilante' ),
1260 + );
1261 + } elseif ( 'none' === $vg_self_tone ) {
1262 + // The score holds back the points of a check that has not run yet,
1263 + // so the card has to say why instead of just showing a lower number.
1264 + $recommendations[] = array(
1265 + 'icon' => 'shield',
1266 + 'priority' => 'high',
1267 + 'tab' => 'file-integrity',
1268 + 'message' => __( 'Vigilant has not checked its own files yet: run a scan from File Integrity.', 'vigilante' ),
1269 + );
1270 + }
1271 +
870 1272 // Critical: Firewall disabled
871 1273 if ( empty( $options['modules']['firewall'] ) ) {
872 1274 $recommendations[] = array(
873 1275 'icon' => 'warning',
@@ -950,14 +1352,14 @@
950 1352 );
951 1353 }
952 1354
953 1355 // Low: XML-RPC enabled (only if login security is active)
954 - if ( ! empty( $options['modules']['login_security'] ) && empty( $login['disable_xmlrpc'] ) ) {
1356 + if ( ! empty( $options['modules']['login_security'] ) && ! $this->is_xmlrpc_protected( $options ) ) {
955 1357 $recommendations[] = array(
956 1358 'icon' => 'info',
957 1359 'priority' => 'low',
958 1360 'message' => __( 'Disable XML-RPC if not needed (reduces attack surface).', 'vigilante' ),
959 - 'tab' => 'login',
1361 + 'tab' => 'wp-hardening',
960 1362 );
961 1363 }
962 1364
963 1365 // Low: Strong passwords not enforced (only if user security is active)
@@ -1270,8 +1672,9 @@
1270 1672 // File Integrity
1271 1673 array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'File Integrity Monitoring', 'vigilante' ), 'anchor' => 'vigilante-section-fi-monitoring', 'label' => __( 'File Integrity Monitoring', 'vigilante' ), 'label_en' => 'File Integrity Monitoring', 'keywords' => _x( 'file integrity monitoring files checksum checksums tamper', 'settings search keywords', 'vigilante' ) ),
1272 1674 array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'File Integrity Monitoring', 'vigilante' ), 'anchor' => 'vigilante-section-fi-monitoring', 'label' => __( 'Scan schedule', 'vigilante' ), 'label_en' => 'Scan schedule', 'keywords' => _x( 'scan schedule scans scanning check cron', 'settings search keywords', 'vigilante' ) ),
1273 1675 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' ) ),
1676 + array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'Vigilant self-protection', 'vigilante' ), 'anchor' => 'vigilante-section-fi-self', 'label' => __( 'Vigilant self-protection', 'vigilante' ), 'label_en' => 'Vigilant self-protection', 'keywords' => _x( 'self protection selfprotection self-check autoproteccion manifest sha256 checksums tampering tampered repair reinstall own files guardian integrity of the plugin', 'settings search keywords', 'vigilante' ) ),
1274 1677 array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'Ignored Files', 'vigilante' ), 'anchor' => 'vigilante-section-fi-ignored', 'label' => __( 'Ignored Files', 'vigilante' ), 'label_en' => 'Ignored Files', 'keywords' => _x( 'ignored files file exclude', 'settings search keywords', 'vigilante' ) ),
1275 1678 // Security Audit
1276 1679 array( 'tab' => 'activity-log', 'tab_label' => __( 'Security Audit', 'vigilante' ), 'section' => __( 'Security Audit Settings', 'vigilante' ), 'anchor' => 'vigilante-section-audit-settings', 'label' => __( 'Retention', 'vigilante' ), 'label_en' => 'Retention', 'keywords' => _x( 'retention keep days storage log', 'settings search keywords', 'vigilante' ) ),
1277 1680 array( 'tab' => 'activity-log', 'tab_label' => __( 'Security Audit', 'vigilante' ), 'section' => __( 'Security Audit Settings', 'vigilante' ), 'anchor' => 'vigilante-section-audit-settings', 'label' => __( 'Events to Log', 'vigilante' ), 'label_en' => 'Events to Log', 'keywords' => _x( 'events to log', 'settings search keywords', 'vigilante' ) ),
@@ -1424,13 +1827,17 @@
1424 1827 );
1425 1828
1426 1829 wp_localize_script( 'vigilante-admin', 'vigilanteAdmin', array(
1427 1830 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
1831 + 'selfBoxUrl' => esc_url( admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' ) ),
1428 1832 'nonce' => wp_create_nonce( 'vigilante_admin_nonce' ),
1429 1833 'currentUserId' => get_current_user_id(),
1430 1834 'logoutUrl' => wp_logout_url( wp_login_url() ),
1431 1835 'adminUrl' => admin_url( 'admin.php?page=vigilante' ),
1432 1836 'searchIndex' => $this->get_search_index(),
1837 + // The scan repaints this table from JavaScript, so the same gate
1838 + // has to travel with it or half the screen keeps the dead button.
1839 + 'approvalLocked' => $this->critical_approval_locked(),
1433 1840 'underAttack' => array(
1434 1841 'active' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->is_active(),
1435 1842 'remaining' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->get_remaining_time(),
1436 1843 ),
@@ -1444,9 +1851,8 @@
1444 1851 'scanComplete' => __( 'Scan complete', 'vigilante' ),
1445 1852 'loading' => __( 'Loading...', 'vigilante' ),
1446 1853 'searching' => __( 'Searching...', 'vigilante' ),
1447 1854 'noUsersFound' => __( 'No users found', 'vigilante' ),
1448 - 'searchError' => __( 'Error searching users', 'vigilante' ),
1449 1855 'sending' => __( 'Sending...', 'vigilante' ),
1450 1856 'sendNotification' => __( 'Send notification now', 'vigilante' ),
1451 1857 'notificationsSent' => __( 'notifications sent', 'vigilante' ),
1452 1858 'skipped' => __( 'skipped', 'vigilante' ),
@@ -1455,10 +1861,11 @@
1455 1861 // Header tester strings
1456 1862 'testHeaders' => __( 'Test Headers', 'vigilante' ),
1457 1863 'testing' => __( 'Testing...', 'vigilante' ),
1458 1864 'score' => __( 'Score', 'vigilante' ),
1459 - 'enabledHeaders' => __( 'Enabled headers', 'vigilante' ),
1460 - 'missingHeaders' => __( 'Missing headers', 'vigilante' ),
1865 + 'enabledHeaders' => __( 'Passed', 'vigilante' ),
1866 + 'missingHeaders' => __( 'Failing', 'vigilante' ),
1867 + 'headersMeasured' => __( 'Measured right now on the home page of the site, with the same checks as Security Check.', 'vigilante' ),
1461 1868 'warnings' => __( 'Warnings', 'vigilante' ),
1462 1869 // File integrity scan results strings
1463 1870 'scanResults' => __( 'Scan Results', 'vigilante' ),
1464 1871 'ok' => __( 'OK', 'vigilante' ),
@@ -1465,19 +1872,24 @@
1465 1872 'modified' => __( 'Modified', 'vigilante' ),
1466 1873 'suspicious' => __( 'Suspicious', 'vigilante' ),
1467 1874 'totalScanned' => __( 'Total Scanned', 'vigilante' ),
1468 1875 'suspiciousFiles' => __( 'Suspicious Files', 'vigilante' ),
1469 - 'suspiciousWarning' => __( 'These files may contain malicious code or are in unexpected locations. Review immediately!', 'vigilante' ),
1876 + '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' ),
1470 1877 'file' => __( 'File', 'vigilante' ),
1471 1878 'reason' => __( 'Reason', 'vigilante' ),
1472 1879 'type' => __( 'Type', 'vigilante' ),
1473 1880 'unknown' => __( 'Unknown', 'vigilante' ),
1881 + 'selfType' => __( 'Vigilant (self)', 'vigilante' ),
1882 + 'logWhatHappened' => __( 'What happened', 'vigilante' ),
1883 + 'logWhatItMeans' => __( 'What it means', 'vigilante' ),
1884 + 'logWhatToDo' => __( 'What to do', 'vigilante' ),
1885 + 'logSelfSeeDetails' => __( 'Open File Integrity for the full detail', 'vigilante' ),
1474 1886 'modifiedFiles' => __( 'Modified Files', 'vigilante' ),
1475 - 'modifiedDescription' => __( 'These files (apparently) differ from the original WordPress or plugin versions.', 'vigilante' ),
1887 + 'modifiedDescription' => __( 'These files do not match the copy WordPress.org publishes for the installed version.', 'vigilante' ),
1476 1888 'extraFiles' => __( 'Extra Files', 'vigilante' ),
1477 1889 'extra' => __( 'Extra', 'vigilante' ),
1478 1890 'ignored' => __( 'Ignored', 'vigilante' ),
1479 - 'extraDescription' => __( 'PHP files found in plugins or themes that are not part of the original distribution from WordPress.org.', 'vigilante' ),
1891 + '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' ),
1480 1892 'actions' => __( 'Actions', 'vigilante' ),
1481 1893 'ignore' => __( 'Ignore', 'vigilante' ),
1482 1894 'ignoring' => __( 'Ignoring...', 'vigilante' ),
1483 1895 'fileIgnored' => __( 'File added to ignored list.', 'vigilante' ),
@@ -1485,9 +1897,8 @@
1485 1897 'confirmClearIgnored' => __( 'Remove all files from the ignored list? They will appear in scan results again.', 'vigilante' ),
1486 1898 'ignoredCleared' => __( 'Ignored files list cleared. Page will reload...', 'vigilante' ),
1487 1899 'selectAll' => __( 'Select all', 'vigilante' ),
1488 1900 'bulkIgnoreSelected' => __( 'Ignore selected', 'vigilante' ),
1489 - 'bulkUnignoreSelected'=> __( 'Stop ignoring selected', 'vigilante' ),
1490 1901 'bulkNoSelection' => __( 'Select at least one file first.', 'vigilante' ),
1491 1902 'bulkConfirmIgnore' => __( 'Ignore the selected files? They will be hidden from future scan results until you remove them from the ignored list.', 'vigilante' ),
1492 1903 'bulkConfirmUnignore' => __( 'Remove the selected files from the ignored list? They will appear in scan results again.', 'vigilante' ),
1493 1904 'bulkProcessing' => __( 'Processing...', 'vigilante' ),
@@ -1492,18 +1903,22 @@
1492 1903 'bulkConfirmUnignore' => __( 'Remove the selected files from the ignored list? They will appear in scan results again.', 'vigilante' ),
1493 1904 'bulkProcessing' => __( 'Processing...', 'vigilante' ),
1494 1905 /* translators: %d: number of files selected for bulk action. */
1495 1906 'bulkSelectedCount' => __( '%d selected', 'vigilante' ),
1496 - 'allClear' => __( 'All files verified - no issues found!', 'vigilante' ),
1907 + 'allClear' => __( 'Good job! Nothing to report in the files that were checked.', 'vigilante' ),
1497 1908 'criticalConfigTitle' => __( 'Critical config files modified', 'vigilante' ),
1498 - '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' ),
1909 + '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' ),
1499 1910 'approve' => __( 'Approve', 'vigilante' ),
1500 1911 'approving' => __( 'Approving...', 'vigilante' ),
1912 + 'approvalLockedNotice' => $this->critical_approval_notice(),
1501 1913 'criticalApproved' => __( 'Change approved. Next scan will use the current state as baseline.', 'vigilante' ),
1502 1914 'reviewChanges' => __( 'Review changes', 'vigilante' ),
1503 1915 'hideChanges' => __( 'Hide changes', 'vigilante' ),
1504 1916 'changes' => __( 'Changes', 'vigilante' ),
1505 1917 'diffUnavailable' => __( 'Diff not available for this file (baseline was created before diff tracking was added). Approve to enable diff on future changes.', 'vigilante' ),
1918 + 'diffNetwork' => __( 'This file belongs to the whole network, so its line changes are only shown to network administrators, on the main site.', 'vigilante' ),
1919 + 'diffRescan' => __( 'Run a new scan to see the line changes of this file.', 'vigilante' ),
1920 + 'diffRedaction' => __( 'The line changes of this file are not shown because a value in it could not be hidden safely. The change itself is still detected.', 'vigilante' ),
1506 1921 'diffEmpty' => __( 'No line-level changes detected (may be whitespace or reordering).', 'vigilante' ),
1507 1922 'diffLines' => __( 'lines', 'vigilante' ),
1508 1923 // Under Attack mode strings
1509 1924 'underAttackConfirmActivate' => __( 'Activate Under Attack mode? All visitors will see a verification page for the next 4 hours.', 'vigilante' ),
@@ -1585,13 +2000,10 @@
1585 2000 'noLogsToExport' => __( 'No logs to export', 'vigilante' ),
1586 2001 'exportFailed' => __( 'Export failed', 'vigilante' ),
1587 2002 'exportLogs' => __( 'Export Logs', 'vigilante' ),
1588 2003 // Backup strings
1589 - 'backupCreated' => __( 'Backup created successfully.', 'vigilante' ),
1590 2004 'createBackupNow' => __( 'Download Backup', 'vigilante' ),
1591 2005 'downloadBackup' => __( 'Download Backup (.zip)', 'vigilante' ),
1592 - /* translators: %d: number of tables */
1593 - 'tablesCount' => __( '%d tables', 'vigilante' ),
1594 2006 /* translators: 1: table count, 2: human-readable size */
1595 2007 'dbTablesTotal' => __( '%1$d tables total (%2$s)', 'vigilante' ),
1596 2008 /* translators: 1: selected count, 2: human-readable size */
1597 2009 'dbTablesSelected' => __( '%1$d tables selected (%2$s)', 'vigilante' ),
@@ -1598,15 +2010,12 @@
1598 2010 // Settings search strings
1599 2011 'searchNoResults' => __( 'No matching settings found.', 'vigilante' ),
1600 2012 /* translators: %d: number of results that did not fit in the list. */
1601 2013 'searchMoreResults' => __( '%d more results. Refine the search to see them.', 'vigilante' ),
1602 - 'searchInTab' => __( 'in', 'vigilante' ),
1603 - // Modules string
1604 - /* translators: 1: enabled count, 2: total count */
1605 - 'modulesEnabled' => __( '%1$d / %2$d modules enabled', 'vigilante' ),
1606 2014 // Activity log label maps for JS rendering
1607 2015 'eventTypeLabels' => array(
1608 2016 'login' => __( 'Login', 'vigilante' ),
2017 + '2fa' => __( 'Two-factor', 'vigilante' ),
1609 2018 'user' => __( 'User', 'vigilante' ),
1610 2019 'content' => __( 'Content', 'vigilante' ),
1611 2020 'plugin' => __( 'Plugin', 'vigilante' ),
1612 2021 'theme' => __( 'Theme', 'vigilante' ),
@@ -1672,10 +2081,8 @@
1672 2081 /* translators: 1: first item number, 2: last item number, 3: total items */
1673 2082 'paginationOf' => __( '%1$d–%2$d of %3$d', 'vigilante' ),
1674 2083 'paginationEmpty' => __( '0 items', 'vigilante' ),
1675 2084 // Security Analyzer strings
1676 - 'analyzerScanNow' => __( 'Scan now', 'vigilante' ),
1677 - 'analyzerScanning' => __( 'Scanning…', 'vigilante' ),
1678 2085 'analyzerFastPhase' => __( 'Running fast checks…', 'vigilante' ),
1679 2086 'analyzerSlowPhase' => __( 'Running remote checks…', 'vigilante' ),
1680 2087 'analyzerScanComplete' => __( 'Security scan complete.', 'vigilante' ),
1681 2088 'analyzerScanFailed' => __( 'Security scan failed.', 'vigilante' ),
@@ -1681,11 +2088,8 @@
1681 2088 'analyzerScanFailed' => __( 'Security scan failed.', 'vigilante' ),
1682 2089 'analyzerShowDetails' => __( 'Show detailed breakdown', 'vigilante' ),
1683 2090 'analyzerHideDetails' => __( 'Hide detailed breakdown', 'vigilante' ),
1684 2091 'analyzerGoToSetting' => __( 'Go to setting', 'vigilante' ),
1685 - 'analyzerNoData' => __( 'No data yet — run a scan to populate this category.', 'vigilante' ),
1686 - 'analyzerJustNow' => __( 'just now', 'vigilante' ),
1687 - 'analyzerAgo' => __( 'ago', 'vigilante' ),
1688 2092 'analyzerSettingsSaved' => __( 'Analyzer settings saved.', 'vigilante' ),
1689 2093 'analyzerLastScanJustNow' => __( 'Last scan just now', 'vigilante' ),
1690 2094 'analyzerQualityExcellent' => __( 'Excellent', 'vigilante' ),
1691 2095 'analyzerQualityGood' => __( 'Good', 'vigilante' ),
@@ -1691,9 +2095,9 @@
1691 2095 'analyzerQualityGood' => __( 'Good', 'vigilante' ),
1692 2096 'analyzerQualityFair' => __( 'Fair', 'vigilante' ),
1693 2097 'analyzerQualityPoor' => __( 'Poor', 'vigilante' ),
1694 2098 'analyzerQualityCritical' => __( 'Critical', 'vigilante' ),
1695 - 'analyzerPts' => __( 'pts', 'vigilante' ),
2099 + 'analyzerNotMeasured' => __( 'Not measured', 'vigilante' ),
1696 2100 'analyzerLearnMore' => __( 'Learn more', 'vigilante' ),
1697 2101 'analyzerInfoAllClear' => __( 'All clear', 'vigilante' ),
1698 2102 /* translators: %d: number of findings in an info-only category */
1699 2103 'analyzerInfoFindings' => __( '%d findings', 'vigilante' ),
@@ -1719,9 +2123,97 @@
1719 2123
1720 2124 /**
1721 2125 * Show admin notices
1722 2126 */
2127 + /**
2128 + * Self-protection notice.
2129 + *
2130 + * A change to Vigilant own files is shown on every admin screen, because
2131 + * waiting for someone to open the plugin is exactly what an attacker who
2132 + * patched it would want. A warning is shown only on Vigilant screens, so
2133 + * the notice that matters is not diluted. Neither is dismissible, both are
2134 + * for administrators only, and on a network the steps depend on whether
2135 + * the person can install plugins at all.
2136 + */
2137 + public function maybe_show_self_protection_notice() {
2138 + if ( ! current_user_can( 'manage_options' ) ) {
2139 + return;
2140 + }
2141 + $summary = $this->self_integrity_summary();
2142 + $tone = $summary['tone'];
2143 + if ( ! in_array( $tone, array( 'critical', 'off', 'warning' ), true ) ) {
2144 + return;
2145 + }
2146 +
2147 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the screen slug to decide where a notice is shown; no action taken.
2148 + $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
2149 + $on_vigilante = ( 0 === strpos( $page, 'vigilante' ) );
2150 + if ( 'warning' === $tone && ! $on_vigilante ) {
2151 + return;
2152 + }
2153 + $alarma = in_array( $tone, array( 'critical', 'off' ), true );
2154 +
2155 + $link = admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' );
2156 + $network = is_multisite() && ! current_user_can( 'update_plugins' );
2157 + ?>
2158 + <div class="notice notice-<?php echo $alarma ? 'error' : 'warning'; ?> vigilante-self-notice">
2159 + <p class="vigilante-self-notice-title">
2160 + <span class="dashicons dashicons-shield" aria-hidden="true"></span>
2161 + <strong>
2162 + <?php
2163 + if ( 'critical' === $tone && in_array( $summary['kind'], array( 'tasks', 'hooks' ), true ) ) {
2164 + echo esc_html( $this->self_integrity_headline( $tone, $summary['state'] ) );
2165 + } elseif ( 'critical' === $tone ) {
2166 + esc_html_e( 'Vigilant detected changes in its own files', 'vigilante' );
2167 + } elseif ( 'off' === $tone ) {
2168 + esc_html_e( 'Vigilant self-protection is switched off by code', 'vigilante' );
2169 + } else {
2170 + echo esc_html( $this->self_integrity_headline( $tone, $summary['state'] ) );
2171 + }
2172 + ?>
2173 + </strong>
2174 + </p>
2175 + <p class="vigilante-self-notice-text">
2176 + <span>
2177 + <?php
2178 + if ( 'critical' === $tone && ! empty( $summary['tasks'] ) ) {
2179 + esc_html_e( 'Vigilant scheduled them again each time, but something keeps removing them. File Integrity says what to look for.', 'vigilante' );
2180 + } elseif ( 'critical' === $tone && 'hooks' === $summary['kind'] ) {
2181 + esc_html_e( 'Code on this site removed hooks that run the self-check of Vigilant. File Integrity says which ones and what to look for.', 'vigilante' );
2182 + } elseif ( 'critical' === $tone ) {
2183 + esc_html_e( 'Your security plugin may have been tampered with, and while that is true nothing it reports can be trusted.', 'vigilante' );
2184 + } elseif ( 'off' === $tone ) {
2185 + esc_html_e( 'Nothing is checking that Vigilant own files are intact. File Integrity says which file switches it off.', 'vigilante' );
2186 + } else {
2187 + esc_html_e( 'File Integrity says what was found, what it means and what to do about it.', 'vigilante' );
2188 + }
2189 + // Repairing is for a case about the files: tasks, removed
2190 + // hooks and a filter are not repaired with a clean copy.
2191 + if ( $network && ! empty( $summary['about_files'] ) ) {
2192 + 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' );
2193 + }
2194 + ?>
2195 + </span>
2196 + <?php
2197 + /*
2198 + * One button, on the same line as the text it belongs to, and it
2199 + * leads to the whole story. Repairing from a notice, without
2200 + * seeing which files, what it means and what it will do, is
2201 + * asking someone to fix what they have not read: the repair
2202 + * button lives in the card, after all that.
2203 + */
2204 + ?>
2205 + <a class="button button-primary button-small" href="<?php echo esc_url( $link ); ?>">
2206 + <?php esc_html_e( 'See what to do', 'vigilante' ); ?>
2207 + </a>
2208 + </p>
2209 + </div>
2210 + <?php
2211 + }
2212 +
1723 2213 public function show_admin_notices() {
2214 + $this->maybe_show_self_protection_notice();
2215 +
1724 2216 // Activation notice
1725 2217 if ( get_transient( 'vigilante_activated' ) ) {
1726 2218 ?>
1727 2219 <div class="notice notice-success is-dismissible vigilante-activation-notice">
@@ -1766,8 +2258,21 @@
1766 2258 </p>
1767 2259 <p>
1768 2260 <em><?php esc_html_e( 'Vigilant has applied the Maximum preset plus extra hardening on top of your previous configuration. Any changes you make to Vigilant settings while this mode is active will be reverted when it ends.', 'vigilante' ); ?></em>
1769 2261 </p>
2262 + <?php
2263 + // The cache-bypass rules could not be written (a host where
2264 + // WordPress cannot write files by itself, a held lock, a
2265 + // failed read-back): show them, so they can be added by hand.
2266 + $ua_instance = new Vigilante_Under_Attack( $this->settings, $this->activity_log );
2267 + if ( $ua_instance->cache_rules_missing() ) :
2268 + ?>
2269 + <p>
2270 + <strong><?php esc_html_e( 'The cache-bypass rules could not be written to your .htaccess.', 'vigilante' ); ?></strong>
2271 + <?php esc_html_e( 'Without them a page cache may keep serving stored pages during the attack. Add this block at the top of the .htaccess in your site root (the activity log records why it was not written):', 'vigilante' ); ?>
2272 + </p>
2273 + <textarea readonly rows="9" class="large-text code" onclick="this.select();"><?php echo esc_textarea( Vigilante_Under_Attack::get_cache_bypass_block() ); ?></textarea>
2274 + <?php endif; ?>
1770 2275 </div>
1771 2276 <?php
1772 2277 }
1773 2278 }
@@ -1999,8 +2504,41 @@
1999 2504 return ! Vigilante_Settings::can_write_shared_files();
2000 2505 }
2001 2506
2002 2507 /**
2508 + * Whether this is the main site and the user cannot change what it builds the shared files from
2509 + *
2510 + * See Vigilante_Settings::get_main_site_file_settings(). On a subsite those
2511 + * settings only act on that site, so they are never locked there.
2512 + *
2513 + * @since 2.11.6
2514 + *
2515 + * @return bool
2516 + */
2517 + private function main_site_files_locked() {
2518 + return $this->shared_files_locked() && Vigilante_Settings::owns_shared_files();
2519 + }
2520 +
2521 + /**
2522 + * Sentence added to a bulk change when some settings were left as they were
2523 + *
2524 + * Importing a file, applying a preset and restoring the defaults touch every
2525 + * section at once, so the user is told that the shared file settings did
2526 + * not move.
2527 + *
2528 + * @since 2.11.6
2529 + *
2530 + * @return string Empty when the user can change every setting.
2531 + */
2532 + private function locked_file_settings_message() {
2533 + if ( ! Vigilante_Settings::get_locked_file_settings() ) {
2534 + return '';
2535 + }
2536 +
2537 + return ' ' . __( 'The settings that end up in wp-config.php or .htaccess were left as they were.', 'vigilante' ) . ' ' . Vigilante_Settings::get_shared_files_notice();
2538 + }
2539 +
2540 + /**
2003 2541 * Print the shared-files notice for a section that cannot be edited here
2004 2542 *
2005 2543 * @since 2.9.8
2006 2544 */
@@ -2026,10 +2564,46 @@
2026 2564 *
2027 2565 * @since 2.10.4
2028 2566 * @return bool
2029 2567 */
2568 + private function forwarded_chain_readings() {
2569 + // Shown, not decided on: the firewall resolves the address elsewhere.
2570 + $chain = Vigilante_IP_Utils::trusted_forwarded_for();
2571 +
2572 + if ( '' === $chain ) {
2573 + return array();
2574 + }
2575 +
2576 + $public = array();
2577 +
2578 + foreach ( explode( ',', $chain ) as $entry ) {
2579 + $address = Vigilante_IP_Utils::unmap_ipv4( trim( $entry ) );
2580 +
2581 + if ( filter_var( $address, FILTER_VALIDATE_IP ) && ! Vigilante_IP_Utils::is_own_network( $address ) ) {
2582 + $public[] = $address;
2583 + }
2584 + }
2585 +
2586 + if ( count( $public ) < 2 ) {
2587 + return array();
2588 + }
2589 +
2590 + return array(
2591 + 'now' => Vigilante_IP_Utils::client_from_chain( $chain ),
2592 + 'before' => $public[0],
2593 + );
2594 + }
2595 +
2596 + /**
2597 + * Whether the user tools of this screen are out of reach for this user
2598 + *
2599 + * @return bool
2600 + */
2030 2601 private function user_actions_locked() {
2031 - return is_multisite() && ! current_user_can( 'manage_network_users' );
2602 + // On a single site edit_user maps to edit_users, which a custom role with
2603 + // manage_options may lack: since 2.11.8 approving and rejecting a pending
2604 + // registration ask for it, so the buttons have to say so there too.
2605 + return is_multisite() ? ! current_user_can( 'manage_network_users' ) : ! current_user_can( 'edit_users' );
2032 2606 }
2033 2607
2034 2608 /**
2035 2609 * Print the notice for user tools that cannot be used from this site
@@ -2041,14 +2615,47 @@
2041 2615 return;
2042 2616 }
2043 2617 ?>
2044 2618 <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;">
2619 + <?php if ( is_multisite() ) : ?>
2045 2620 <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>
2621 + <?php else : ?>
2622 + <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>
2623 + <?php endif; ?>
2046 2624 </div>
2047 2625 <?php
2048 2626 }
2049 2627
2050 2628 /**
2629 + * Approving a change to the shared config files needs the network
2630 + *
2631 + * Since 2.11.3 the handler behind the Approve button asks for
2632 + * manage_network_options, because the two files it approves, wp-config.php
2633 + * and the root .htaccess, belong to the installation, and so does the
2634 + * record of them. The button, though, went on being painted for everybody,
2635 + * so the administrator of a subsite saw the warning, saw the button,
2636 + * pressed it and got "Permission denied" with no explanation. That is
2637 + * exactly what user_actions_locked() above exists to avoid, one release
2638 + * later and one screen over. Flagged by @calzbert.
2639 + *
2640 + * @since 2.11.4
2641 + * @return bool
2642 + */
2643 + private function critical_approval_locked() {
2644 + return is_multisite() && ! current_user_can( 'manage_network_options' );
2645 + }
2646 +
2647 + /**
2648 + * The line that replaces the Approve button where it cannot be used
2649 + *
2650 + * @since 2.11.4
2651 + * @return string
2652 + */
2653 + private function critical_approval_notice() {
2654 + 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' );
2655 + }
2656 +
2657 + /**
2051 2658 * Check if module is disabled and render warning
2052 2659 *
2053 2660 * @param string $module_key Module key.
2054 2661 * @return bool True if disabled.
@@ -2084,10 +2691,10 @@
2084 2691
2085 2692 /**
2086 2693 * Render the Security Analyzer (Security Check) widget + expandable full report.
2087 2694 *
2088 - * Lives in the Dashboard tab between the Configuration Score status card and
2089 - * the modules grid. Uses the last persisted scan to hydrate server-side so the
2695 + * Lives in the Dashboard tab above the configuration card and the modules
2696 + * grid: it carries the only letter of the page. Uses the last persisted scan to hydrate server-side so the
2090 2697 * first paint shows real data; "Scan now" runs the 2-phase AJAX to refresh.
2091 2698 *
2092 2699 * @param array $last_scan Result of Vigilante_Security_Analyzer::get_last_scan().
2093 2700 * @param array $history Score history (oldest first).
@@ -2109,9 +2716,43 @@
2109 2716 $categories = isset( $last_scan['categories'] ) && is_array( $last_scan['categories'] ) ? $last_scan['categories'] : array();
2110 2717 $weekly_enabled = ! isset( $analyzer_settings['weekly_scan_enabled'] ) || ! empty( $analyzer_settings['weekly_scan_enabled'] );
2111 2718 $email_enabled = ! empty( $analyzer_settings['email_on_regression'] );
2112 2719
2113 - $quality = self::analyzer_quality_tag( $score );
2720 + /*
2721 + * Self-protection caps the score, and it does so here and not only in
2722 + * the stored report: the report is reused until the next Security
2723 + * Check, so tampering found after it ran would otherwise be shown next
2724 + * to the score the site earned before it happened.
2725 + */
2726 + $self_summary = $this->self_integrity_summary();
2727 + $self_capped = in_array( $self_summary['tone'], array( 'critical', 'off' ), true )
2728 + || ( 'self_integrity' === ( isset( $last_scan['capped_by'] ) ? $last_scan['capped_by'] : '' ) );
2729 + // Why the note under the title says the score is capped: something
2730 + // that is true now, about tasks or about files, or only what the
2731 + // stored report found when it ran. In that last case the note does not
2732 + // say that anything is wrong now: a report stored before the notice
2733 + // about tasks closed, or before a repair, kept saying in the present
2734 + // that the files had been changed until the next scan.
2735 + $self_capped_now = in_array( $self_summary['tone'], array( 'critical', 'off' ), true );
2736 + $self_capped_why = 'stored';
2737 + if ( $self_capped_now ) {
2738 + $self_capped_why = 'files';
2739 + if ( 'tasks' === $self_summary['kind'] ) {
2740 + $self_capped_why = 'tasks';
2741 + } elseif ( in_array( $self_summary['kind'], array( 'off', 'hooks' ), true ) ) {
2742 + // The check itself is switched off or unhooked: no file was found changed.
2743 + $self_capped_why = 'check';
2744 + }
2745 + }
2746 + if ( $self_capped && $has_data && $score > Vigilante_Security_Analyzer::SCORE_CAP_ON_TAMPER ) {
2747 + $score = Vigilante_Security_Analyzer::SCORE_CAP_ON_TAMPER;
2748 + }
2749 + if ( $has_data && '' !== $grade ) {
2750 + $grade = Vigilante_SA_Helpers::grade_for_score( $score );
2751 + }
2752 +
2753 + $quality = self::analyzer_quality_tag( $score );
2754 + $not_measured = isset( $counts['skip'] ) ? (int) $counts['skip'] : 0;
2114 2755 ?>
2115 2756 <div class="vigilante-analyzer" id="vigilante-analyzer"
2116 2757 data-has-data="<?php echo $has_data ? '1' : '0'; ?>">
2117 2758 <div class="vigilante-analyzer-header">
@@ -2120,9 +2761,9 @@
2120 2761 <span class="dashicons dashicons-shield-alt" aria-hidden="true"></span>
2121 2762 <?php esc_html_e( 'Security Check', 'vigilante' ); ?>
2122 2763 </h2>
2123 2764 <p class="description">
2124 - <?php esc_html_e( 'On-demand audit of what an attacker would see right now, plus 13 internal checks impossible from the outside.', 'vigilante' ); ?>
2765 + <?php esc_html_e( 'On-demand audit of what an attacker would see right now, plus internal checks impossible from the outside.', 'vigilante' ); ?>
2125 2766 </p>
2126 2767 <p class="vigilante-analyzer-last-scan" data-role="ran-at">
2127 2768 <span class="dashicons dashicons-clock" aria-hidden="true"></span>
2128 2769 <?php echo esc_html( $ran_at_human ); ?>
@@ -2135,8 +2776,29 @@
2135 2776 </button>
2136 2777 </div>
2137 2778 </div>
2138 2779
2780 + <?php if ( $self_capped ) : ?>
2781 + <p class="vigilante-analyzer-capped" data-why="<?php echo esc_attr( $self_capped_why ); ?>">
2782 + <span class="dashicons dashicons-shield" aria-hidden="true"></span>
2783 + <strong><?php 'stored' === $self_capped_why ? esc_html_e( 'This score is from an earlier scan.', 'vigilante' ) : esc_html_e( 'This score is not reliable right now.', 'vigilante' ); ?></strong>
2784 + <?php
2785 + if ( 'stored' === $self_capped_why ) {
2786 + esc_html_e( 'When this scan ran, Vigilant self-protection was reporting a critical finding, so the score was held at the bottom of the scale. Run a new scan to bring it up to date.', 'vigilante' );
2787 + } elseif ( 'tasks' === $self_capped_why ) {
2788 + esc_html_e( 'Something keeps removing Vigilant scheduled tasks, so its scans and its daily check do not run when they should. The score is held at the bottom of the scale while that goes on.', 'vigilante' );
2789 + } elseif ( 'check' === $self_capped_why ) {
2790 + esc_html_e( 'Code on this site is keeping Vigilant self-protection from running as it should, so the code that produces every other result on this page is not being checked as it should. The score is held at the bottom of the scale while that goes on.', 'vigilante' );
2791 + } else {
2792 + 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' );
2793 + }
2794 + ?>
2795 + <a href="<?php echo esc_url( admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' ) ); ?>">
2796 + <?php esc_html_e( 'See what to do', 'vigilante' ); ?>
2797 + </a>
2798 + </p>
2799 + <?php endif; ?>
2800 +
2139 2801 <div class="vigilante-analyzer-summary">
2140 2802 <div class="vigilante-analyzer-score-card">
2141 2803 <?php if ( $has_data && $grade ) : ?>
2142 2804 <div class="vigilante-score-circle vigilante-grade-<?php echo esc_attr( strtolower( $grade ) ); ?>">
@@ -2175,8 +2837,20 @@
2175 2837 <span class="dashicons dashicons-dismiss" aria-hidden="true"></span>
2176 2838 <strong data-role="fail"><?php echo esc_html( isset( $counts['fail'] ) ? $counts['fail'] : 0 ); ?></strong>
2177 2839 <span class="vigilante-analyzer-count-label"><?php esc_html_e( 'Failing', 'vigilante' ); ?></span>
2178 2840 </span>
2841 + <?php
2842 + // What could not be measured is left out of the score, so
2843 + // it is said here: a score over fewer checks is not the
2844 + // same score.
2845 + ?>
2846 + <span class="vigilante-analyzer-count vigilante-analyzer-count--skip" data-role="skip-wrap"
2847 + title="<?php esc_attr_e( 'Checks that could not be run this time. They are not part of the score.', 'vigilante' ); ?>"
2848 + <?php echo $not_measured > 0 ? '' : 'hidden'; ?>>
2849 + <span class="dashicons dashicons-minus" aria-hidden="true"></span>
2850 + <strong data-role="skip"><?php echo esc_html( $not_measured ); ?></strong>
2851 + <span class="vigilante-analyzer-count-label"><?php esc_html_e( 'Not measured', 'vigilante' ); ?></span>
2852 + </span>
2179 2853 </div>
2180 2854
2181 2855 <?php
2182 2856 // Sparkline of recent scores. Require at least 3 data points so the trend
@@ -2254,18 +2928,29 @@
2254 2928 <div class="vigilante-analyzer-categories" data-role="categories">
2255 2929 <?php foreach ( $categories_def as $slug => $meta ) :
2256 2930 $cat = isset( $categories[ $slug ] ) ? $categories[ $slug ] : array();
2257 2931 $earned = isset( $cat['earned'] ) ? (int) $cat['earned'] : 0;
2258 - // Always use the declared meta as the source of truth for the maximum.
2259 - // The cached scan may carry an old max if a check was added/removed
2260 - // between releases (see 2.6.1: closed_plugins raised internal from 22 to 28
2261 - // but cached scans still reported max=22 until reset).
2262 - $cat_max = (int) $meta['max'];
2932 + $checks = isset( $cat['checks'] ) ? (array) $cat['checks'] : array();
2263 2933 $info_only = ! empty( $meta['info_only'] ) || 0 === (int) $meta['max'];
2934 + // The maximum of a category is what its checks could
2935 + // score this time: one that could not be measured, or
2936 + // that is only informative, is not in it. The report
2937 + // adds it up from the checks themselves
2938 + // (Vigilante_Security_Analyzer::summarize_categories()),
2939 + // so it cannot drift from them. Before the first scan
2940 + // there are no checks and the declared figure is shown.
2941 + $cat_max = ( ! empty( $checks ) && isset( $cat['max'] ) ) ? (int) $cat['max'] : (int) $meta['max'];
2264 2942 $cat_pct = $cat_max > 0 ? (int) round( ( $earned / $cat_max ) * 100 ) : 0;
2265 - $checks = isset( $cat['checks'] ) ? (array) $cat['checks'] : array();
2266 2943 $cat_counts = isset( $cat['counts'] ) && is_array( $cat['counts'] ) ? $cat['counts'] : array( 'pass' => 0, 'warn' => 0, 'fail' => 0, 'info' => 0 );
2267 2944 $cat_quality = self::analyzer_quality_tag( $cat_pct );
2945 + // Nothing in the category could be scored: that is not
2946 + // a zero, and it does not get the red label of one.
2947 + if ( ! $info_only && ! empty( $checks ) && 0 === $cat_max ) {
2948 + $cat_quality = array(
2949 + 'label' => __( 'Not measured', 'vigilante' ),
2950 + 'slug' => 'info',
2951 + );
2952 + }
2268 2953 $info_count = isset( $cat_counts['info'] ) ? (int) $cat_counts['info'] : 0;
2269 2954 ?>
2270 2955 <details class="vigilante-analyzer-category<?php echo $info_only ? ' vigilante-analyzer-category--info' : ''; ?>"
2271 2956 data-category="<?php echo esc_attr( $slug ); ?>"
@@ -2314,9 +2999,9 @@
2314 2999 </span>
2315 3000 <?php endif; ?>
2316 3001 <?php if ( ! $info_only ) : ?>
2317 3002 <span class="vigilante-analyzer-category-score">
2318 - <span data-role="earned"><?php echo esc_html( $earned ); ?></span><span class="vigilante-analyzer-category-score-sep">/</span><?php echo esc_html( $cat_max ); ?>
3003 + <span data-role="earned"><?php echo esc_html( $earned ); ?></span><span class="vigilante-analyzer-category-score-sep">/</span><span data-role="max"><?php echo esc_html( $cat_max ); ?></span>
2319 3004 <span class="vigilante-analyzer-category-score-unit"><?php esc_html_e( 'pts', 'vigilante' ); ?></span>
2320 3005 </span>
2321 3006 <span class="vigilante-analyzer-category-bar" aria-hidden="true">
2322 3007 <span class="vigilante-analyzer-category-bar-fill vigilante-analyzer-category-bar-fill--<?php echo esc_attr( $cat_quality['slug'] ); ?>"
@@ -2393,23 +3078,27 @@
2393 3078 * @param int $pct 0..100.
2394 3079 * @return array{label:string,slug:string}
2395 3080 */
2396 3081 public static function analyzer_quality_tag( $pct ) {
2397 - $pct = max( 0, min( 100, (int) $pct ) );
2398 - // "Excellent" is reserved for a perfect score — a single missing point drops to Good.
2399 - if ( 100 === $pct ) {
2400 - return array( 'label' => __( 'Excellent', 'vigilante' ), 'slug' => 'a' );
3082 + if ( ! class_exists( 'Vigilante_SA_Helpers' ) ) {
3083 + require_once VIGILANTE_INCLUDES_DIR . 'security-analyzer/class-sa-helpers.php';
2401 3084 }
2402 - if ( $pct >= 70 ) {
2403 - return array( 'label' => __( 'Good', 'vigilante' ), 'slug' => 'b' );
2404 - }
2405 - if ( $pct >= 50 ) {
2406 - return array( 'label' => __( 'Fair', 'vigilante' ), 'slug' => 'c' );
2407 - }
2408 - if ( $pct >= 30 ) {
2409 - return array( 'label' => __( 'Poor', 'vigilante' ), 'slug' => 'd' );
2410 - }
2411 - return array( 'label' => __( 'Critical', 'vigilante' ), 'slug' => 'e' );
3085 +
3086 + // The word follows the letter, and the letter has one scale:
3087 + // Vigilante_SA_Helpers::grade_for_score().
3088 + $labels = array(
3089 + 'A' => __( 'Excellent', 'vigilante' ),
3090 + 'B' => __( 'Good', 'vigilante' ),
3091 + 'C' => __( 'Fair', 'vigilante' ),
3092 + 'D' => __( 'Poor', 'vigilante' ),
3093 + 'E' => __( 'Critical', 'vigilante' ),
3094 + );
3095 + $grade = Vigilante_SA_Helpers::grade_for_score( $pct );
3096 +
3097 + return array(
3098 + 'label' => $labels[ $grade ],
3099 + 'slug' => strtolower( $grade ),
3100 + );
2412 3101 }
2413 3102
2414 3103 /**
2415 3104 * Render a single analyzer check row (used both server-side and via JS template).
@@ -2510,53 +3199,43 @@
2510 3199
2511 3200 /**
2512 3201 * Render dashboard tab
2513 3202 */
2514 - private function render_tab_dashboard() {
2515 - $options = $this->settings->get_all_options();
2516 - $module_labels = $this->settings->get_module_labels();
2517 - $module_descriptions = $this->settings->get_module_descriptions();
2518 - $presets = $this->settings->get_presets();
2519 - $active_preset = get_option( 'vigilante_active_preset', '' );
2520 -
2521 - // Calculate security score with more factors
2522 - $security_score = $this->calculate_security_score( $options );
2523 -
2524 - // Security Analyzer (v2.1.0) — hydrate widget with the last persisted scan, if any.
2525 - if ( ! class_exists( 'Vigilante_Security_Analyzer' ) ) {
2526 - require_once VIGILANTE_INCLUDES_DIR . 'class-security-analyzer.php';
2527 - }
2528 - $analyzer_instance = new Vigilante_Security_Analyzer( $this->settings, $this->activity_log );
2529 - $analyzer_last_scan = $analyzer_instance->get_last_scan();
2530 - $analyzer_history = $analyzer_instance->get_score_history();
2531 - $analyzer_categories_def = Vigilante_Security_Analyzer::get_categories();
2532 - $analyzer_settings = isset( $options['security_analyzer'] ) ? $options['security_analyzer'] : array();
3203 + /**
3204 + * The Vigilant configuration card of the Dashboard: how much is switched on
3205 + *
3206 + * A bar with a percentage and, on purpose, neither a circle nor a letter.
3207 + * Until 3.0.4 this was the Configuration Score, drawn exactly like the
3208 + * score of Security Check next to it, and two letters side by side read
3209 + * as two grades of the same site that did not agree. The grade is the one
3210 + * of Security Check, which measures the site; this only counts settings,
3211 + * so it sits with the recommendations and the module switches it counts.
3212 + * The figure is still the one of calculate_security_score().
3213 + *
3214 + * @since 3.0.5
3215 + *
3216 + * @param int $security_score What calculate_security_score() gives, 0 to 100.
3217 + * @param array $options The settings of the plugin.
3218 + * @param string|bool $active_preset Slug of the preset in force, if any.
3219 + * @param array $presets The presets, by slug.
3220 + */
3221 + private function render_configuration_card( $security_score, $options, $active_preset, $presets ) {
3222 + $modules = ( isset( $options['modules'] ) && is_array( $options['modules'] ) ) ? $options['modules'] : array();
3223 + $modules_on = count( array_filter( $modules ) );
3224 + $level = max( 0, min( 100, (int) $security_score ) );
2533 3225 ?>
2534 - <div class="vigilante-dashboard">
2535 - <div class="vigilante-status-card">
2536 - <h2><?php esc_html_e( 'Configuration Score', 'vigilante' ); ?></h2>
3226 + <div class="vigilante-status-card vigilante-configuration-card">
3227 + <h2><?php esc_html_e( 'Vigilant configuration', 'vigilante' ); ?></h2>
2537 3228 <p class="vigilante-score-kind description">
2538 - <?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' ); ?>
3229 + <?php esc_html_e( 'How much of what Vigilant offers is switched on here.', 'vigilante' ); ?>
2539 3230 </p>
2540 - <div class="vigilante-security-score">
2541 - <?php
2542 - // Grade thresholds: A (90+), B (70-89), C (50-69), D (30-49), E (0-29)
2543 - if ( $security_score >= 90 ) {
2544 - $grade = 'A';
2545 - } elseif ( $security_score >= 70 ) {
2546 - $grade = 'B';
2547 - } elseif ( $security_score >= 50 ) {
2548 - $grade = 'C';
2549 - } elseif ( $security_score >= 30 ) {
2550 - $grade = 'D';
2551 - } else {
2552 - $grade = 'E';
2553 - }
2554 - ?>
2555 - <div class="vigilante-score-circle vigilante-grade-<?php echo esc_attr( strtolower( $grade ) ); ?>">
2556 - <span class="vigilante-grade"><?php echo esc_html( $grade ); ?></span>
2557 - <span class="vigilante-score-text"><?php echo esc_html( $security_score ); ?>%</span>
3231 + <div class="vigilante-configuration-level">
3232 + <div class="vigilante-configuration-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100"
3233 + aria-valuenow="<?php echo esc_attr( $level ); ?>"
3234 + aria-label="<?php esc_attr_e( 'Vigilant configuration', 'vigilante' ); ?>">
3235 + <span class="vigilante-configuration-bar-fill" style="width: <?php echo esc_attr( $level ); ?>%;"></span>
2558 3236 </div>
3237 + <strong class="vigilante-configuration-percent"><?php echo esc_html( $level ); ?>%</strong>
2559 3238 <div class="vigilante-config-status">
2560 3239 <?php if ( $active_preset && isset( $presets[ $active_preset ] ) ) : ?>
2561 3240 <span class="vigilante-preset-badge vigilante-preset-<?php echo esc_attr( $active_preset ); ?>">
2562 3241 <?php echo esc_html( $presets[ $active_preset ]['name'] ); ?>
@@ -2567,9 +3246,21 @@
2567 3246 </span>
2568 3247 <?php endif; ?>
2569 3248 </div>
2570 3249 </div>
2571 -
3250 + <p class="vigilante-configuration-modules description">
3251 + <?php
3252 + echo esc_html(
3253 + sprintf(
3254 + /* translators: 1: number of modules switched on, 2: number of modules */
3255 + __( '%1$d of %2$d modules switched on.', 'vigilante' ),
3256 + $modules_on,
3257 + count( $modules )
3258 + )
3259 + );
3260 + ?>
3261 + </p>
3262 +
2572 3263 <?php
2573 3264 $recommendations = $this->get_security_recommendations( $options );
2574 3265 if ( ! empty( $recommendations ) ) :
2575 3266 ?>
@@ -2588,18 +3279,95 @@
2588 3279 </ul>
2589 3280 </div>
2590 3281 <?php endif; ?>
2591 3282 </div>
3283 + <?php
3284 + }
2592 3285
3286 + /**
3287 + * One line strip at the top of the Dashboard tab: the state of Vigilant own
3288 + * files, above the Security Check and the configuration card, because
3289 + * nothing else on this screen means much while it is red.
3290 + */
3291 + private function render_self_protection_strip() {
3292 + $summary = $this->self_integrity_summary();
3293 + $tone = $summary['tone'];
3294 + $state = $summary['state'];
3295 + $count = count( $summary['findings'] );
3296 + $link = admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' );
3297 + ?>
3298 + <div class="vigilante-self-strip vigilante-self-strip--<?php echo esc_attr( $tone ); ?>">
3299 + <span class="dashicons dashicons-shield" aria-hidden="true"></span>
3300 + <strong><?php esc_html_e( 'Vigilant self-protection', 'vigilante' ); ?></strong>
3301 + <span class="vigilante-self-strip-state"><?php echo esc_html( $this->self_integrity_headline( $tone, $state ) ); ?></span>
3302 + <?php if ( $count > 0 ) : ?>
3303 + <span class="vigilante-self-strip-count">
3304 + <?php
3305 + printf(
3306 + /* translators: %d: number of findings about Vigilant own files */
3307 + esc_html( _n( '%d finding', '%d findings', $count, 'vigilante' ) ),
3308 + (int) $count
3309 + );
3310 + ?>
3311 + </span>
3312 + <?php elseif ( ! empty( $state['last_check'] ) && 'off' !== $tone ) : ?>
3313 + <span class="vigilante-self-strip-count">
3314 + <?php
3315 + printf(
3316 + /* translators: %s: human time difference, like "2 hours" */
3317 + esc_html__( 'checked %s ago', 'vigilante' ),
3318 + esc_html( human_time_diff( (int) $state['last_check'], time() ) )
3319 + );
3320 + ?>
3321 + </span>
3322 + <?php endif; ?>
3323 + <a href="<?php echo esc_url( $link ); ?>">
3324 + <?php
3325 + if ( in_array( $tone, array( 'critical', 'off', 'warning' ), true ) ) {
3326 + esc_html_e( 'See what to do', 'vigilante' );
3327 + } else {
3328 + esc_html_e( 'See details', 'vigilante' );
3329 + }
3330 + ?>
3331 + </a>
3332 + </div>
3333 + <?php
3334 + }
3335 +
3336 + private function render_tab_dashboard() {
3337 + $options = $this->settings->get_all_options();
3338 + $module_labels = $this->settings->get_module_labels();
3339 + $module_descriptions = $this->settings->get_module_descriptions();
3340 + $presets = $this->settings->get_presets();
3341 + $active_preset = get_option( 'vigilante_active_preset', '' );
3342 +
3343 + // Calculate security score with more factors
3344 + $security_score = $this->calculate_security_score( $options );
3345 +
3346 + // Security Analyzer (v2.1.0) — hydrate widget with the last persisted scan, if any.
3347 + if ( ! class_exists( 'Vigilante_Security_Analyzer' ) ) {
3348 + require_once VIGILANTE_INCLUDES_DIR . 'class-security-analyzer.php';
3349 + }
3350 + $analyzer_instance = new Vigilante_Security_Analyzer( $this->settings, $this->activity_log );
3351 + $analyzer_last_scan = $analyzer_instance->get_last_scan();
3352 + $analyzer_history = $analyzer_instance->get_score_history();
3353 + $analyzer_categories_def = Vigilante_Security_Analyzer::get_categories();
3354 + $analyzer_settings = isset( $options['security_analyzer'] ) ? $options['security_analyzer'] : array();
3355 + ?>
3356 + <div class="vigilante-dashboard">
3357 + <?php $this->render_self_protection_strip(); ?>
2593 3358 <?php $this->render_analyzer_widget( $analyzer_last_scan, $analyzer_history, $analyzer_categories_def, $analyzer_settings ); ?>
2594 3359
3360 + <?php $this->render_configuration_card( $security_score, $options, $active_preset, $presets ); ?>
3361 +
2595 3362 <div class="vigilante-modules-grid">
2596 - <h2><?php esc_html_e( 'Security Modules', 'vigilante' ); ?></h2>
3363 + <h2 id="vigilante-section-dashboard-modules"><?php esc_html_e( 'Security Modules', 'vigilante' ); ?></h2>
2597 3364 <p class="description"><?php esc_html_e( 'Enable or disable security modules. Each module controls a tab with detailed settings.', 'vigilante' ); ?></p>
2598 3365 <div class="vigilante-modules-list">
2599 3366 <?php foreach ( $options['modules'] as $module => $enabled ) :
2600 3367 $label = isset( $module_labels[ $module ] ) ? $module_labels[ $module ] : ucwords( str_replace( '_', ' ', $module ) );
2601 3368 $description = isset( $module_descriptions[ $module ] ) ? $module_descriptions[ $module ] : '';
3369 + $vg_module_locked = $this->main_site_files_locked() && in_array( $module, Vigilante_Settings::get_main_site_file_settings()['modules'], true );
2602 3370 ?>
2603 3371 <div class="vigilante-module-item <?php echo $enabled ? 'enabled' : 'disabled'; ?>">
2604 3372 <div class="vigilante-module-header">
2605 3373 <span class="vigilante-module-status"></span>
@@ -2612,8 +3380,9 @@
2612 3380 <input type="checkbox"
2613 3381 name="modules[<?php echo esc_attr( $module ); ?>]"
2614 3382 value="1"
2615 3383 <?php checked( $enabled ); ?>
3384 + <?php disabled( $vg_module_locked ); ?>
2616 3385 aria-label="<?php echo esc_attr( $toggle_label ); ?>"
2617 3386 data-module="<?php echo esc_attr( $module ); ?>">
2618 3387 <span class="vigilante-toggle-slider"></span>
2619 3388 </label>
@@ -2620,8 +3389,11 @@
2620 3389 </div>
2621 3390 <?php if ( $description ) : ?>
2622 3391 <p class="vigilante-module-desc"><?php echo esc_html( $description ); ?></p>
2623 3392 <?php endif; ?>
3393 + <?php if ( $vg_module_locked ) : ?>
3394 + <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>
3395 + <?php endif; ?>
2624 3396 </div>
2625 3397 <?php endforeach; ?>
2626 3398 </div>
2627 3399 </div>
@@ -2653,9 +3425,9 @@
2653 3425 $ua_remaining_hours = floor( $ua_remaining / 3600 );
2654 3426 $ua_remaining_mins = floor( ( $ua_remaining % 3600 ) / 60 );
2655 3427 ?>
2656 3428 <div class="vigilante-preset-card vigilante-under-attack-card <?php echo $ua_active ? 'vigilante-under-attack-active' : ''; ?>">
2657 - <h3>
3429 + <h3 id="vigilante-section-dashboard-under-attack">
2658 3430 <span class="dashicons dashicons-shield"></span>
2659 3431 <?php esc_html_e( 'Under Attack', 'vigilante' ); ?>
2660 3432 </h3>
2661 3433 <p><?php esc_html_e( 'Emergency mode. JavaScript challenge for all visitors, aggressive rate limiting, and restricted access. Auto-deactivates after 4 hours.', 'vigilante' ); ?></p>
@@ -3011,14 +3783,21 @@
3011 3783 <?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' ); ?>
3012 3784 </p>
3013 3785 </div>
3014 3786
3787 + <?php $vg_main_locked = $this->main_site_files_locked(); ?>
3788 + <?php if ( $vg_main_locked ) : ?>
3789 + <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;">
3790 + <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>
3791 + </div>
3792 + <?php endif; ?>
3793 +
3015 3794 <table class="form-table">
3016 3795 <tr>
3017 3796 <th scope="row"><?php esc_html_e( 'Block Bad Query Strings', 'vigilante' ); ?></th>
3018 3797 <td>
3019 3798 <label>
3020 - <input type="checkbox" name="firewall[block_bad_query_strings]" value="1" <?php checked( ! empty( $options['block_bad_query_strings'] ) ); ?>>
3799 + <input type="checkbox" name="firewall[block_bad_query_strings]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( ! empty( $options['block_bad_query_strings'] ) ); ?>>
3021 3800 <?php esc_html_e( 'Block malicious query string patterns', 'vigilante' ); ?>
3022 3801 </label>
3023 3802 </td>
3024 3803 </tr>
@@ -3061,11 +3840,12 @@
3061 3840 <tr>
3062 3841 <th scope="row"><?php esc_html_e( 'Block Bad Bots', 'vigilante' ); ?></th>
3063 3842 <td>
3064 3843 <label>
3065 - <input type="checkbox" name="firewall[block_bad_bots]" value="1" <?php checked( ! empty( $options['block_bad_bots'] ) ); ?>>
3844 + <input type="checkbox" name="firewall[block_bad_bots]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( ! empty( $options['block_bad_bots'] ) ); ?>>
3066 3845 <?php esc_html_e( 'Block known malicious bots and scanners', 'vigilante' ); ?>
3067 3846 </label>
3847 + <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>
3068 3848 </td>
3069 3849 </tr>
3070 3850 </table>
3071 3851
@@ -3177,8 +3957,29 @@
3177 3957 </table>
3178 3958 </div>
3179 3959 <?php endif; ?>
3180 3960
3961 + <?php
3962 + // Since 2.11.8 X-Forwarded-For is read from its end, where the proxy
3963 + // writes. The administrator's own request shows whether that end is
3964 + // a CDN or a balancer for everybody here. Cross review of 2.11.8.
3965 + $xff_readings = $this->forwarded_chain_readings();
3966 + if ( $xff_readings ) :
3967 + ?>
3968 + <div id="vigilante-xff-chain-notice" class="notice notice-warning inline" style="margin:10px 0 16px;padding:8px 12px;">
3969 + <p style="margin:0;">
3970 + <?php
3971 + printf(
3972 + /* translators: 1: last address in the header, the one Vigilant reads, 2: first address in the header, which a visitor can write */
3973 + 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' ),
3974 + esc_html( $xff_readings['now'] ),
3975 + esc_html( $xff_readings['before'] )
3976 + );
3977 + ?>
3978 + </p>
3979 + </div>
3980 + <?php endif; ?>
3981 +
3181 3982 <h3><?php esc_html_e( 'IP Lists', 'vigilante' ); ?></h3>
3182 3983 <p class="description">
3183 3984 <?php
3184 3985 printf(
@@ -3192,9 +3993,9 @@
3192 3993 <tr>
3193 3994 <th scope="row"><label for="vigilante-f-firewall-trusted-proxy-header"><?php esc_html_e( 'Visitor IP detection', 'vigilante' ); ?></label></th>
3194 3995 <td>
3195 3996 <?php $proxy_header = $options['trusted_proxy_header'] ?? ''; ?>
3196 - <select id="vigilante-f-firewall-trusted-proxy-header" name="firewall[trusted_proxy_header]">
3997 + <select id="vigilante-f-firewall-trusted-proxy-header" name="firewall[trusted_proxy_header]" <?php disabled( $vg_main_locked ); ?>>
3197 3998 <option value="" <?php selected( $proxy_header, '' ); ?>><?php esc_html_e( 'Direct connection, only REMOTE_ADDR (recommended)', 'vigilante' ); ?></option>
3198 3999 <option value="cf-connecting-ip" <?php selected( $proxy_header, 'cf-connecting-ip' ); ?>><?php esc_html_e( 'Behind Cloudflare (CF-Connecting-IP)', 'vigilante' ); ?></option>
3199 4000 <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>
3200 4001 <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>
@@ -3204,11 +4005,23 @@
3204 4005 </p>
3205 4006 </td>
3206 4007 </tr>
3207 4008 <tr>
4009 + <th scope="row"><label for="vigilante-f-firewall-trusted-proxies"><?php esc_html_e( 'Trusted proxy IPs', 'vigilante' ); ?></label></th>
4010 + <td>
4011 + <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>
4012 + <p class="description">
4013 + <?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' ); ?>
4014 + <?php if ( in_array( $proxy_header, array( 'x-forwarded-for', 'x-real-ip' ), true ) && empty( $options['trusted_proxies'] ) ) : ?>
4015 + <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>
4016 + <?php endif; ?>
4017 + </p>
4018 + </td>
4019 + </tr>
4020 + <tr>
3208 4021 <th scope="row"><label for="vigilante-f-firewall-ip-whitelist"><?php esc_html_e( 'IP Whitelist', 'vigilante' ); ?></label></th>
3209 4022 <td>
3210 - <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>
4023 + <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>
3211 4024 <p class="description">
3212 4025 <?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' ); ?>
3213 4026 <br>
3214 4027 <?php
@@ -3247,9 +4060,9 @@
3247 4060 <table class="form-table">
3248 4061 <tr>
3249 4062 <th scope="row"><label for="vigilante-f-firewall-ua-whitelist"><?php esc_html_e( 'User-Agent Whitelist', 'vigilante' ); ?></label></th>
3250 4063 <td>
3251 - <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>
4064 + <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>
3252 4065 <p class="description"><?php esc_html_e( 'One User-Agent per line. These will bypass all firewall checks. Example: ManageWP, MainWP, UptimeRobot.', 'vigilante' ); ?></p>
3253 4066 </td>
3254 4067 </tr>
3255 4068 <tr>
@@ -3345,9 +4158,9 @@
3345 4158 <th scope="row"><?php esc_html_e( 'Limit HTTP Methods', 'vigilante' ); ?></th>
3346 4159 <td>
3347 4160 <label>
3348 4161 <input type="checkbox" name="firewall[limit_http_methods]" value="1" <?php checked( ! empty( $options['limit_http_methods'] ) ); ?>>
3349 - <?php esc_html_e( 'Allow only GET, POST, HEAD (blocks PUT, DELETE, TRACE, etc.)', 'vigilante' ); ?>
4162 + <?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' ); ?>
3350 4163 </label>
3351 4164 </td>
3352 4165 </tr>
3353 4166 </table>
@@ -3537,9 +4350,9 @@
3537 4350 $two_factor = $options['two_factor'] ?? array();
3538 4351 $two_factor_enabled = ! empty( $two_factor['enabled'] );
3539 4352 ?>
3540 4353 <div class="vigilante-settings-section vigilante-lockout-section">
3541 - <h2><?php esc_html_e( 'Login Protection Status', 'vigilante' ); ?></h2>
4354 + <h2 id="vigilante-section-login-status"><?php esc_html_e( 'Login Protection Status', 'vigilante' ); ?></h2>
3542 4355
3543 4356 <table class="form-table">
3544 4357 <tr>
3545 4358 <th scope="row"><?php esc_html_e( 'Current settings', 'vigilante' ); ?></th>
@@ -3699,9 +4512,9 @@
3699 4512 $excluded = $two_factor['excluded_users'] ?? array();
3700 4513 $method = $two_factor['method'] ?? 'email';
3701 4514 $grace_days = $two_factor['grace_period_days'] ?? 3;
3702 4515 ?>
3703 - <h3>
4516 + <h3 id="vigilante-section-login-2fa">
3704 4517 <?php esc_html_e( 'Two-Factor Authentication (2FA)', 'vigilante' ); ?>
3705 4518 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
3706 4519 <span class="vigilante-method-badge database"><?php esc_html_e( 'Database', 'vigilante' ); ?></span>
3707 4520 </h3>
@@ -3747,8 +4560,9 @@
3747 4560 <tr>
3748 4561 <th scope="row"><?php esc_html_e( 'Enforce for roles', 'vigilante' ); ?></th>
3749 4562 <td>
3750 4563 <div class="vigilante-2fa-roles">
4564 + <?php $this->list_sentinel( 'login_security[two_factor][enforced_roles]' ); ?>
3751 4565 <?php foreach ( $all_roles as $role_slug => $role_data ) :
3752 4566 $user_count = count( get_users( array( 'role' => $role_slug, 'fields' => 'ID' ) ) );
3753 4567 ?>
3754 4568 <label>
@@ -3776,8 +4590,9 @@
3776 4590 autocomplete="off">
3777 4591 <div class="vigilante-2fa-search-results"></div>
3778 4592 </div>
3779 4593 <div class="vigilante-2fa-excluded-users">
4594 + <?php $this->list_sentinel( 'login_security[two_factor][excluded_users]' ); ?>
3780 4595 <?php
3781 4596 foreach ( $excluded as $user_id ) :
3782 4597 $user = get_user_by( 'ID', $user_id );
3783 4598 if ( ! $user ) continue;
@@ -3958,9 +4773,9 @@
3958 4773 ?>
3959 4774 <div class="vigilante-settings-section" id="vigilante-headers-recovery">
3960 4775 <h2><?php esc_html_e( 'Recover your previous header settings', 'vigilante' ); ?></h2>
3961 4776 <p>
3962 - <?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' ); ?>
4777 + <?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' ); ?>
3963 4778 </p>
3964 4779 <?php if ( $taken ) : ?>
3965 4780 <p class="description">
3966 4781 <?php
@@ -4072,9 +4887,9 @@
4072 4887 </td>
4073 4888 </tr>
4074 4889 </table>
4075 4890
4076 - <h3><?php esc_html_e( 'Content Security Policy', 'vigilante' ); ?></h3>
4891 + <h3 id="vigilante-section-headers-csp"><?php esc_html_e( 'Content Security Policy', 'vigilante' ); ?></h3>
4077 4892 <table class="form-table">
4078 4893 <tr>
4079 4894 <th scope="row"><?php esc_html_e( 'Enable CSP', 'vigilante' ); ?></th>
4080 4895 <td>
@@ -4081,8 +4896,9 @@
4081 4896 <label>
4082 4897 <input type="checkbox" name="security_headers[csp][enabled]" value="1" <?php checked( ! empty( $options['csp']['enabled'] ) ); ?>>
4083 4898 <?php esc_html_e( 'Enable Content Security Policy', 'vigilante' ); ?>
4084 4899 </label>
4900 + <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>
4085 4901 </td>
4086 4902 </tr>
4087 4903 <tr>
4088 4904 <th scope="row"><?php esc_html_e( 'Report Only Mode', 'vigilante' ); ?></th>
@@ -4094,9 +4910,9 @@
4094 4910 </td>
4095 4911 </tr>
4096 4912 </table>
4097 4913
4098 - <h3><?php esc_html_e( 'HTTPS', 'vigilante' ); ?></h3>
4914 + <h3 id="vigilante-section-headers-force-https"><?php esc_html_e( 'HTTPS', 'vigilante' ); ?></h3>
4099 4915 <p class="description"><?php esc_html_e( 'HTTPS is strongly recommended, but Vigilant will not impose it. Enable only what your site already supports.', 'vigilante' ); ?></p>
4100 4916 <table class="form-table">
4101 4917 <tr>
4102 4918 <th scope="row"><?php esc_html_e( 'Redirect HTTP to HTTPS', 'vigilante' ); ?></th>
@@ -4139,9 +4955,9 @@
4139 4955 </td>
4140 4956 </tr>
4141 4957 </table>
4142 4958
4143 - <h3><?php esc_html_e( 'HSTS (HTTP Strict Transport Security)', 'vigilante' ); ?></h3>
4959 + <h3 id="vigilante-section-headers-hsts"><?php esc_html_e( 'HSTS (HTTP Strict Transport Security)', 'vigilante' ); ?></h3>
4144 4960 <?php $vig_home_https = ( 0 === strpos( (string) get_option( 'home' ), 'https://' ) ); ?>
4145 4961 <p class="description"><?php esc_html_e( 'Tells browsers to reach this site over HTTPS and never over HTTP, for as long as the max age below.', 'vigilante' ); ?></p>
4146 4962 <?php if ( ! $vig_home_https ) : ?>
4147 4963 <p class="description" style="color:#b32d2e"><strong><?php esc_html_e( 'Unavailable: the site address still starts with http://. Enabling HSTS on a site not published over HTTPS would make it unreachable in any browser that honours it.', 'vigilante' ); ?></strong></p>
@@ -4182,9 +4998,9 @@
4182 4998 </td>
4183 4999 </tr>
4184 5000 </table>
4185 5001
4186 - <h3><?php esc_html_e( 'Server Identity', 'vigilante' ); ?></h3>
5002 + <h3 id="vigilante-section-headers-fingerprint"><?php esc_html_e( 'Server Identity', 'vigilante' ); ?></h3>
4187 5003 <p class="description"><?php esc_html_e( 'Hide identifying information that servers expose in responses.', 'vigilante' ); ?></p>
4188 5004 <table class="form-table">
4189 5005 <tr>
4190 5006 <th scope="row"><?php esc_html_e( 'Server Signature', 'vigilante' ); ?></th>
@@ -4433,8 +5249,9 @@
4433 5249 </tr>
4434 5250 <tr>
4435 5251 <th scope="row"><?php esc_html_e( 'Apply Password Rules To', 'vigilante' ); ?></th>
4436 5252 <td>
5253 + <?php $this->list_sentinel( 'user_security[password_policy][affected_roles]' ); ?>
4437 5254 <?php foreach ( wp_roles()->get_names() as $role_slug => $role_name ) : ?>
4438 5255 <label style="display:block;margin-bottom:5px;">
4439 5256 <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 ) ); ?>>
4440 5257 <?php echo esc_html( translate_user_role( $role_name ) ); ?>
@@ -4569,8 +5386,16 @@
4569 5386 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
4570 5387 </h2>
4571 5388 <p><?php esc_html_e( 'Limit the number of simultaneous sessions per user.', 'vigilante' ); ?></p>
4572 5389
5390 + <?php if ( Vigilante_User_Security::session_limit_is_network_wide() ) : ?>
5391 + <div class="notice notice-warning inline">
5392 + <p>
5393 + <?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' ); ?>
5394 + </p>
5395 + </div>
5396 + <?php endif; ?>
5397 +
4573 5398 <table class="form-table">
4574 5399 <tr>
4575 5400 <th scope="row"><?php esc_html_e( 'Enable Session Limits', 'vigilante' ); ?></th>
4576 5401 <td>
@@ -4666,8 +5491,9 @@
4666 5491 <td>
4667 5492 <?php
4668 5493 $affected_roles = $password_exp['affected_roles'] ?? array( 'administrator', 'editor' );
4669 5494 $all_roles = wp_roles()->get_names();
5495 + $this->list_sentinel( 'user_security[password_expiration][affected_roles]' );
4670 5496 foreach ( $all_roles as $role_slug => $role_name ) :
4671 5497 ?>
4672 5498 <label style="display: block; margin-bottom: 5px;">
4673 5499 <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 ) ); ?>>
@@ -4689,8 +5515,9 @@
4689 5515 autocomplete="off">
4690 5516 <div class="vigilante-pwexp-search-results"></div>
4691 5517 </div>
4692 5518 <div class="vigilante-pwexp-excluded-users">
5519 + <?php $this->list_sentinel( 'user_security[password_expiration][excluded_users]' ); ?>
4693 5520 <?php
4694 5521 foreach ( $pwexp_excluded as $pwexp_excluded_id ) :
4695 5522 $excluded_user = get_user_by( 'ID', $pwexp_excluded_id );
4696 5523 if ( ! $excluded_user ) {
@@ -4910,12 +5737,20 @@
4910 5737 </div>
4911 5738
4912 5739 <!-- Pending Registrations -->
4913 5740 <?php
4914 - $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log );
5741 + // Enforcement-only: this instance exists to read the queue, and the
5742 + // flag keeps it from registering the module's own hooks a second
5743 + // time. It is not inert, and saying it was would be a false comment:
5744 + // init_enforcement_hooks() does add its three filters again, on top
5745 + // of the ones already registered. They are idempotent (the same
5746 + // methods of an equivalent instance, deciding on the same user meta),
5747 + // so running them twice in an admin request changes nothing, which is
5748 + // why this is accepted rather than worked around.
5749 + $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log, true );
4915 5750 $pending_users = $user_security->get_pending_users();
4916 5751 ?>
4917 - <div class="vigilante-tool-box vigilante-pending-users-section">
5752 + <div id="vigilante-section-users-pending" class="vigilante-tool-box vigilante-pending-users-section">
4918 5753 <h3>
4919 5754 <?php esc_html_e( 'Pending registrations', 'vigilante' ); ?>
4920 5755 <?php if ( count( $pending_users ) > 0 ) : ?>
4921 5756 <span class="vigilante-badge vigilante-badge-warning"><?php echo esc_html( count( $pending_users ) ); ?></span>
@@ -4921,9 +5756,20 @@
4921 5756 <span class="vigilante-badge vigilante-badge-warning"><?php echo esc_html( count( $pending_users ) ); ?></span>
4922 5757 <?php endif; ?>
4923 5758 </h3>
4924 5759
4925 - <?php if ( empty( $registration['enabled'] ) ) : ?>
5760 + <?php
5761 + /*
5762 + * The queue is shown whenever there is somebody in it, even with
5763 + * the feature off. Since 2.11.10 an account already waiting stays
5764 + * blocked when the feature is switched off, which is the point:
5765 + * turning a setting off must not quietly let in people an
5766 + * administrator decided not to approve. But hiding the table then
5767 + * left them locked out with no button anywhere to approve or
5768 + * reject them. Found by the cross review of 2.11.10.
5769 + */
5770 + ?>
5771 + <?php if ( empty( $registration['enabled'] ) && empty( $pending_users ) ) : ?>
4926 5772 <p class="description">
4927 5773 <span class="dashicons dashicons-info" style="color: #72aee6;"></span>
4928 5774 <?php esc_html_e( 'Registration approval is disabled. Enable it in the settings above to require manual approval for new users.', 'vigilante' ); ?>
4929 5775 </p>
@@ -4932,8 +5778,9 @@
4932 5778 <span class="dashicons dashicons-yes-alt"></span>
4933 5779 <p><?php esc_html_e( 'No pending registrations.', 'vigilante' ); ?></p>
4934 5780 </div>
4935 5781 <?php else : ?>
5782 + <?php $this->render_user_actions_notice(); ?>
4936 5783 <table class="wp-list-table widefat fixed striped vigilante-pending-users-table">
4937 5784 <thead>
4938 5785 <tr>
4939 5786 <th><?php esc_html_e( 'User', 'vigilante' ); ?></th>
@@ -4943,9 +5790,9 @@
4943 5790 </tr>
4944 5791 </thead>
4945 5792 <tbody>
4946 5793 <?php foreach ( $pending_users as $pending_user ) :
4947 - $pending_since = get_user_meta( $pending_user->ID, 'vigilante_pending_since', true );
5794 + $pending_since = get_user_meta( $pending_user->ID, Vigilante_User_Security::site_user_meta_key( 'vigilante_pending_since' ), true );
4948 5795 ?>
4949 5796 <tr data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>">
4950 5797 <td>
4951 5798 <?php echo get_avatar( $pending_user->ID, 32 ); ?>
@@ -4962,12 +5809,12 @@
4962 5809 }
4963 5810 ?>
4964 5811 </td>
4965 5812 <td>
4966 - <button type="button" class="button button-small vigilante-approve-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>">
5813 + <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() ); ?>>
4967 5814 <?php esc_html_e( 'Approve', 'vigilante' ); ?>
4968 5815 </button>
4969 - <button type="button" class="button button-small vigilante-reject-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>" style="color: #d63638;">
5816 + <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() ); ?>>
4970 5817 <?php esc_html_e( 'Reject', 'vigilante' ); ?>
4971 5818 </button>
4972 5819 </td>
4973 5820 </tr>
@@ -5594,9 +6441,9 @@
5594 6441 <select id="vigilante-f-audit-alerts-immediate-min-severity" name="audit_alerts[immediate][min_severity]">
5595 6442 <option value="critical" <?php selected( $alert_severity, 'critical' ); ?>><?php esc_html_e( 'Critical only (recommended)', 'vigilante' ); ?></option>
5596 6443 <option value="warning" <?php selected( $alert_severity, 'warning' ); ?>><?php esc_html_e( 'Warning and Critical', 'vigilante' ); ?></option>
5597 6444 </select>
5598 - <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>
6445 + <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>
5599 6446 </td>
5600 6447 </tr>
5601 6448 <tr id="field-audit-alerts-threshold">
5602 6449 <th scope="row"><?php esc_html_e( 'Threshold alerts', 'vigilante' ); ?></th>
@@ -5689,8 +6536,9 @@
5689 6536
5690 6537 // Label maps for translated display
5691 6538 $type_labels = array(
5692 6539 'login' => __( 'Login', 'vigilante' ),
6540 + '2fa' => __( 'Two-factor', 'vigilante' ),
5693 6541 'user' => __( 'User', 'vigilante' ),
5694 6542 'content' => __( 'Content', 'vigilante' ),
5695 6543 'plugin' => __( 'Plugin', 'vigilante' ),
5696 6544 'theme' => __( 'Theme', 'vigilante' ),
@@ -5719,8 +6567,9 @@
5719 6567 <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">
5720 6568 <select id="vigilante-log-type-filter" aria-label="<?php esc_attr_e( 'Filter the log by event type', 'vigilante' ); ?>">
5721 6569 <option value=""><?php esc_html_e( 'All Types', 'vigilante' ); ?></option>
5722 6570 <option value="login"><?php esc_html_e( 'Login', 'vigilante' ); ?></option>
6571 + <option value="2fa"><?php esc_html_e( 'Two-factor', 'vigilante' ); ?></option>
5723 6572 <option value="user"><?php esc_html_e( 'User', 'vigilante' ); ?></option>
5724 6573 <option value="content"><?php esc_html_e( 'Content', 'vigilante' ); ?></option>
5725 6574 <option value="plugin"><?php esc_html_e( 'Plugin', 'vigilante' ); ?></option>
5726 6575 <option value="theme"><?php esc_html_e( 'Theme', 'vigilante' ); ?></option>
@@ -5814,8 +6663,19 @@
5814 6663 'is_ip_blacklisted' => ( '' !== $ip_val && in_array( $ip_val, $ip_blacklist, true ) ),
5815 6664 'is_ua_whitelisted' => ( '' !== $ua_val && in_array( $ua_val, $ua_whitelist, true ) ),
5816 6665 'is_ua_blacklisted' => ( '' !== $ua_val && in_array( $ua_val, $ua_blacklist, true ) ),
5817 6666 );
6667 + // Self-protection entries carry what happened, what
6668 + // it means and what to do, from the same catalogue
6669 + // the File Integrity box uses.
6670 + $vg_self_event = Vigilante_Self_Integrity_Guidance::for_log_event(
6671 + (string) ( $log->event_action ?? '' ),
6672 + $log->extra_data ?? '',
6673 + (string) ( $log->severity ?? 'info' )
6674 + );
6675 + if ( null !== $vg_self_event ) {
6676 + $details['self'] = $vg_self_event;
6677 + }
5818 6678 $display_type = isset( $type_labels[ $log->event_type ] ) ? $type_labels[ $log->event_type ] : $log->event_type;
5819 6679 $display_severity = isset( $severity_labels[ $log->severity ] ) ? $severity_labels[ $log->severity ] : $log->severity;
5820 6680 ?>
5821 6681 <tr class="vigilante-severity-<?php echo esc_attr( $log->severity ); ?>">
@@ -5858,15 +6718,534 @@
5858 6718
5859 6719 /**
5860 6720 * Render File Integrity tab
5861 6721 */
6722 + /**
6723 + * Findings of the self-check grouped by the case that explains them, worst
6724 + * first: ten modified files are one case with ten paths, not ten copies of
6725 + * the same explanation.
6726 + *
6727 + * @param array $findings Findings from the state.
6728 + * @return array
6729 + */
6730 + private function self_findings_by_case( $findings ) {
6731 + $groups = array();
6732 + foreach ( (array) $findings as $finding ) {
6733 + if ( ! is_array( $finding ) || 'info' === ( $finding['severity'] ?? '' ) ) {
6734 + continue;
6735 + }
6736 + $guidance = Vigilante_Self_Integrity_Guidance::for_finding( $finding );
6737 + $key = $guidance['key'];
6738 + if ( ! isset( $groups[ $key ] ) ) {
6739 + $guidance['files'] = array();
6740 + $guidance['severity'] = 'warning';
6741 + $groups[ $key ] = $guidance;
6742 + }
6743 + $file = isset( $finding['file'] ) ? (string) $finding['file'] : '';
6744 + if ( '' !== $file && ! in_array( $file, $groups[ $key ]['files'], true ) ) {
6745 + $groups[ $key ]['files'][] = $file;
6746 + }
6747 + if ( 'critical' === ( $finding['severity'] ?? '' ) ) {
6748 + $groups[ $key ]['severity'] = 'critical';
6749 + }
6750 + }
6751 + uasort(
6752 + $groups,
6753 + function ( $a, $b ) {
6754 + $rank = array( 'critical' => 0, 'warning' => 1 );
6755 + $ra = isset( $rank[ $a['severity'] ] ) ? $rank[ $a['severity'] ] : 2;
6756 + $rb = isset( $rank[ $b['severity'] ] ) ? $rank[ $b['severity'] ] : 2;
6757 + return $ra - $rb;
6758 + }
6759 + );
6760 + return $groups;
6761 + }
6762 +
6763 + /**
6764 + * Human label for the context that ran the last self-check.
6765 + *
6766 + * @param string $context Stored context.
6767 + * @return string
6768 + */
6769 + private function self_context_label( $context ) {
6770 + switch ( (string) $context ) {
6771 + case 'scan':
6772 + return __( 'during a file integrity scan', 'vigilante' );
6773 + case 'upgrader':
6774 + return __( 'right after updating Vigilant', 'vigilante' );
6775 + case 'version_change':
6776 + return __( 'after a version change made outside the updater', 'vigilante' );
6777 + case 'migration':
6778 + return __( 'while updating to this version', 'vigilante' );
6779 + case 'activation':
6780 + return __( 'when Vigilant was activated', 'vigilante' );
6781 + case 'watchdog':
6782 + return __( 'from the scheduled task watchdog', 'vigilante' );
6783 + }
6784 + return '';
6785 + }
6786 +
6787 + /**
6788 + * Short line for the badge of the box, the notices and the Dashboard strip.
6789 + *
6790 + * @param string $tone Tone from Vigilante_Self_Integrity::tone().
6791 + * @param array $state State.
6792 + * @return string
6793 + */
6794 + private function self_integrity_headline( $tone, $state ) {
6795 + $files = isset( $state['files_checked'] ) ? (int) $state['files_checked'] : 0;
6796 + $anchors = ( isset( $state['anchors'] ) && is_array( $state['anchors'] ) ) ? $state['anchors'] : array();
6797 + switch ( $tone ) {
6798 + case 'critical':
6799 + $kind = Vigilante_Self_Integrity::alarm_kind( $state, Vigilante_Self_Integrity::is_on() );
6800 + if ( 'tasks' === $kind ) {
6801 + return __( 'Vigilant scheduled tasks keep being removed', 'vigilante' );
6802 + }
6803 + if ( 'hooks' === $kind ) {
6804 + return __( 'Something removed the hooks of Vigilant self-protection', 'vigilante' );
6805 + }
6806 + return __( 'Changes detected in Vigilant own files', 'vigilante' );
6807 + case 'warning':
6808 + return ( $files < 1 )
6809 + ? __( 'Vigilant could not check its own files', 'vigilante' )
6810 + : __( 'Vigilant self-protection needs your attention', 'vigilante' );
6811 + case 'off':
6812 + return __( 'Self-protection is switched off by code', 'vigilante' );
6813 + case 'none':
6814 + return __( 'Vigilant has not checked its own files yet', 'vigilante' );
6815 + }
6816 + return sprintf(
6817 + /* translators: 1: number of files verified, 2: number of references available, out of three */
6818 + __( 'Verified: %1$d files, %2$d of 3 references', 'vigilante' ),
6819 + $files,
6820 + count( array_filter( $anchors ) )
6821 + );
6822 + }
6823 +
6824 + /**
6825 + * Vigilant self-protection box: the first block of the File Integrity
6826 + * results, with its own colour by severity, what each finding means and
6827 + * how to fix it.
6828 + *
6829 + * It renders whether or not a scan has been stored, because the self-check
6830 + * also runs after every update and once a day: before 3.0.0 this lived in
6831 + * a line above the numeric cards of the last scan, where it was invisible
6832 + * and, without a stored scan, absent.
6833 + *
6834 + * @param array $fi_options File Integrity settings section.
6835 + */
6836 + /**
6837 + * Everything the screens need to say about self-protection, read once:
6838 + * the state, its findings grouped by case, and the tone that colours the
6839 + * box, the menu counter and the notices.
6840 + *
6841 + * @param array|null $fi_options File Integrity settings, read if not given.
6842 + * @return array { state, findings, groups, tone, enabled }
6843 + */
6844 + private function self_integrity_summary( $fi_options = null ) {
6845 + // Four to six screens ask for this in the same page load (menu, notice,
6846 + // box, strip, both scores). The option is cached by the core options
6847 + // layer, but the grouping and the tone are not: memoize per request.
6848 + static $cached = null;
6849 + if ( null !== $cached && ! is_array( $fi_options ) ) {
6850 + return $cached;
6851 + }
6852 + if ( ! is_array( $fi_options ) ) {
6853 + $fi_options = (array) $this->settings->get_section( 'file_integrity' );
6854 + }
6855 + $enabled = Vigilante_Self_Integrity::is_on();
6856 + $state = Vigilante_Self_Integrity::display_state();
6857 + $findings = Vigilante_Self_Integrity::state_findings( $state, $enabled );
6858 + $groups = $this->self_findings_by_case( $findings );
6859 + // Whether any case on screen is one a clean copy of the plugin fixes,
6860 + // which is what the guidance marks as repair: the notice only sends
6861 + // to whoever can repair then. Tasks, removed hooks, a filter and a
6862 + // check that has not run for days are not.
6863 + $about_files = false;
6864 + foreach ( $groups as $group ) {
6865 + if ( ! empty( $group['repair'] ) ) {
6866 + $about_files = true;
6867 + break;
6868 + }
6869 + }
6870 + $summary = array(
6871 + 'state' => $state,
6872 + 'findings' => $findings,
6873 + 'groups' => $groups,
6874 + 'about_files' => $about_files,
6875 + 'tone' => Vigilante_Self_Integrity::tone( $state, $enabled ),
6876 + 'enabled' => $enabled,
6877 + // What the alarm is about (files, off, hooks or tasks), so no
6878 + // text says that files changed when that is not what was found.
6879 + 'kind' => Vigilante_Self_Integrity::alarm_kind( $state, $enabled ),
6880 + 'tasks' => Vigilante_Self_Integrity::alarm_is_about_tasks( $state, $enabled ),
6881 + );
6882 + $cached = $summary;
6883 + return $summary;
6884 + }
6885 +
6886 + private function render_self_protection_box( $fi_options ) {
6887 + $summary = $this->self_integrity_summary( $fi_options );
6888 + $enabled = $summary['enabled'];
6889 + $state = $summary['state'];
6890 + $findings = $summary['findings'];
6891 + $groups = $summary['groups'];
6892 + $tone = $summary['tone'];
6893 + $total = isset( $state['last_findings_total'] ) ? (int) $state['last_findings_total'] : count( $findings );
6894 + $status = array(
6895 + 'status' => isset( $state['last_status'] ) ? (string) $state['last_status'] : '',
6896 + 'files' => isset( $state['files_checked'] ) ? (int) $state['files_checked'] : 0,
6897 + 'anchors' => ( isset( $state['anchors'] ) && is_array( $state['anchors'] ) ) ? $state['anchors'] : array(),
6898 + 'enabled' => $enabled,
6899 + 'has_run' => ! empty( $state['last_check'] ),
6900 + );
6901 +
6902 + /*
6903 + * Nothing to do, nothing to open: one line. A card with a big icon and
6904 + * folded sections for "everything is fine" is furniture, and furniture
6905 + * is what made the previous version of this invisible.
6906 + */
6907 + if ( empty( $groups ) ) {
6908 + $this->render_self_line( $tone, $state, $status );
6909 + return;
6910 + }
6911 +
6912 + $datetime_format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' );
6913 + $context_label = $this->self_context_label( isset( $state['last_context'] ) ? $state['last_context'] : '' );
6914 + $icon = ( 'critical' === $tone ) ? 'dashicons-shield' : 'dashicons-warning';
6915 + ?>
6916 + <div id="vigilante-section-fi-self" class="vigilante-settings-section vigilante-self-box vigilante-self-box--<?php echo esc_attr( $tone ); ?>">
6917 + <div class="vigilante-self-card">
6918 + <div class="vigilante-self-head">
6919 + <span class="vigilante-self-icon"><span class="dashicons <?php echo esc_attr( $icon ); ?>" aria-hidden="true"></span></span>
6920 + <div class="vigilante-self-head-text">
6921 + <h2><?php echo esc_html( $this->self_integrity_headline( $tone, $state ) ); ?></h2>
6922 + <p class="vigilante-self-meta">
6923 + <?php
6924 + printf(
6925 + /* translators: %s: date and time of the last self-check */
6926 + esc_html__( 'Checked on %s', 'vigilante' ),
6927 + esc_html( wp_date( $datetime_format, (int) $state['last_check'] ) )
6928 + );
6929 + if ( '' !== $context_label ) {
6930 + echo ', ' . esc_html( $context_label );
6931 + }
6932 + if ( $status['files'] > 0 ) {
6933 + echo ', ';
6934 + printf(
6935 + /* translators: 1: number of files checked, 2: number of references available, out of three */
6936 + esc_html__( '%1$d files against %2$d of 3 references', 'vigilante' ),
6937 + (int) $status['files'],
6938 + (int) count( array_filter( $status['anchors'] ) )
6939 + );
6940 + }
6941 + ?>
6942 + </p>
6943 + </div>
6944 + </div>
6945 +
6946 + <div class="vigilante-self-cases">
6947 + <?php
6948 + $first = true;
6949 + /*
6950 + * Reinstalling fixes files, not a filter someone wrote, a hook
6951 + * someone removed or a scheduled task that went missing: the
6952 + * button only appears when at least one of the cases on screen
6953 + * is one a clean copy solves. Offering it next to "switched off
6954 + * by code" was telling the person to fix something else. And
6955 + * when every case is about scheduled tasks there is nothing to
6956 + * repair, so the note about repairing by hand stays out too.
6957 + */
6958 + $vg_offer_repair = false;
6959 + $vg_scan_helps = false;
6960 + foreach ( $groups as $group ) {
6961 + $vg_is_task = ( 0 === strpos( (string) $group['key'], 'cron_' ) );
6962 + $vg_unit = 'file';
6963 + if ( $vg_is_task ) {
6964 + $vg_unit = 'task';
6965 + } elseif ( 'hooks_removed' === $group['key'] ) {
6966 + $vg_unit = 'hook';
6967 + }
6968 + $this->render_self_case( $group, $group['files'], $group['severity'], $first, $vg_unit );
6969 + $first = false;
6970 + if ( ! empty( $group['repair'] ) ) {
6971 + $vg_offer_repair = true;
6972 + }
6973 + // A scan checks files: it clears nothing about a task, which
6974 + // closes on its own, nor about removed hooks, which the next
6975 + // admin page looks for again.
6976 + if ( ! $vg_is_task && 'hooks_removed' !== $group['key'] ) {
6977 + $vg_scan_helps = true;
6978 + }
6979 + }
6980 +
6981 + // How to replace the files by hand is for a case a clean copy fixes.
6982 + if ( $vg_offer_repair && in_array( $tone, array( 'critical', 'warning' ), true ) ) {
6983 + $can_repair = class_exists( 'Vigilante_Self_Repair' ) && Vigilante_Self_Repair::can_repair();
6984 + if ( ! $can_repair ) {
6985 + if ( class_exists( 'Vigilante_Self_Repair' ) && ! Vigilante_Self_Repair::folder_is_the_distributed_one() ) {
6986 + $vg_reason = 'renamed';
6987 + } elseif ( ! wp_is_file_mod_allowed( 'capability_update_core' ) ) {
6988 + // With DISALLOW_FILE_MODS nobody can do it from the admin,
6989 + // not even a network administrator: telling a subsite admin
6990 + // to ask theirs would send them to someone equally blocked.
6991 + $vg_reason = 'no_caps';
6992 + } elseif ( is_multisite() && ! current_user_can( 'update_plugins' ) ) {
6993 + $vg_reason = 'network';
6994 + } else {
6995 + $vg_reason = 'no_caps';
6996 + }
6997 + $this->render_self_case(
6998 + array(
6999 + 'title' => __( 'How to repair it here', 'vigilante' ),
7000 + 'meaning' => __( 'This site does not let Vigilant replace its own files from this screen.', 'vigilante' ),
7001 + 'steps' => Vigilante_Self_Integrity_Guidance::manual_steps( $vg_reason ),
7002 + ),
7003 + array(),
7004 + '',
7005 + false
7006 + );
7007 + }
7008 + }
7009 + ?>
7010 + </div>
7011 +
7012 + <?php // Switched off, the box only holds that case: the stored total is of the last check that ran. ?>
7013 + <?php if ( $enabled && $total > count( $findings ) ) : ?>
7014 + <p class="description">
7015 + <?php
7016 + printf(
7017 + /* translators: 1: findings shown, 2: findings found in total */
7018 + esc_html__( 'Showing %1$d of %2$d findings: the rest are of the same kind.', 'vigilante' ),
7019 + (int) count( $findings ),
7020 + (int) $total
7021 + );
7022 + ?>
7023 + </p>
7024 + <?php endif; ?>
7025 +
7026 + <p class="vigilante-self-actions">
7027 + <?php if ( $vg_offer_repair && class_exists( 'Vigilante_Self_Repair' ) && Vigilante_Self_Repair::can_repair() ) : ?>
7028 + <a class="button button-primary" href="<?php echo esc_url( Vigilante_Self_Repair::action_url() ); ?>">
7029 + <?php esc_html_e( 'Repair Vigilant', 'vigilante' ); ?>
7030 + </a>
7031 + <span class="description">
7032 + <?php esc_html_e( 'It downloads a clean copy from WordPress.org, asks before changing anything, and keeps your settings and log.', 'vigilante' ); ?>
7033 + </span>
7034 + <?php elseif ( $vg_scan_helps ) : ?>
7035 + <span class="description">
7036 + <?php esc_html_e( 'After fixing it, run a new scan with the Run Scan Now button above.', 'vigilante' ); ?>
7037 + </span>
7038 + <?php endif; ?>
7039 + </p>
7040 + </div>
7041 + </div>
7042 + <?php
7043 + }
7044 +
7045 + /**
7046 + * The same state when there is nothing to act on: one line inside the same
7047 + * card, so the first block of the results is always the same object.
7048 + *
7049 + * @param string $tone Tone from Vigilante_Self_Integrity::tone().
7050 + * @param array $state State.
7051 + * @param array $status Arguments for the guidance catalogue.
7052 + */
7053 + private function render_self_line( $tone, $state, $status ) {
7054 + $guidance = Vigilante_Self_Integrity_Guidance::for_status( $status );
7055 + $icon = ( 'ok' === $tone ) ? 'dashicons-yes-alt' : 'dashicons-shield';
7056 + ?>
7057 + <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">
7058 + <div class="vigilante-self-card vigilante-self-card--quiet">
7059 + <p class="vigilante-self-line">
7060 + <span class="dashicons <?php echo esc_attr( $icon ); ?>" aria-hidden="true"></span>
7061 + <strong><?php esc_html_e( 'Vigilant self-protection:', 'vigilante' ); ?></strong>
7062 + <span class="vigilante-self-line-state"><?php echo esc_html( $this->self_integrity_headline( $tone, $state ) ); ?></span>
7063 + <?php if ( ! empty( $state['last_check'] ) ) : ?>
7064 + <span class="vigilante-self-line-meta">
7065 + <?php
7066 + printf(
7067 + /* translators: %s: human time difference, like "2 hours" */
7068 + esc_html__( 'checked %s ago', 'vigilante' ),
7069 + esc_html( human_time_diff( (int) $state['last_check'], time() ) )
7070 + );
7071 + ?>
7072 + </span>
7073 + <?php endif; ?>
7074 + </p>
7075 + <?php if ( ! empty( $guidance['steps'] ) && 'none' === $tone ) : ?>
7076 + <p class="description vigilante-self-line-help"><?php echo esc_html( $guidance['steps'][0] ); ?></p>
7077 + <?php endif; ?>
7078 + </div>
7079 + </div>
7080 + <?php
7081 + }
7082 +
7083 + /**
7084 + * One case of the self-protection box, as a row that opens: what it is, how
7085 + * many files, its severity, and inside, the files, what it means and what to
7086 + * do. Plain HTML details, so there is no JavaScript between a finding about
7087 + * the security plugin and the person reading it.
7088 + *
7089 + * @param array $guidance Guidance entry (title, meaning, steps).
7090 + * @param array $files Paths this case was found in.
7091 + * @param string $severity critical|warning, empty for the how-to-repair row.
7092 + * @param bool $open Whether the row starts open.
7093 + * @param string $unit What is being counted, file, task or hook: a
7094 + * scheduled task and a removed hook were counted
7095 + * as "1 file" until 3.0.4.
7096 + */
7097 + private function render_self_case( $guidance, $files = array(), $severity = '', $open = false, $unit = 'file' ) {
7098 + $shown = array_slice( (array) $files, 0, 20 );
7099 + $hidden = count( (array) $files ) - count( $shown );
7100 + ?>
7101 + <details class="vigilante-self-case vigilante-self-case--<?php echo esc_attr( '' !== $severity ? $severity : 'plain' ); ?>"<?php echo $open ? ' open' : ''; ?>>
7102 + <summary>
7103 + <span class="vigilante-self-case-title"><?php echo esc_html( $guidance['title'] ); ?></span>
7104 + <?php if ( ! empty( $files ) ) : ?>
7105 + <span class="vigilante-self-case-count">
7106 + <?php
7107 + if ( 'task' === $unit ) {
7108 + printf(
7109 + /* translators: %d: number of scheduled tasks of this finding */
7110 + esc_html( _n( '%d task', '%d tasks', count( (array) $files ), 'vigilante' ) ),
7111 + (int) count( (array) $files )
7112 + );
7113 + } elseif ( 'hook' === $unit ) {
7114 + printf(
7115 + /* translators: %d: number of removed hooks of this finding */
7116 + esc_html( _n( '%d hook', '%d hooks', count( (array) $files ), 'vigilante' ) ),
7117 + (int) count( (array) $files )
7118 + );
7119 + } else {
7120 + printf(
7121 + /* translators: %d: number of files of this finding */
7122 + esc_html( _n( '%d file', '%d files', count( (array) $files ), 'vigilante' ) ),
7123 + (int) count( (array) $files )
7124 + );
7125 + }
7126 + ?>
7127 + </span>
7128 + <?php endif; ?>
7129 + <?php if ( '' !== $severity ) : ?>
7130 + <span class="vigilante-self-case-severity">
7131 + <?php echo esc_html( 'critical' === $severity ? __( 'critical', 'vigilante' ) : __( 'warning', 'vigilante' ) ); ?>
7132 + </span>
7133 + <?php endif; ?>
7134 + </summary>
7135 + <div class="vigilante-self-case-body">
7136 + <?php if ( ! empty( $shown ) ) : ?>
7137 + <ul class="vigilante-self-paths">
7138 + <?php foreach ( $shown as $file ) : ?>
7139 + <li><code><?php echo esc_html( $file ); ?></code></li>
7140 + <?php endforeach; ?>
7141 + <?php if ( $hidden > 0 ) : ?>
7142 + <li>
7143 + <?php
7144 + printf(
7145 + /* translators: %d: number of additional files */
7146 + esc_html__( 'and %d more', 'vigilante' ),
7147 + (int) $hidden
7148 + );
7149 + ?>
7150 + </li>
7151 + <?php endif; ?>
7152 + </ul>
7153 + <?php endif; ?>
7154 + <p class="vigilante-self-meaning">
7155 + <strong><?php esc_html_e( 'What it means:', 'vigilante' ); ?></strong>
7156 + <?php echo esc_html( $guidance['meaning'] ); ?>
7157 + </p>
7158 + <?php if ( ! empty( $guidance['steps'] ) ) : ?>
7159 + <p class="vigilante-self-todo"><strong><?php esc_html_e( 'What to do:', 'vigilante' ); ?></strong></p>
7160 + <ol class="vigilante-self-steps">
7161 + <?php foreach ( $guidance['steps'] as $step ) : ?>
7162 + <li><?php echo esc_html( $step ); ?></li>
7163 + <?php endforeach; ?>
7164 + </ol>
7165 + <?php endif; ?>
7166 + </div>
7167 + </details>
7168 + <?php
7169 + }
7170 +
7171 + /**
7172 + * Print what each kind of finding in a list usually is and what to do
7173 + *
7174 + * One line per kind actually present in the list, from the single source
7175 + * of that text, Vigilante_File_Integrity::finding_guidance().
7176 + *
7177 + * @param array $items Findings of one section of the last scan.
7178 + */
7179 + private function render_fi_guidance( $items ) {
7180 + $seen = array();
7181 + $guides = array();
7182 +
7183 + foreach ( (array) $items as $item ) {
7184 + $type = is_array( $item ) ? (string) ( $item['type'] ?? '' ) : '';
7185 +
7186 + if ( '' === $type || 'vigilante_self' === $type || isset( $seen[ $type ] ) ) {
7187 + continue;
7188 + }
7189 + $seen[ $type ] = true;
7190 +
7191 + $guide = Vigilante_File_Integrity::finding_guidance( $type );
7192 +
7193 + if ( '' !== $guide['advice'] ) {
7194 + // A plugin and a theme share their text: one line for both,
7195 + // under the two names.
7196 + $guides[ $guide['advice'] ][] = $guide['label'];
7197 + }
7198 + }
7199 +
7200 + if ( empty( $guides ) ) {
7201 + return;
7202 + }
7203 + ?>
7204 + <ul class="vigilante-fi-guidance" style="margin: 8px 0 12px 18px; list-style: disc;">
7205 + <?php foreach ( $guides as $advice => $labels ) : ?>
7206 + <li><strong><?php echo esc_html( implode( ', ', $labels ) ); ?>:</strong> <?php echo esc_html( $advice ); ?></li>
7207 + <?php endforeach; ?>
7208 + </ul>
7209 + <?php
7210 + }
7211 +
5862 7212 private function render_tab_file_integrity() {
5863 7213 $is_disabled = $this->render_module_disabled_notice( 'file_integrity' );
5864 7214 $options = $this->settings->get_section( 'file_integrity' );
7215 + // On the main site of a network the critical-file scan is the network's
7216 + // canary for a change to wp-config.php or the root .htaccess, so a
7217 + // main-site admin without network rights cannot turn it off. Since
7218 + // 2.11.8; see Vigilante_Settings::get_main_site_file_settings().
7219 + $vg_main_locked = $this->main_site_files_locked();
5865 7220 $last_scan = get_option( 'vigilante_last_integrity_scan' );
5866 7221 $last_results = get_option( 'vigilante_last_integrity_results' );
5867 7222 $ignored_files = get_option( 'vigilante_ignored_files', array() );
5868 7223
7224 + /*
7225 + * Vigilant own findings are shown in their own box, the first of the
7226 + * results, with what each one means and how to fix it. They are taken
7227 + * out of the generic tables here (and out of the counters above them)
7228 + * so the same finding is not reported twice and so no row of the
7229 + * security plugin's own files offers an Ignore button. The stored
7230 + * results keep them: the scan email reads its own section from there.
7231 + */
7232 + if ( is_array( $last_results ) ) {
7233 + foreach ( array( 'modified', 'suspicious', 'extra', 'missing' ) as $vg_bucket ) {
7234 + if ( empty( $last_results[ $vg_bucket ] ) || ! is_array( $last_results[ $vg_bucket ] ) ) {
7235 + continue;
7236 + }
7237 + $last_results[ $vg_bucket ] = array_values(
7238 + array_filter(
7239 + $last_results[ $vg_bucket ],
7240 + function ( $vg_item ) {
7241 + return ! ( is_array( $vg_item ) && 'vigilante_self' === ( $vg_item['type'] ?? '' ) );
7242 + }
7243 + )
7244 + );
7245 + }
7246 + }
7247 +
5869 7248 // Backward compat: convert old notify_on_changes to notify_level
5870 7249 $notify_level = $options['notify_level'] ?? '';
5871 7250 if ( empty( $notify_level ) ) {
5872 7251 $notify_level = ! empty( $options['notify_on_changes'] ) ? 'all' : 'disabled';
@@ -5902,9 +7281,10 @@
5902 7281 <h2>
5903 7282 <?php esc_html_e( 'File Integrity Monitoring', 'vigilante' ); ?>
5904 7283 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
5905 7284 </h2>
5906 - <p><?php esc_html_e( 'Detects file modifications using WordPress.org checksums.', 'vigilante' ); ?></p>
7285 + <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>
7286 + <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>
5907 7287
5908 7288 <table class="form-table">
5909 7289 <tr>
5910 7290 <th scope="row"><?php esc_html_e( 'Automatic Scans', 'vigilante' ); ?></th>
@@ -5927,13 +7307,13 @@
5927 7307 <tr>
5928 7308 <th scope="row"><label for="vigilante-f-file-integrity-notify-level"><?php esc_html_e( 'Email Notifications', 'vigilante' ); ?></label></th>
5929 7309 <td>
5930 7310 <select id="vigilante-f-file-integrity-notify-level" name="file_integrity[notify_level]">
5931 - <option value="all" <?php selected( $notify_level, 'all' ); ?>><?php esc_html_e( 'All issues (modified + suspicious)', 'vigilante' ); ?></option>
5932 - <option value="suspicious_only" <?php selected( $notify_level, 'suspicious_only' ); ?>><?php esc_html_e( 'Suspicious files only', 'vigilante' ); ?></option>
7311 + <option value="all" <?php selected( $notify_level, 'all' ); ?>><?php esc_html_e( 'Every finding, modified files included', 'vigilante' ); ?></option>
7312 + <option value="suspicious_only" <?php selected( $notify_level, 'suspicious_only' ); ?>><?php esc_html_e( 'Serious findings only', 'vigilante' ); ?></option>
5933 7313 <option value="disabled" <?php selected( $notify_level, 'disabled' ); ?>><?php esc_html_e( 'Disabled', 'vigilante' ); ?></option>
5934 7314 </select>
5935 - <p class="description"><?php esc_html_e( '"Suspicious files only" reduces noise by skipping modified file notifications. Recommended for most sites.', 'vigilante' ); ?></p>
7315 + <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>
5936 7316 </td>
5937 7317 </tr>
5938 7318 <tr>
5939 7319 <th scope="row"><?php esc_html_e( 'Instant Alert', 'vigilante' ); ?></th>
@@ -5939,11 +7319,11 @@
5939 7319 <th scope="row"><?php esc_html_e( 'Instant Alert', 'vigilante' ); ?></th>
5940 7320 <td>
5941 7321 <label>
5942 7322 <input type="checkbox" name="file_integrity[instant_alert]" value="1" <?php checked( ! empty( $options['instant_alert'] ) ); ?>>
5943 - <?php esc_html_e( 'Send immediate alert when modified, suspicious or additional files are detected, or when a closed plugin is found', 'vigilante' ); ?>
7323 + <?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' ); ?>
5944 7324 </label>
5945 - <p class="description"><?php esc_html_e( 'Fires even if the Email Notifications setting above is set to Disabled.', 'vigilante' ); ?></p>
7325 + <p class="description"><?php esc_html_e( 'This takes precedence over the choice above, Disabled included.', 'vigilante' ); ?></p>
5946 7326 <p class="description">
5947 7327 <?php
5948 7328 printf(
5949 7329 /* translators: %s: Link to notification settings */
@@ -5969,29 +7349,32 @@
5969 7349 <td>
5970 7350 <fieldset>
5971 7351 <label>
5972 7352 <input type="checkbox" name="file_integrity[scan_core]" value="1" <?php checked( $options['scan_core'] ?? true ); ?>>
5973 - <?php esc_html_e( 'Core files (compare against WordPress.org checksums)', 'vigilante' ); ?>
7353 + <?php esc_html_e( 'WordPress core (compared with WordPress.org) and the other files in the site root', 'vigilante' ); ?>
5974 7354 </label>
5975 7355 <br>
5976 7356 <label>
5977 7357 <input type="checkbox" name="file_integrity[scan_plugins]" value="1" <?php checked( $options['scan_plugins'] ?? true ); ?>>
5978 - <?php esc_html_e( 'Plugins (WordPress.org repository plugins)', 'vigilante' ); ?>
7358 + <?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' ); ?>
5979 7359 </label>
5980 7360 <br>
5981 7361 <label>
5982 7362 <input type="checkbox" name="file_integrity[scan_themes]" value="1" <?php checked( $options['scan_themes'] ?? true ); ?>>
5983 - <?php esc_html_e( 'Themes (WordPress.org repository themes)', 'vigilante' ); ?>
7363 + <?php esc_html_e( 'Themes (compared with WordPress.org; the rest are only searched for hidden code)', 'vigilante' ); ?>
5984 7364 </label>
5985 7365 <br>
5986 7366 <label>
5987 7367 <input type="checkbox" name="file_integrity[scan_uploads]" value="1" <?php checked( $options['scan_uploads'] ?? true ); ?>>
5988 - <?php esc_html_e( 'Uploads directory (detect PHP files, double extensions, .htaccess)', 'vigilante' ); ?>
7368 + <?php esc_html_e( 'Uploads directory (PHP and other scripts, double extensions, .htaccess and PHP configuration files)', 'vigilante' ); ?>
5989 7369 </label>
5990 7370 <br>
5991 7371 <label>
5992 - <input type="checkbox" name="file_integrity[scan_critical_config]" value="1" <?php checked( $options['scan_critical_config'] ?? true ); ?>>
5993 - <?php esc_html_e( 'Critical config files (wp-config.php, .htaccess baseline monitoring)', 'vigilante' ); ?>
7372 + <input type="checkbox" name="file_integrity[scan_critical_config]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( $options['scan_critical_config'] ?? true ); ?>>
7373 + <?php esc_html_e( 'wp-config.php and .htaccess (any change since you last approved them)', 'vigilante' ); ?>
7374 + <?php if ( $vg_main_locked ) : ?>
7375 + <span class="description" style="display:block;margin-left:24px;"><?php echo esc_html( Vigilante_Settings::get_shared_files_notice() ); ?></span>
7376 + <?php endif; ?>
5994 7377 </label>
5995 7378 <br>
5996 7379 <label>
5997 7380 <input type="checkbox" name="file_integrity[check_closed_plugins]" value="1" <?php checked( $options['check_closed_plugins'] ?? true ); ?>>
@@ -6002,10 +7385,10 @@
6002 7385 </tr>
6003 7386 <tr>
6004 7387 <th scope="row"><label for="vigilante-f-file-integrity-excluded-paths"><?php esc_html_e( 'Excluded Paths', 'vigilante' ); ?></label></th>
6005 7388 <td>
6006 - <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>
6007 - <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>
7389 + <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>
7390 + <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>
6008 7391 </td>
6009 7392 </tr>
6010 7393 <tr>
6011 7394 <th scope="row"><label for="vigilante-f-file-integrity-excluded-extensions"><?php esc_html_e( 'Excluded Extensions', 'vigilante' ); ?></label></th>
@@ -6046,11 +7429,51 @@
6046 7429 </form>
6047 7430
6048 7431 <div id="vigilante-scan-results" class="vigilante-settings-section" style="display:none;"></div>
6049 7432
7433 + <?php $this->render_self_protection_box( $options ); ?>
7434 +
7435 + <?php
7436 + // Whole folders of code left out of every scan. A partial scan tells
7437 + // how to check the rest by excluding what it did check, and those
7438 + // lines have to come out afterwards: with them in place a scan comes
7439 + // out complete and clean without having looked there. Said for as
7440 + // long as they are in the list, whoever put them there and why.
7441 + $vg_fi_tool = new Vigilante_File_Integrity( $this->settings, $this->database );
7442 + $vg_excluded_code = $vg_fi_tool->excluded_code_folders();
7443 + $vg_folders_to_skip = $vg_fi_tool->folders_to_exclude( $last_results );
7444 + ?>
7445 + <?php if ( ! empty( $vg_excluded_code ) ) : ?>
7446 + <div id="vigilante-fi-excluded-code" class="notice notice-warning inline">
7447 + <p>
7448 + <strong>
7449 + <?php
7450 + printf(
7451 + /* translators: %s: Comma separated list of folders */
7452 + esc_html__( 'Excluded Paths holds whole folders of plugins or themes, or the uploads folder: %s.', 'vigilante' ),
7453 + esc_html( implode( ', ', $vg_excluded_code ) )
7454 + );
7455 + ?>
7456 + </strong>
7457 + <?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' ); ?>
7458 + </p>
7459 + </div>
7460 + <?php endif; ?>
7461 +
6050 7462 <?php if ( $last_scan || $has_closed || $closed_last_check > 0 ) : ?>
6051 7463 <div id="vigilante-section-fi-last-scan" class="vigilante-settings-section">
6052 7464 <h2><?php esc_html_e( 'Last Scan Results', 'vigilante' ); ?></h2>
7465 + <?php
7466 + // A scan the server stops halfway stores nothing, and neither
7467 + // does one WordPress never starts: the tab kept showing the
7468 + // last result that did finish, with nothing to say that scans
7469 + // had stopped. Its date is the only sign.
7470 + if ( $last_scan && $this->settings->is_module_enabled( 'file_integrity' ) && Vigilante_File_Integrity::is_scan_overdue( $last_scan, $options['scan_frequency'] ?? 'daily' ) ) :
7471 + ?>
7472 + <div class="notice notice-warning inline">
7473 + <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>
7474 + </div>
7475 + <?php endif; ?>
6053 7476 <?php if ( $last_scan ) : ?>
6054 7477 <p>
6055 7478 <?php
6056 7479 // Build the "X files scanned" hint inline with the date so it doesn't
@@ -6133,12 +7556,112 @@
6133 7556 </div>
6134 7557 <?php endif; ?>
6135 7558 </div>
6136 7559
7560 + <?php
7561 + // A scan that could not finish, or could not get the official
7562 + // copy to compare with, checked less than it seems. Until now
7563 + // its errors were stored and never shown, and the tab could
7564 + // say that every file had passed.
7565 + $vg_scan_errors = ( $last_results && ! empty( $last_results['errors'] ) && is_array( $last_results['errors'] ) ) ? $last_results['errors'] : array();
7566 + $vg_scan_partial = ! empty( $vg_scan_errors ) || ( $last_results && ! empty( $last_results['incomplete'] ) );
7567 +
7568 + // Files of the official copies that are not on the server. Core
7569 + // files under wp-content are left out: results stored before
7570 + // the bundled themes and plugins got their own turn list
7571 + // there the ones people delete on purpose.
7572 + $vg_missing_core = array();
7573 + if ( $last_results && ! empty( $last_results['missing'] ) && is_array( $last_results['missing'] ) ) {
7574 + foreach ( $last_results['missing'] as $item ) {
7575 + if ( ! is_array( $item ) || ! isset( $item['file'] ) ) {
7576 + continue;
7577 + }
7578 +
7579 + $vg_missing_type = (string) ( $item['type'] ?? '' );
7580 +
7581 + if ( in_array( $vg_missing_type, array( 'plugin', 'theme' ), true )
7582 + || ( 'core' === $vg_missing_type && 0 !== strpos( (string) $item['file'], 'wp-content/' ) ) ) {
7583 + $vg_missing_core[] = (string) $item['file'];
7584 + }
7585 + }
7586 + }
7587 + ?>
7588 +
7589 + <?php if ( $last_results && ! empty( $last_results['unverified'] ) && is_array( $last_results['unverified'] ) ) : ?>
7590 + <p class="description">
7591 + <?php
7592 + printf(
7593 + /* translators: %s: Comma separated list of plugin and theme names */
7594 + esc_html__( 'No official copy to compare with, so only searched for hidden code: %s.', 'vigilante' ),
7595 + esc_html( implode( ', ', array_unique( array_map( 'strval', $last_results['unverified'] ) ) ) )
7596 + );
7597 + ?>
7598 + </p>
7599 + <?php endif; ?>
7600 +
7601 + <?php
7602 + // What the scan did not get to, by name. A scan starts from
7603 + // the beginning every time, so on a site too large for it
7604 + // these are the same on every scan, and whoever reads the
7605 + // result has to know that nothing here was looked at.
7606 + $vg_scan_notes = array_merge( $vg_scan_errors, Vigilante_File_Integrity::not_checked_lines( $last_results ) );
7607 + ?>
7608 + <?php if ( $vg_scan_partial ) : ?>
7609 + <div class="notice notice-warning inline">
7610 + <p><strong><?php esc_html_e( 'The last scan could not check everything, so its result is partial.', 'vigilante' ); ?></strong></p>
7611 + <?php if ( ! empty( $vg_scan_notes ) ) : ?>
7612 + <ul style="margin: 0 0 8px 18px; list-style: disc;">
7613 + <?php foreach ( $vg_scan_notes as $vg_scan_note ) : ?>
7614 + <li><?php echo esc_html( (string) $vg_scan_note ); ?></li>
7615 + <?php endforeach; ?>
7616 + </ul>
7617 + <?php endif; ?>
7618 + <p>
7619 + <?php
7620 + printf(
7621 + /* translators: %d: Seconds a scan runs for at most */
7622 + 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' ),
7623 + (int) Vigilante_File_Integrity::MAX_SCAN_SECONDS
7624 + );
7625 + ?>
7626 + </p>
7627 + <?php if ( ! empty( $vg_folders_to_skip ) ) : ?>
7628 + <p><strong><?php esc_html_e( 'To check what was left out:', 'vigilante' ); ?></strong></p>
7629 + <ol style="margin: 0 0 8px 18px;">
7630 + <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>
7631 + <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>
7632 + <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>
7633 + </ol>
7634 + <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>
7635 + <?php endif; ?>
7636 + </div>
7637 + <?php endif; ?>
7638 +
7639 + <?php if ( ! empty( $vg_missing_core ) ) : ?>
7640 + <div class="vigilante-file-list">
7641 + <h3><?php esc_html_e( 'Missing Files', 'vigilante' ); ?></h3>
7642 + <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>
7643 + <ul style="margin: 8px 0 12px 18px; list-style: disc;">
7644 + <?php foreach ( array_slice( $vg_missing_core, 0, 20 ) as $vg_missing_file ) : ?>
7645 + <li><code><?php echo esc_html( $vg_missing_file ); ?></code></li>
7646 + <?php endforeach; ?>
7647 + </ul>
7648 + <?php if ( count( $vg_missing_core ) > 20 ) : ?>
7649 + <p class="description">
7650 + <?php
7651 + /* translators: %d: Number of additional files */
7652 + printf( esc_html__( '... and %d more', 'vigilante' ), (int) ( count( $vg_missing_core ) - 20 ) );
7653 + ?>
7654 + </p>
7655 + <?php endif; ?>
7656 + </div>
7657 + <?php endif; ?>
7658 +
6137 7659 <?php if ( ! empty( $last_results['suspicious'] ) ) : ?>
6138 7660 <div class="vigilante-file-list vigilante-suspicious-files vigilante-paginated-section" data-bulk-mode="ignore">
6139 7661 <h3 style="color: #d63638;"><?php esc_html_e( 'Suspicious Files', 'vigilante' ); ?></h3>
6140 - <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>
7662 + <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>
7663 + <?php $this->render_fi_guidance( $last_results['suspicious'] ); ?>
6141 7664 <div class="vigilante-fi-bulk-bar">
6142 7665 <button type="button" class="button vigilante-bulk-ignore" disabled><?php esc_html_e( 'Ignore selected', 'vigilante' ); ?></button>
6143 7666 <span class="vigilante-fi-bulk-count" aria-live="polite"></span>
6144 7667 </div>
@@ -6172,8 +7695,14 @@
6172 7695 }
6173 7696 } else {
6174 7697 $file_path = (string) $item;
6175 7698 }
7699 + if ( 'vigilante_self' === $file_type ) {
7700 + $file_type = __( 'Vigilant (self)', 'vigilante' );
7701 + } else {
7702 + // A name a person can read, instead of the internal slug.
7703 + $file_type = Vigilante_File_Integrity::finding_guidance( $file_type )['label'];
7704 + }
6176 7705 ?>
6177 7706 <tr>
6178 7707 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
6179 7708 <td><code style="color: #d63638;"><?php echo esc_html( $file_path ); ?></code></td>
@@ -6191,9 +7720,10 @@
6191 7720
6192 7721 <?php if ( ! empty( $last_results['extra'] ) ) : ?>
6193 7722 <div class="vigilante-file-list vigilante-extra-files vigilante-paginated-section" data-bulk-mode="ignore">
6194 7723 <h3 style="color: #b32d2e;"><?php esc_html_e( 'Extra Files', 'vigilante' ); ?></h3>
6195 - <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>
7724 + <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>
7725 + <?php $this->render_fi_guidance( $last_results['extra'] ); ?>
6196 7726 <div class="vigilante-fi-bulk-bar">
6197 7727 <button type="button" class="button vigilante-bulk-ignore" disabled><?php esc_html_e( 'Ignore selected', 'vigilante' ); ?></button>
6198 7728 <span class="vigilante-fi-bulk-count" aria-live="polite"></span>
6199 7729 </div>
@@ -6213,8 +7743,14 @@
6213 7743 foreach ( $last_results['extra'] as $item ) {
6214 7744 $file_path = is_array( $item ) ? ( $item['file'] ?? '' ) : (string) $item;
6215 7745 $file_reason = is_array( $item ) ? ( $item['reason'] ?? __( 'Unknown', 'vigilante' ) ) : __( 'Unknown', 'vigilante' );
6216 7746 $file_type = is_array( $item ) ? ( $item['type'] ?? 'unknown' ) : 'unknown';
7747 + if ( 'vigilante_self' === $file_type ) {
7748 + $file_type = __( 'Vigilant (self)', 'vigilante' );
7749 + } else {
7750 + // A name a person can read, instead of the internal slug.
7751 + $file_type = Vigilante_File_Integrity::finding_guidance( $file_type )['label'];
7752 + }
6217 7753 ?>
6218 7754 <tr>
6219 7755 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
6220 7756 <td><code style="color: #b32d2e;"><?php echo esc_html( $file_path ); ?></code></td>
@@ -6245,8 +7781,19 @@
6245 7781 $regular_modified[] = $item;
6246 7782 }
6247 7783 }
6248 7784 }
7785 + // A missing file of Vigilant is critical, and the missing
7786 + // files of the generic scan have no table: it is listed with
7787 + // the modified files, or the tab said "All files passed" with
7788 + // a module deleted.
7789 + if ( $last_results && ! empty( $last_results['missing'] ) && is_array( $last_results['missing'] ) ) {
7790 + foreach ( $last_results['missing'] as $item ) {
7791 + if ( is_array( $item ) && 'vigilante_self' === ( $item['type'] ?? '' ) ) {
7792 + $regular_modified[] = $item;
7793 + }
7794 + }
7795 + }
6249 7796 ?>
6250 7797
6251 7798 <?php if ( ! empty( $critical_modified ) ) : ?>
6252 7799 <div class="vigilante-file-list vigilante-critical-config-files">
@@ -6251,9 +7798,9 @@
6251 7798 <?php if ( ! empty( $critical_modified ) ) : ?>
6252 7799 <div class="vigilante-file-list vigilante-critical-config-files">
6253 7800 <h3 style="color: #e36210;"><?php esc_html_e( 'Critical config files modified', 'vigilante' ); ?></h3>
6254 7801 <p class="description">
6255 - <?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' ); ?>
7802 + <?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' ); ?>
6256 7803 </p>
6257 7804 <table class="wp-list-table widefat fixed striped">
6258 7805 <thead>
6259 7806 <tr>
@@ -6270,9 +7817,15 @@
6270 7817 $crit_diff = $crit_item['diff'] ?? array();
6271 7818 $crit_id = sanitize_html_class( $crit_file );
6272 7819 $added_count = is_array( $crit_diff ) ? count( $crit_diff['added'] ?? array() ) : 0;
6273 7820 $removed_count = is_array( $crit_diff ) ? count( $crit_diff['removed'] ?? array() ) : 0;
6274 - $diff_unavailable = is_array( $crit_diff ) && ! empty( $crit_diff['unavailable'] );
7821 + // The lines of a shared file are for whoever approves it. Results
7822 + // stored before 2.11.8 on the main site still carry them, so the
7823 + // screen asks too, not only the scan that wrote them.
7824 + $diff_network = ( is_array( $crit_diff ) && ! empty( $crit_diff['network'] ) ) || $this->critical_approval_locked();
7825 + $diff_rescan = is_array( $crit_diff ) && ! empty( $crit_diff['rescan'] );
7826 + $diff_redaction = is_array( $crit_diff ) && ! empty( $crit_diff['redaction'] );
7827 + $diff_unavailable = $diff_network || ( is_array( $crit_diff ) && ! empty( $crit_diff['unavailable'] ) );
6275 7828 ?>
6276 7829 <tr>
6277 7830 <td><code style="color: #e36210;"><?php echo esc_html( $crit_file ); ?></code></td>
6278 7831 <td>
@@ -6295,18 +7848,36 @@
6295 7848 <td>
6296 7849 <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' ); ?>">
6297 7850 <?php esc_html_e( 'Review changes', 'vigilante' ); ?>
6298 7851 </button>
6299 - <button type="button" class="button button-small button-primary vigilante-approve-critical-file" data-file="<?php echo esc_attr( $crit_file ); ?>">
6300 - <?php esc_html_e( 'Approve', 'vigilante' ); ?>
6301 - </button>
7852 + <?php if ( $this->critical_approval_locked() ) : ?>
7853 + <span class="description" style="display:block;margin-top:4px;">
7854 + <?php echo esc_html( $this->critical_approval_notice() ); ?>
7855 + </span>
7856 + <?php else : ?>
7857 + <button type="button" class="button button-small button-primary vigilante-approve-critical-file" data-file="<?php echo esc_attr( $crit_file ); ?>">
7858 + <?php esc_html_e( 'Approve', 'vigilante' ); ?>
7859 + </button>
7860 + <?php endif; ?>
6302 7861 </td>
6303 7862 </tr>
6304 7863 <tr id="vigilante-critical-content-<?php echo esc_attr( $crit_id ); ?>" class="vigilante-critical-content-row" style="display:none;">
6305 7864 <td colspan="3" style="padding: 0;">
6306 7865 <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;">
6307 - <?php if ( $diff_unavailable ) : ?>
7866 + <?php if ( $diff_network ) : ?>
6308 7867 <p style="color: #50575e; font-style: italic; margin: 0;">
7868 + <?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' ); ?>
7869 + </p>
7870 + <?php elseif ( $diff_rescan ) : ?>
7871 + <p style="color: #50575e; font-style: italic; margin: 0;">
7872 + <?php esc_html_e( 'Run a new scan to see the line changes of this file.', 'vigilante' ); ?>
7873 + </p>
7874 + <?php elseif ( $diff_redaction ) : ?>
7875 + <p style="color: #50575e; font-style: italic; margin: 0;">
7876 + <?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' ); ?>
7877 + </p>
7878 + <?php elseif ( $diff_unavailable ) : ?>
7879 + <p style="color: #50575e; font-style: italic; margin: 0;">
6309 7880 <?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' ); ?>
6310 7881 </p>
6311 7882 <?php elseif ( empty( $crit_diff['added'] ) && empty( $crit_diff['removed'] ) ) : ?>
6312 7883 <p style="color: #50575e; font-style: italic; margin: 0;">
@@ -6334,9 +7905,9 @@
6334 7905 <?php endif; ?>
6335 7906
6336 7907 <?php if ( $has_closed ) : ?>
6337 7908 <div class="vigilante-file-list vigilante-closed-plugins">
6338 - <h3 style="color: #d63638;"><?php esc_html_e( 'Closed + Removed Plugins', 'vigilante' ); ?></h3>
7909 + <h3 id="vigilante-section-fi-closed-plugins" style="color: #d63638;"><?php esc_html_e( 'Closed + Removed Plugins', 'vigilante' ); ?></h3>
6339 7910 <p class="description" style="color: #d63638;">
6340 7911 <?php esc_html_e( '&#9888; Warning: These plugins have been closed in the WordPress.org repository. Closures usually indicate malware, security issues, guideline violations, or supply chain attacks. Uninstall and replace as soon as possible.', 'vigilante' ); ?>
6341 7912 </p>
6342 7913 <table class="wp-list-table widefat striped">
@@ -6390,9 +7961,10 @@
6390 7961
6391 7962 <?php if ( ! empty( $regular_modified ) ) : ?>
6392 7963 <div class="vigilante-file-list vigilante-paginated-section" data-bulk-mode="ignore">
6393 7964 <h3><?php esc_html_e( 'Modified Files', 'vigilante' ); ?></h3>
6394 - <p class="description"><?php esc_html_e( 'These files (apparently) differ from the original WordPress or plugin versions.', 'vigilante' ); ?></p>
7965 + <p class="description"><?php esc_html_e( 'These files do not match the copy WordPress.org publishes for the installed version.', 'vigilante' ); ?></p>
7966 + <?php $this->render_fi_guidance( $regular_modified ); ?>
6395 7967 <div class="vigilante-fi-bulk-bar">
6396 7968 <button type="button" class="button vigilante-bulk-ignore" disabled><?php esc_html_e( 'Ignore selected', 'vigilante' ); ?></button>
6397 7969 <span class="vigilante-fi-bulk-count" aria-live="polite"></span>
6398 7970 </div>
@@ -6421,8 +7993,14 @@
6421 7993 }
6422 7994 } else {
6423 7995 $file_path = (string) $item;
6424 7996 }
7997 + if ( 'vigilante_self' === $file_type ) {
7998 + $file_type = __( 'Vigilant (self)', 'vigilante' );
7999 + } else {
8000 + // A name a person can read, instead of the internal slug.
8001 + $file_type = Vigilante_File_Integrity::finding_guidance( $file_type )['label'];
8002 + }
6425 8003 ?>
6426 8004 <tr>
6427 8005 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
6428 8006 <td><code><?php echo esc_html( $file_path ); ?></code></td>
@@ -6436,11 +8014,18 @@
6436 8014 </table>
6437 8015 </div>
6438 8016 <?php endif; ?>
6439 8017
6440 - <?php if ( $last_results && empty( $last_results['modified'] ) && empty( $last_results['suspicious'] ) && empty( $last_results['extra'] ) && ! $has_closed ) : ?>
8018 + <?php
8019 + // "All files passed" is about every file, and Vigilant's own
8020 + // are files too: with the self-protection box in red or amber
8021 + // above, this line contradicted it (its findings no longer
8022 + // travel in the tables below).
8023 + $vg_self_alarm = in_array( $this->self_integrity_summary( $options )['tone'], array( 'critical', 'off', 'warning' ), true );
8024 + ?>
8025 + <?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 ) ) : ?>
6441 8026 <p class="vigilante-all-clear" style="color: #00a32a; font-weight: bold;">
6442 - <?php esc_html_e( 'Good Job! All files passed integrity check. No issues found.', 'vigilante' ); ?>
8027 + <?php esc_html_e( 'Good job! Nothing to report in the files that were checked.', 'vigilante' ); ?>
6443 8028 </p>
6444 8029 <?php endif; ?>
6445 8030 <?php endif; ?>
6446 8031 </div>
@@ -6489,9 +8074,9 @@
6489 8074
6490 8075 <?php if ( ! empty( $ignored_files ) ) : ?>
6491 8076 <div id="vigilante-section-fi-ignored" class="vigilante-settings-section">
6492 8077 <h2><?php esc_html_e( 'Ignored Files', 'vigilante' ); ?></h2>
6493 - <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>
8078 + <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>
6494 8079 <div class="vigilante-paginated-section" data-bulk-mode="unignore">
6495 8080 <div class="vigilante-fi-bulk-bar">
6496 8081 <button type="button" class="button vigilante-bulk-unignore" disabled><?php esc_html_e( 'Stop ignoring selected', 'vigilante' ); ?></button>
6497 8082 <span class="vigilante-fi-bulk-count" aria-live="polite"></span>
@@ -6580,9 +8165,9 @@
6580 8165 } elseif ( isset( $_POST['data'] ) ) {
6581 8166 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
6582 8167 $raw_data = wp_unslash( $_POST['data'] );
6583 8168 parse_str( $raw_data, $data );
6584 - $data = map_deep( $data, 'sanitize_textarea_field' );
8169 + $data = $this->sanitize_posted_settings( $data );
6585 8170 } else {
6586 8171 $data = array();
6587 8172 }
6588 8173
@@ -6642,10 +8227,30 @@
6642 8227
6643 8228 // Read ONLY saved options from database (not merged with defaults)
6644 8229 $saved_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
6645 8230
6646 - $rejected_ips = array();
8231 + // What is stored before this request changes anything: the shared file
8232 + // settings this user may not change are put back from here (2.11.6).
8233 + $stored_options = $saved_options;
8234 + $locked = Vigilante_Settings::get_locked_file_settings();
6647 8235
8236 + if ( isset( $locked[ $section ] ) && true === $locked[ $section ] ) {
8237 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
8238 + }
8239 +
8240 + // A module switch is a single key, so refusing says more than a success
8241 + // that changed nothing, and the dashboard puts the toggle back.
8242 + if ( 'modules' === $section && isset( $locked['modules'], $data['modules'] ) && is_array( $locked['modules'] ) && is_array( $data['modules'] ) ) {
8243 + foreach ( array_keys( $data['modules'] ) as $vg_module ) {
8244 + if ( in_array( sanitize_key( $vg_module ), $locked['modules'], true ) ) {
8245 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
8246 + }
8247 + }
8248 + }
8249 +
8250 + $rejected_ips = array();
8251 + $rejected_proxies = array();
8252 +
6648 8253 // Handle modules
6649 8254 if ( 'modules' === $section && isset( $data['modules'] ) ) {
6650 8255 if ( ! isset( $saved_options['modules'] ) ) {
6651 8256 $saved_options['modules'] = array();
@@ -6669,9 +8274,9 @@
6669 8274 // went straight into the option. An entry the matcher can never
6670 8275 // match still sits in a security list looking like protection,
6671 8276 // so the ones that cannot match are dropped and reported back
6672 8277 // instead of being stored in silence.
6673 - $rejected_ips = $this->filter_ip_lists( $section, $processed );
8278 + $rejected_ips = $this->filter_ip_lists( $section, $processed, $rejected_proxies );
6674 8279
6675 8280 // Save the processed section
6676 8281 $saved_options[ $section ] = $processed;
6677 8282
@@ -6682,8 +8287,10 @@
6682 8287
6683 8288 // Clear cache before saving
6684 8289 wp_cache_delete( Vigilante_Settings::OPTION_NAME, 'options' );
6685 8290
8291 + $saved_options = Vigilante_Settings::keep_locked_file_settings( $saved_options, $stored_options );
8292 +
6686 8293 // Save to database
6687 8294 update_option( Vigilante_Settings::OPTION_NAME, $saved_options );
6688 8295
6689 8296 // Clear the settings cache
@@ -6745,8 +8352,21 @@
6745 8352 implode( ', ', array_map( 'esc_html', $rejected_ips ) )
6746 8353 );
6747 8354 }
6748 8355
8356 + if ( ! empty( $rejected_proxies ) ) {
8357 + $message .= ' ' . sprintf(
8358 + /* translators: %s: comma separated list of the trusted proxy entries that were not saved. */
8359 + _n(
8360 + 'A trusted proxy must be an exact IP or a CIDR range, not a wildcard, so this entry was not saved: %s',
8361 + 'A trusted proxy must be an exact IP or a CIDR range, not a wildcard, so these entries were not saved: %s',
8362 + count( $rejected_proxies ),
8363 + 'vigilante'
8364 + ),
8365 + implode( ', ', array_map( 'esc_html', $rejected_proxies ) )
8366 + );
8367 + }
8368 +
6749 8369 wp_send_json_success( $message );
6750 8370 }
6751 8371
6752 8372 /**
@@ -6757,9 +8377,21 @@
6757 8377 * @param string $section Section being saved.
6758 8378 * @param array $processed Section data, edited in place.
6759 8379 * @return array Entries that were dropped, for the message back to the user.
6760 8380 */
6761 - private function filter_ip_lists( $section, &$processed ) {
8381 + private function filter_ip_lists( $section, &$processed, &$rejected_proxies = array() ) {
8382 + $rejected_proxies = array();
8383 +
8384 + // Trusted proxies feed an identity decision, so only exact addresses and
8385 + // CIDR ranges belong there: a wildcard is stripped with its own message,
8386 + // never stored looking effective. The matcher ignores it anyway (see
8387 + // Vigilante_IP_Utils::in_list_ip_or_cidr), this stops it persisting.
8388 + if ( 'firewall' === $section && isset( $processed['trusted_proxies'] ) && is_array( $processed['trusted_proxies'] ) ) {
8389 + $split = Vigilante_IP_Utils::split_list_ip_or_cidr( $processed['trusted_proxies'] );
8390 + $processed['trusted_proxies'] = $split['valid'];
8391 + $rejected_proxies = $split['rejected'];
8392 + }
8393 +
6762 8394 $lists = array(
6763 8395 'firewall' => array( 'ip_whitelist', 'ip_blacklist' ),
6764 8396 'login_security' => array( 'ip_whitelist' ),
6765 8397 );
@@ -6925,8 +8557,48 @@
6925 8557 return array( 'sent' => $sent, 'failed' => $failed );
6926 8558 }
6927 8559
6928 8560 /**
8561 + * Sanitize the settings a tab posted, once the serialized form is parsed
8562 + *
8563 + * Every value goes through sanitize_textarea_field() except the lists of
8564 + * file paths (Vigilante_Settings::path_list_keys()). That one deletes every
8565 + * %XX octet and, when it finds one, collapses repeated spaces, so an
8566 + * exclusion for a folder called foto%20x was saved as fotox and excluded
8567 + * nothing (measured on 1 oct 2026 against 3.0.1). The paths are read as
8568 + * paths before the generic sanitizer runs and put back after it.
8569 + *
8570 + * @since 3.0.2
8571 + *
8572 + * @param array $data Parsed form: section => key => value.
8573 + * @return array
8574 + */
8575 + private function sanitize_posted_settings( $data ) {
8576 + $paths = Vigilante_Settings::take_path_lists( $data );
8577 + $data = map_deep( $data, 'sanitize_textarea_field' );
8578 +
8579 + return Vigilante_Settings::restore_path_lists( $data, $paths );
8580 + }
8581 +
8582 + /**
8583 + * Sanitize an imported settings file, once it is decoded
8584 + *
8585 + * Same rule as sanitize_posted_settings(): the lists of paths keep what the
8586 + * export wrote, so an exclusion survives an export and an import.
8587 + *
8588 + * @since 3.0.2
8589 + *
8590 + * @param array $imported Decoded export.
8591 + * @return array
8592 + */
8593 + private function sanitize_imported_settings( $imported ) {
8594 + $paths = Vigilante_Settings::take_path_lists( $imported );
8595 + $imported = map_deep( $imported, 'sanitize_text_field' );
8596 +
8597 + return Vigilante_Settings::restore_path_lists( $imported, $paths );
8598 + }
8599 +
8600 + /**
6929 8601 * Process section data maintaining proper types from defaults
6930 8602 *
6931 8603 * @param array $submitted_data Data submitted from form.
6932 8604 * @param array $defaults Default values for this section.
@@ -6934,16 +8606,51 @@
6934 8606 * @param string $section_name Section name for special handling.
6935 8607 * @return array Processed data.
6936 8608 */
6937 8609 private function process_section_data( $submitted_data, $defaults, $current, $section_name = '' ) {
6938 - // Start with defaults, then merge current saved values
8610 + // Start with defaults, then merge current saved values. Lists are put
8611 + // back whole afterwards: array_replace_recursive() merges them index by
8612 + // index, so a saved list shorter than the default kept the default's
8613 + // tail (see below, and Vigilante_Settings::array_merge_deep()).
6939 8614 $result = array_replace_recursive( $defaults, $current );
6940 -
8615 + $result = $this->restore_lists( $result, $current );
8616 +
6941 8617 // Process each submitted value
6942 8618 foreach ( $submitted_data as $key => $value ) {
6943 8619 $key = sanitize_key( $key );
6944 -
6945 - if ( is_array( $value ) ) {
8620 +
8621 + if ( is_array( $value ) && $this->is_list_field( $key, $value, $defaults, $current ) ) {
8622 + /*
8623 + * A list of values, not a group of settings: the roles of a
8624 + * checkbox group, the excluded user ids. What the form sent IS
8625 + * the list, so it replaces the stored one outright.
8626 + *
8627 + * Until 3.0.1 this went through the recursion below, which
8628 + * writes position by position and left every stored entry the
8629 + * new list was too short to overwrite. Unchecking Editor in
8630 + * "Enforce for roles" saved administrator over position 0 and
8631 + * kept editor in position 1, so the box came back ticked
8632 + * (reported on the support forum for 3.0.0, and the same bug
8633 + * merge_preset() was fixed for in 2.9.8).
8634 + *
8635 + * Keys are dropped on purpose: a list is data, so anything the
8636 + * request tried to name is not carried over. The empty string
8637 + * is dropped too, which is what the hidden field that marks an
8638 + * empty group sends.
8639 + */
8640 + $list = array();
8641 + foreach ( $value as $entry ) {
8642 + if ( ! is_scalar( $entry ) ) {
8643 + continue;
8644 + }
8645 + $entry = sanitize_text_field( (string) $entry );
8646 + if ( '' === $entry ) {
8647 + continue;
8648 + }
8649 + $list[] = $entry;
8650 + }
8651 + $result[ $key ] = $list;
8652 + } elseif ( is_array( $value ) ) {
6946 8653 // Nested array (like rate_limiting)
6947 8654 $nested_defaults = isset( $defaults[ $key ] ) && is_array( $defaults[ $key ] ) ? $defaults[ $key ] : array();
6948 8655 $nested_current = isset( $result[ $key ] ) && is_array( $result[ $key ] ) ? $result[ $key ] : array();
6949 8656 $result[ $key ] = $this->process_section_data( $value, $nested_defaults, $nested_current, $key );
@@ -7029,10 +8736,26 @@
7029 8736 }
7030 8737 }
7031 8738
7032 8739 if ( $is_value_list ) {
7033 - // All items removed - reset to empty array
7034 - $result[ $key ] = array();
8740 + /*
8741 + * A list whose key did not travel is left as it was. Until
8742 + * 3.0.1 it was blanked here, which read "the user unticked
8743 + * everything" into something the form never asked about:
8744 + * saving the Firewall tab blanked allowed_http_methods,
8745 + * which has no field on any screen, and saving Users blanked
8746 + * insecure_usernames. Both survived only because reading the
8747 + * option put the defaults back on top, and that is exactly
8748 + * the merge being removed in this release: with lists
8749 + * replaced whole, blanking here would have left a site
8750 + * answering 403 to every HTTP method.
8751 + *
8752 + * Emptying a group is still possible, and is now said out
8753 + * loud: each checkbox group prints a hidden field with an
8754 + * empty value, so an empty group arrives as an empty list
8755 + * instead of as silence. See list_sentinel().
8756 + */
8757 + continue;
7035 8758 } else {
7036 8759 // Nested settings group - handle boolean children
7037 8760 foreach ( $default_value as $nested_key => $nested_default ) {
7038 8761 if ( is_bool( $nested_default ) && isset( $result[ $key ] ) && is_array( $result[ $key ] ) ) {
@@ -7051,8 +8774,106 @@
7051 8774 return $result;
7052 8775 }
7053 8776
7054 8777 /**
8778 + * Whether a submitted value is a list of values rather than a group of settings
8779 + *
8780 + * The difference decides whether what arrived replaces the stored value or
8781 + * is merged into it key by key, so it is answered from what the setting is
8782 + * (its default, or what is stored today), not from the shape of the
8783 + * request. A request that sends named keys where a list belongs gets them
8784 + * dropped, which is one way this is narrower than what it replaces.
8785 + *
8786 + * @since 3.0.1
8787 + *
8788 + * @param string $key Setting key, already through sanitize_key().
8789 + * @param array $value Value as submitted.
8790 + * @param array $defaults Defaults of the section being processed.
8791 + * @param array $current Stored values of the section being processed.
8792 + * @return bool
8793 + */
8794 + private function is_list_field( $key, $value, $defaults, $current ) {
8795 + $default_known = array_key_exists( $key, $defaults ) && is_array( $defaults[ $key ] );
8796 +
8797 + // The default has the first and the last word, the same rule that
8798 + // array_merge_deep() and restore_lists() follow. Letting the stored
8799 + // value decide, as this did until the fourth cross review of 3.0.1,
8800 + // disagrees with them where it matters: an option corrupted into
8801 + // two_factor = array( 'a', 'b' ) would have been treated as a list and
8802 + // saving the tab would have replaced the whole group of settings with
8803 + // the two values that arrived.
8804 + if ( $default_known && array() !== $defaults[ $key ] ) {
8805 + return Vigilante_Settings::is_list( $defaults[ $key ] );
8806 + }
8807 +
8808 + // Nothing declares it either way (an empty default, or a key with no
8809 + // default at all), so what is stored, and then what was submitted, is
8810 + // all there is to go on.
8811 + if ( isset( $current[ $key ] ) && is_array( $current[ $key ] ) && array() !== $current[ $key ] && Vigilante_Settings::is_list( $current[ $key ] ) ) {
8812 + return true;
8813 + }
8814 +
8815 + return Vigilante_Settings::is_list( $value );
8816 + }
8817 +
8818 + /**
8819 + * Put stored lists back after array_replace_recursive()
8820 + *
8821 + * array_replace_recursive() merges a list by position, so a stored list
8822 + * shorter than the default comes out with the default's tail attached:
8823 + * ['administrator'] over ['administrator','editor'] gives both back. Every
8824 + * list that is stored is therefore restored whole before anything else is
8825 + * processed, and only associative arrays are walked into.
8826 + *
8827 + * @since 3.0.1
8828 + *
8829 + * @param array $result Defaults with the stored values merged over them.
8830 + * @param array $current Stored values.
8831 + * @return array
8832 + */
8833 + private function restore_lists( $result, $current ) {
8834 + if ( ! is_array( $current ) ) {
8835 + return $result;
8836 + }
8837 +
8838 + foreach ( $current as $key => $value ) {
8839 + if ( ! is_array( $value ) || ! isset( $result[ $key ] ) || ! is_array( $result[ $key ] ) ) {
8840 + continue;
8841 + }
8842 +
8843 + // Decided by what is in $result, which comes from the defaults, and
8844 + // never by the stored value: array() is a list by any definition, so
8845 + // a section stored empty would have replaced a whole group of
8846 + // settings with nothing. Same reasoning as in array_merge_deep().
8847 + if ( Vigilante_Settings::is_list( $result[ $key ] ) ) {
8848 + $result[ $key ] = $value;
8849 + continue;
8850 + }
8851 +
8852 + $result[ $key ] = $this->restore_lists( $result[ $key ], $value );
8853 + }
8854 +
8855 + return $result;
8856 + }
8857 +
8858 + /**
8859 + * Print the hidden field that lets a checkbox group arrive empty
8860 + *
8861 + * A checkbox group sends nothing at all when every box is unticked, which
8862 + * is indistinguishable from a form that never had that field. Since 3.0.1
8863 + * the save path leaves an absent list alone, so the difference has to be
8864 + * stated: this empty entry always travels, and the save path drops empty
8865 + * strings, so an untouched group arrives as an empty list.
8866 + *
8867 + * @since 3.0.1
8868 + *
8869 + * @param string $name Field name without the trailing [].
8870 + */
8871 + private function list_sentinel( $name ) {
8872 + printf( '<input type="hidden" name="%s[]" value="">', esc_attr( $name ) );
8873 + }
8874 +
8875 + /**
7055 8876 * AJAX: Export settings
7056 8877 */
7057 8878 public function ajax_export_settings() {
7058 8879 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
@@ -7104,15 +8925,29 @@
7104 8925 wp_send_json_error( __( 'Invalid JSON format.', 'vigilante' ) );
7105 8926 }
7106 8927
7107 8928 // Sanitize imported data recursively
7108 - $imported = map_deep( $imported, 'sanitize_text_field' );
8929 + $imported = $this->sanitize_imported_settings( $imported );
7109 8930
7110 - // Validate structure
7111 - $defaults = $this->settings->get_default_options();
7112 - $merged = array_replace_recursive( $defaults, $imported );
8931 + // Validate structure: only sections and keys of the schema survive, and
8932 + // every value takes the type of its default. Until 2.11.0 this was an
8933 + // array_replace_recursive() of the file over the defaults, so any key in
8934 + // the file, known or not, landed in vigilante_options (S7). Sections
8935 + // the file does not carry keep their defaults; a section it does carry
8936 + // replaces the default one whole, because validate_options() has
8937 + // already filled in whatever the file left out.
8938 + $defaults = $this->settings->get_default_options();
8939 + $validated = $this->settings->validate_options( $imported );
8940 + $merged = $defaults;
7113 8941
8942 + foreach ( $validated as $section => $data ) {
8943 + if ( is_array( $data ) ) {
8944 + $merged[ $section ] = $data;
8945 + }
8946 + }
8947 +
7114 8948 // Save
8949 + $merged = Vigilante_Settings::keep_locked_file_settings( $merged, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7115 8950 update_option( Vigilante_Settings::OPTION_NAME, $merged );
7116 8951 $this->settings->clear_cache();
7117 8952
7118 8953 // Re-evaluate the active preset marker. The imported config may match
@@ -7135,9 +8970,9 @@
7135 8970 if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
7136 8971 wp_schedule_single_event( time() + 5, 'vigilante_under_attack_post_scan' );
7137 8972 }
7138 8973
7139 - wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) );
8974 + wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
7140 8975 }
7141 8976
7142 8977 /**
7143 8978 * Detect whether a vigilante_options array matches a known preset.
@@ -7240,9 +9075,11 @@
7240 9075 $preset = isset( $_POST['preset'] ) ? sanitize_key( $_POST['preset'] ) : '';
7241 9076
7242 9077 // Handle reset to defaults
7243 9078 if ( 'reset' === $preset ) {
7244 - $defaults = Vigilante_Settings::get_defaults_preserving_user_data( get_option( Vigilante_Settings::OPTION_NAME, array() ) );
9079 + $stored_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
9080 + $defaults = Vigilante_Settings::get_defaults_preserving_user_data( $stored_options );
9081 + $defaults = Vigilante_Settings::keep_locked_file_settings( $defaults, $stored_options );
7245 9082 update_option( Vigilante_Settings::OPTION_NAME, $defaults );
7246 9083 $this->settings->clear_cache();
7247 9084
7248 9085 // Clear active preset
@@ -7250,9 +9087,9 @@
7250 9087
7251 9088 // Apply file changes after reset
7252 9089 $this->apply_all_file_changes( $defaults );
7253 9090
7254 - wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) );
9091 + wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) . $this->locked_file_settings_message() );
7255 9092 return;
7256 9093 }
7257 9094
7258 9095 $presets = $this->settings->get_presets();
@@ -7278,8 +9115,9 @@
7278 9115 // invent keys that are missing on both sides.
7279 9116 $current = Vigilante_Settings::merge_preset( $this->settings->get_default_options(), $current );
7280 9117
7281 9118 $merged = Vigilante_Settings::merge_preset( $current, $preset_options );
9119 + $merged = Vigilante_Settings::keep_locked_file_settings( $merged, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7282 9120
7283 9121 update_option( Vigilante_Settings::OPTION_NAME, $merged );
7284 9122 $this->settings->clear_cache();
7285 9123
@@ -7288,9 +9126,9 @@
7288 9126
7289 9127 // Apply file changes after preset
7290 9128 $this->apply_all_file_changes( $merged );
7291 9129
7292 - wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) );
9130 + wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
7293 9131 }
7294 9132
7295 9133 /**
7296 9134 * AJAX: Reset a specific section to defaults
@@ -7325,27 +9163,19 @@
7325 9163 * On a subsite, the settings written to wp-config.php and .htaccess are
7326 9164 * the main site's business. Resetting the local copy of those would only
7327 9165 * make this screen disagree with the file, so they are carried over
7328 9166 * untouched, and a section that is nothing but shared settings is not
7329 - * reset at all.
9167 + * reset at all. On the main site, a user without network rights keeps
9168 + * the ones the shared files are built from as well (2.11.6).
7330 9169 */
7331 - if ( ! Vigilante_Settings::can_write_shared_files() ) {
7332 - $shared = Vigilante_Settings::get_shared_file_settings();
9170 + $locked = Vigilante_Settings::get_locked_file_settings();
7333 9171
7334 - if ( isset( $shared[ $section ] ) ) {
7335 - if ( true === $shared[ $section ] ) {
7336 - wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7337 - }
7338 -
7339 - foreach ( $shared[ $section ] as $shared_key ) {
7340 - if ( array_key_exists( $shared_key, (array) $current_options[ $section ] ) ) {
7341 - $new_values[ $shared_key ] = $current_options[ $section ][ $shared_key ];
7342 - }
7343 - }
7344 - }
9172 + if ( isset( $locked[ $section ] ) && true === $locked[ $section ] ) {
9173 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7345 9174 }
7346 9175
7347 9176 $current_options[ $section ] = $new_values;
9177 + $current_options = Vigilante_Settings::keep_locked_file_settings( $current_options, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7348 9178
7349 9179 // Save
7350 9180 update_option( Vigilante_Settings::OPTION_NAME, $current_options );
7351 9181 $this->settings->clear_cache();
@@ -7428,8 +9258,19 @@
7428 9258 // Save new results
7429 9259 update_option( 'vigilante_last_integrity_scan', time() );
7430 9260 update_option( 'vigilante_last_integrity_results', $results );
7431 9261
9262 + // On the main site the scan does compute the lines of wp-config.php and
9263 + // .htaccess, for the network administrator. Somebody without network
9264 + // rights gets the change and its sizes, not the lines.
9265 + if ( $this->critical_approval_locked() && ! empty( $results['modified'] ) && is_array( $results['modified'] ) ) {
9266 + foreach ( $results['modified'] as $index => $item ) {
9267 + if ( is_array( $item ) && 'critical_config' === ( $item['type'] ?? '' ) ) {
9268 + $results['modified'][ $index ]['diff'] = Vigilante_File_Integrity::network_only_diff();
9269 + }
9270 + }
9271 + }
9272 +
7432 9273 wp_send_json_success( array(
7433 9274 'message' => __( 'Scan completed.', 'vigilante' ),
7434 9275 'results' => $results,
7435 9276 'ignored_count' => count( get_option( 'vigilante_ignored_files', array() ) ),
@@ -7463,11 +9304,50 @@
7463 9304 if ( ! current_user_can( 'manage_options' ) ) {
7464 9305 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
7465 9306 }
7466 9307
9308 + $results = get_option( 'vigilante_last_integrity_results' );
9309 + $scanned_at = get_option( 'vigilante_last_integrity_scan' );
9310 +
7467 9311 delete_option( 'vigilante_last_integrity_results' );
7468 9312 delete_option( 'vigilante_last_integrity_scan' );
7469 9313
9314 + // Starting over also means being told again: the next scan emails
9315 + // whatever it finds, as if nothing had been reported before.
9316 + delete_option( Vigilante_File_Integrity::NOTIFIED_OPTION );
9317 +
9318 + /*
9319 + * A pending change to wp-config.php or the root .htaccess is closed by
9320 + * approving it, which takes the network. Clearing the results was one
9321 + * more way to close it without, until the next scan: the ignore list was
9322 + * shut in 2.11.8 and this button was left open, found by the cross
9323 + * review of 2.11.8. So for somebody who cannot approve, those entries
9324 + * stay and everything else goes.
9325 + */
9326 + // The findings about Vigilant's own files stay too: they report for the
9327 + // whole network, and ignoring them already takes network rights.
9328 + if ( $this->critical_approval_locked() && is_array( $results ) ) {
9329 + $kept = array();
9330 + $found = false;
9331 + foreach ( array( 'modified', 'missing', 'suspicious', 'extra' ) as $bucket ) {
9332 + $kept[ $bucket ] = array_values(
9333 + array_filter(
9334 + isset( $results[ $bucket ] ) && is_array( $results[ $bucket ] ) ? $results[ $bucket ] : array(),
9335 + function ( $item ) {
9336 + return is_array( $item ) && in_array( $item['type'] ?? '', array( 'critical_config', 'vigilante_self' ), true );
9337 + }
9338 + )
9339 + );
9340 + $found = $found || ! empty( $kept[ $bucket ] );
9341 + }
9342 +
9343 + if ( $found ) {
9344 + $results = array_merge( $results, $kept );
9345 + update_option( 'vigilante_last_integrity_results', $results );
9346 + update_option( 'vigilante_last_integrity_scan', $scanned_at ? $scanned_at : time() );
9347 + }
9348 + }
9349 +
7470 9350 if ( $this->database ) {
7471 9351 $this->database->clear_file_hashes();
7472 9352 }
7473 9353
@@ -7487,14 +9367,49 @@
7487 9367 if ( ! current_user_can( 'manage_options' ) ) {
7488 9368 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
7489 9369 }
7490 9370
7491 - $file = isset( $_POST['file'] ) ? sanitize_text_field( wp_unslash( $_POST['file'] ) ) : '';
9371 + /*
9372 + * The path as the scan reported it, which is what the interface sends
9373 + * back. sanitize_text_field() is the wrong reader for it: it deletes
9374 + * every %XX octet and collapses repeated spaces, so a file named
9375 + * foto%20nueva.php was stored as fotonueva.php, the scan compared the
9376 + * list with the paths it finds one by one, never matched, and the
9377 + * warning came back (measured on 1 oct 2026 against 3.0.1). The four
9378 + * handlers of the ignore list read the path the same way.
9379 + *
9380 + * Known limits, found by the cross review, that only a file name with a
9381 + * stray < in it reaches. wp_strip_all_tags() shortens such a name at the
9382 + * < instead of refusing it, so the stored entry can match another file
9383 + * (a.php<b is stored as a.php), and the value ignore_file() returns is
9384 + * not read, so a refusal of its own is answered as success. The fix, if
9385 + * a site ever needs it, is to answer with an error when
9386 + * wp_strip_all_tags() changes what was sent and to check that return
9387 + * value. An entry the old reader stored for such a name (it wrote the
9388 + * < as &lt;) is listed but "Stop ignoring" cannot remove it; "Clear all
9389 + * ignored files" does.
9390 + */
9391 + $file = ( isset( $_POST['file'] ) && is_string( $_POST['file'] ) ) ? wp_strip_all_tags( wp_unslash( $_POST['file'] ) ) : '';
7492 9392
7493 9393 if ( empty( $file ) ) {
7494 9394 wp_send_json_error( __( 'No file specified.', 'vigilante' ) );
7495 9395 }
7496 9396
9397 + // A change to a shared file is closed by approving it, and approving it
9398 + // takes the network. Ignoring it would close the same warning without.
9399 + if ( $this->critical_approval_locked() && in_array( $file, array( 'wp-config.php', '.htaccess' ), true ) ) {
9400 + wp_send_json_error( $this->critical_approval_notice() );
9401 + }
9402 +
9403 + // Vigilant's own files are never ignored, on any site: silencing the
9404 + // check that says the security plugin was changed is the one button
9405 + // an attacker would want on this screen. Since 3.0.0 those findings
9406 + // are not rows of these tables either, so nothing in the interface
9407 + // sends them here; this is the door, not the label.
9408 + if ( Vigilante_Self_Integrity::is_own_file_path( $file ) ) {
9409 + 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' ) );
9410 + }
9411 +
7497 9412 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database );
7498 9413 $file_integrity->ignore_file( $file );
7499 9414
7500 9415 // Also remove the file from stored scan results so UI updates
@@ -7527,9 +9442,9 @@
7527 9442 if ( ! current_user_can( 'manage_options' ) ) {
7528 9443 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
7529 9444 }
7530 9445
7531 - $file = isset( $_POST['file'] ) ? sanitize_text_field( wp_unslash( $_POST['file'] ) ) : '';
9446 + $file = ( isset( $_POST['file'] ) && is_string( $_POST['file'] ) ) ? wp_strip_all_tags( wp_unslash( $_POST['file'] ) ) : '';
7532 9447
7533 9448 if ( empty( $file ) ) {
7534 9449 wp_send_json_error( __( 'No file specified.', 'vigilante' ) );
7535 9450 }
@@ -7558,12 +9473,16 @@
7558 9473 if ( ! is_array( $raw_files ) ) {
7559 9474 wp_send_json_error( __( 'Invalid request.', 'vigilante' ) );
7560 9475 }
7561 9476
7562 - $files = array();
9477 + $files = array();
9478 + $locked = $this->critical_approval_locked();
9479 + $shared = $locked ? array( 'wp-config.php', '.htaccess' ) : array();
7563 9480 foreach ( $raw_files as $f ) {
7564 - $clean = sanitize_text_field( $f );
7565 - if ( '' !== $clean ) {
9481 + $clean = is_string( $f ) ? wp_strip_all_tags( $f ) : '';
9482 + // Same rule as ajax_ignore_file(): the two shared files when the
9483 + // network locks them, and Vigilant's own files always.
9484 + if ( '' !== $clean && ! in_array( $clean, $shared, true ) && ! Vigilante_Self_Integrity::is_own_file_path( $clean ) ) {
7566 9485 $files[] = $clean;
7567 9486 }
7568 9487 }
7569 9488
@@ -7627,9 +9546,9 @@
7627 9546 }
7628 9547
7629 9548 $files = array();
7630 9549 foreach ( $raw_files as $f ) {
7631 - $clean = sanitize_text_field( $f );
9550 + $clean = is_string( $f ) ? wp_strip_all_tags( $f ) : '';
7632 9551 if ( '' !== $clean ) {
7633 9552 $files[] = $clean;
7634 9553 }
7635 9554 }
@@ -7751,13 +9670,99 @@
7751 9670 if ( ! current_user_can( 'manage_options' ) ) {
7752 9671 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
7753 9672 }
7754 9673
7755 - // Get security grade from settings (not from actual HTTP request)
7756 - $security_headers = new Vigilante_Security_Headers( $this->settings );
7757 - $results = $security_headers->get_security_grade();
9674 + $results = $this->headers_test_results();
9675 + if ( is_wp_error( $results ) ) {
9676 + wp_send_json_error( $results->get_error_message() );
9677 + }
7758 9678
7759 9679 wp_send_json_success( $results );
9680 + }
9681 +
9682 + /**
9683 + * What the Test Headers button shows: the headers that reach the browser
9684 + *
9685 + * Asks the home page and runs the header checks of Security Check on the
9686 + * answer, so the two can never say different things about the same site.
9687 + * Until 3.0.4 the button read the settings of the tab, graded them on a
9688 + * scale of its own and listed them as enabled headers: on a server that
9689 + * does not apply the rules of Vigilant it showed a B while Security
9690 + * Check, which does look, said that no header arrived.
9691 + *
9692 + * @since 3.0.5
9693 + *
9694 + * The lists carry text read from the answer of the site (the value of a
9695 + * header), so whoever prints them escapes them: displayHeaderResults()
9696 + * in assets/js/admin.js.
9697 + *
9698 + * They travel as passed, failing and notes. The names they had until
9699 + * 3.0.4 (headers, missing and warnings) stay in the answer, empty: a
9700 + * page that was open when the plugin was updated still runs the script
9701 + * it loaded, which prints those three without escaping them, and it must
9702 + * get the grade and no text read from the site.
9703 + *
9704 + * @return array|WP_Error grade, score (0 to 100), passed, failing and notes.
9705 + */
9706 + private function headers_test_results() {
9707 + if ( ! class_exists( 'Vigilante_Security_Analyzer' ) ) {
9708 + require_once VIGILANTE_INCLUDES_DIR . 'class-security-analyzer.php';
9709 + }
9710 + // The constructor loads the checks and their helpers.
9711 + new Vigilante_Security_Analyzer( $this->settings, $this->activity_log );
9712 + $no_answer = new WP_Error( 'vigilante_no_answer', __( 'Could not fetch the homepage to read response headers.', 'vigilante' ) );
9713 + // A new request every time: the button is pressed right after saving.
9714 + // With no answer there is nothing to grade, and the checks would ask
9715 + // for the page a second time.
9716 + if ( null === Vigilante_SA_Helpers::probe_home( true ) ) {
9717 + return $no_answer;
9718 + }
9719 +
9720 + $category = new Vigilante_SA_Category_Headers( $this->settings );
9721 + $passed = array();
9722 + $failed = array();
9723 + $notes = array();
9724 + $earned = 0;
9725 + $max = 0;
9726 +
9727 + foreach ( $category->run( 'slow' ) as $check ) {
9728 + if ( Vigilante_SA_Check_Result::STATE_SKIP === $check->state ) {
9729 + continue;
9730 + }
9731 + if ( Vigilante_SA_Check_Result::STATE_INFO === $check->state ) {
9732 + $notes[] = $check->detail;
9733 + continue;
9734 + }
9735 + $earned += (int) $check->score;
9736 + $max += (int) $check->max;
9737 + // The three lists are the three counters of Security Check.
9738 + if ( Vigilante_SA_Check_Result::STATE_PASS === $check->state ) {
9739 + $passed[] = $check->detail;
9740 + } elseif ( Vigilante_SA_Check_Result::STATE_WARN === $check->state ) {
9741 + $notes[] = $check->detail;
9742 + } else {
9743 + $failed[] = $check->detail;
9744 + }
9745 + }
9746 +
9747 + if ( $max < 1 ) {
9748 + return $no_answer;
9749 + }
9750 +
9751 + $score = (int) round( 100 * $earned / $max );
9752 + $sender = new Vigilante_Security_Headers( $this->settings );
9753 +
9754 + return array(
9755 + 'grade' => Vigilante_SA_Helpers::grade_for_score( $score ),
9756 + 'score' => $score,
9757 + 'passed' => $passed,
9758 + 'failing' => $failed,
9759 + 'notes' => array_merge( $notes, $sender->get_csp_warnings() ),
9760 + // For the script of a page opened before the update: see above.
9761 + 'headers' => array(),
9762 + 'missing' => array(),
9763 + 'warnings' => array(),
9764 + );
7760 9765 }
7761 9766
7762 9767 /**
7763 9768 * Sanitize section data (kept for compatibility)