PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.0
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.0
3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
← All changes | admin/class-admin.php +1638 -73 2.9.93.0.0 View file →
@@ -27,8 +27,9 @@
27 27
28 28 use Vigilante_Admin_Ajax;
29 29 use Vigilante_Admin_Analyzer_Ajax;
30 30 use Vigilante_Admin_Audit_Alerts_Ajax;
31 + use Vigilante_Admin_Recovery_Ajax;
31 32
32 33 /**
33 34 * Settings instance
34 35 *
@@ -106,8 +107,11 @@
106 107 add_action( 'admin_init', array( $this, 'redirect_submenu_shortcuts' ) );
107 108 add_action( 'admin_init', array( $this, 'register_settings' ) );
108 109 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
109 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' ) );
110 114
111 115 // Highlight correct submenu based on active tab
112 116 add_filter( 'submenu_file', array( $this, 'highlight_submenu_tab' ) );
113 117
@@ -182,8 +186,13 @@
182 186 add_action( 'wp_ajax_vigilante_analyzer_history', array( $this, 'ajax_analyzer_history' ) );
183 187 add_action( 'wp_ajax_vigilante_analyzer_dismiss_notice', array( $this, 'ajax_analyzer_dismiss_notice' ) );
184 188 add_action( 'wp_ajax_vigilante_analyzer_save_settings', array( $this, 'ajax_analyzer_save_settings' ) );
185 189
190 + // Security Headers settings recovery (2.10.0)
191 + add_action( 'wp_ajax_vigilante_headers_recovery_restore', array( $this, 'ajax_headers_recovery_restore' ) );
192 + add_action( 'wp_ajax_vigilante_headers_recovery_undo', array( $this, 'ajax_headers_recovery_undo' ) );
193 + add_action( 'wp_ajax_vigilante_headers_recovery_dismiss', array( $this, 'ajax_headers_recovery_dismiss' ) );
194 +
186 195 // Shared "Send test email" handler — Notification settings, File Integrity, Audit Alerts (v2.8.0)
187 196 add_action( 'wp_ajax_vigilante_send_test_email', array( $this, 'ajax_send_test_email' ) );
188 197
189 198 // Run migrations on admin load
@@ -193,8 +202,26 @@
193 202 /**
194 203 * Run database migrations based on stored version
195 204 */
196 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 +
197 224 $db_version = get_option( 'vigilante_db_version', '0' );
198 225
199 226 // 1.2.3: Fix IP lists corrupted by sanitize_text_field stripping newlines
200 227 if ( version_compare( $db_version, '1.2.3', '<' ) ) {
@@ -267,9 +294,29 @@
267 294 if ( ! class_exists( 'Vigilante_File_Integrity' ) ) {
268 295 require_once VIGILANTE_INCLUDES_DIR . 'class-file-integrity.php';
269 296 }
270 297 $fi = new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
271 - $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 +
272 319 update_option( 'vigilante_db_version', '1.14.0' );
273 320 }
274 321
275 322 // 2.0.0: Move hide_server_signature and remove_fingerprinting_headers
@@ -361,13 +408,23 @@
361 408 $raw = get_option( Vigilante_Settings::OPTION_NAME, array() );
362 409 $stored = ( is_array( $raw ) && isset( $raw['security_headers'] ) && is_array( $raw['security_headers'] ) ) ? $raw['security_headers'] : array();
363 410 $had_fix = array_key_exists( 'fix_mixed_content', $stored ) ? ! empty( $stored['fix_mixed_content'] ) : true;
364 411
412 + /*
413 + * Merge, never replace. update_section() overwrites the whole
414 + * section, so passing just these two keys wiped every other header
415 + * setting the site had stored (HSTS, CSP, cross-origin policies,
416 + * the HTTPS switches, Server Identity) and left the screen showing
417 + * factory defaults while the .htaccess kept serving the old values.
418 + */
365 419 $this->settings->update_section(
366 420 'security_headers',
367 - array(
368 - 'fix_mixed_content' => $had_fix,
369 - 'upgrade_insecure_requests' => $had_fix,
421 + array_merge(
422 + $stored,
423 + array(
424 + 'fix_mixed_content' => $had_fix,
425 + 'upgrade_insecure_requests' => $had_fix,
426 + )
370 427 )
371 428 );
372 429
373 430 update_option( 'vigilante_db_version', '2.9.8' );
@@ -409,11 +466,232 @@
409 466 }
410 467
411 468 update_option( 'vigilante_db_version', '2.9.9' );
412 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 + }
413 603 }
414 604
415 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 + /**
416 694 * Migration: Remove orphaned email fields from saved options
417 695 *
418 696 * v1.10.0 centralized notification recipients into email section.
419 697 * Old per-module notify_email fields and dead email section fields
@@ -509,17 +787,30 @@
509 787 $pending_count = $this->get_pending_approvals_count();
510 788
511 789 // Get security issues with severity
512 790 $security_status = $this->get_security_status_for_badge();
513 -
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 +
514 805 // Total count for badge
515 - $total_badge = $pending_count + $security_status['count'];
516 -
806 + $total_badge = $pending_count + $security_status['count'] + $self_badge;
807 +
517 808 if ( $total_badge > 0 ) {
518 809 // Determine badge color:
519 810 // - Red (awaiting-mod): pending approvals OR critical modules disabled
520 811 // - Orange (update-plugins): only non-critical modules disabled
521 - 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 ) ) {
522 813 $badge_class = 'awaiting-mod';
523 814 } else {
524 815 $badge_class = 'update-plugins vigilante-badge-warning';
525 816 }
@@ -561,13 +852,21 @@
561 852 'vigilante-activity-log',
562 853 array( $this, 'redirect_to_tab' )
563 854 );
564 855
565 - // 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 + }
566 865 add_submenu_page(
567 866 'vigilante',
568 867 __( 'File Integrity', 'vigilante' ),
569 - __( 'File Integrity', 'vigilante' ),
868 + $fi_menu_title,
570 869 'manage_options',
571 870 'vigilante-file-integrity',
572 871 array( $this, 'redirect_to_tab' )
573 872 );
@@ -681,23 +980,32 @@
681 980 if ( ! did_action( 'plugins_loaded' ) ) {
682 981 return 0;
683 982 }
684 983
685 - $registration_approval = $this->settings->get_section( 'user_security' );
686 - $approval_settings = $registration_approval['registration_approval'] ?? array();
687 -
688 - if ( empty( $approval_settings['enabled'] ) ) {
689 - return 0;
690 - }
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 + */
691 990
692 991 // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Limited results in admin context.
693 - $pending_users = get_users( array(
694 - 'meta_key' => 'vigilante_pending_approval',
992 + $args = array(
993 + 'meta_key' => Vigilante_User_Security::site_user_meta_key( 'vigilante_pending_approval' ),
695 994 'meta_value' => '1',
696 995 'fields' => 'ID',
697 - ) );
996 + );
698 997 // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
699 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 +
700 1008 return count( $pending_users );
701 1009 }
702 1010
703 1011 /**
@@ -821,8 +1129,28 @@
821 1129 $score += 3;
822 1130 }
823 1131 }
824 1132
1133 + /*
1134 + * Self-protection (15 points). Having it on is configuration, which is
1135 + * what this card measures; the state of the last check caps the card,
1136 + * because a plugin whose own files were changed is not well configured
1137 + * in any useful sense, whatever the rest of the settings say. The
1138 + * environment block below already mixes measured state into this score
1139 + * (WP_DEBUG, insecure usernames), so the card keeps its meaning.
1140 + */
1141 + $max_score += 15;
1142 + $self_enabled = Vigilante_Self_Integrity::is_on();
1143 + $self_tone = Vigilante_Self_Integrity::tone( Vigilante_Self_Integrity::display_state(), $self_enabled );
1144 + if ( $self_enabled ) {
1145 + $score += 10;
1146 + // The five points are for a check that ran and came out clean: a
1147 + // site that has never checked itself has not earned them.
1148 + if ( in_array( $self_tone, array( 'ok', 'info' ), true ) ) {
1149 + $score += 5;
1150 + }
1151 + }
1152 +
825 1153 // Environment checks (8 points) - penalize insecure server configuration
826 1154 $max_score += 8;
827 1155 $env_score = 8;
828 1156
@@ -838,9 +1166,14 @@
838 1166 }
839 1167
840 1168 $score += max( 0, $env_score );
841 1169
842 - return $max_score > 0 ? round( ( $score / $max_score ) * 100 ) : 0;
1170 + $percent = $max_score > 0 ? (int) round( ( $score / $max_score ) * 100 ) : 0;
1171 + if ( in_array( $self_tone, array( 'critical', 'off' ), true ) ) {
1172 + // Same cap and same reason as the Security Check score.
1173 + $percent = min( $percent, Vigilante_Security_Analyzer::SCORE_CAP_ON_TAMPER );
1174 + }
1175 + return $percent;
843 1176 }
844 1177
845 1178 /**
846 1179 * Get security recommendations based on current settings
@@ -850,8 +1183,51 @@
850 1183 */
851 1184 private function get_security_recommendations( $options ) {
852 1185 $recommendations = array();
853 1186
1187 + // Self-protection first: if Vigilant itself cannot be trusted, nothing
1188 + // else on this card means much.
1189 + $vg_self_enabled = Vigilante_Self_Integrity::is_on();
1190 + $vg_self_tone = Vigilante_Self_Integrity::tone( Vigilante_Self_Integrity::display_state(), $vg_self_enabled );
1191 + if ( ! $vg_self_enabled ) {
1192 + $recommendations[] = array(
1193 + 'icon' => 'shield',
1194 + 'priority' => 'high',
1195 + 'tab' => 'file-integrity',
1196 + 'message' => __( 'Turn Vigilant self-protection on, so a change to Vigilant own files does not go unnoticed.', 'vigilante' ),
1197 + );
1198 + } elseif ( 'off' === $vg_self_tone ) {
1199 + $recommendations[] = array(
1200 + 'icon' => 'warning',
1201 + 'priority' => 'critical',
1202 + 'tab' => 'file-integrity',
1203 + 'message' => __( 'Something on this site switched Vigilant self-protection off: File Integrity says which file does it.', 'vigilante' ),
1204 + );
1205 + } elseif ( 'critical' === $vg_self_tone ) {
1206 + $recommendations[] = array(
1207 + 'icon' => 'warning',
1208 + 'priority' => 'critical',
1209 + 'tab' => 'file-integrity',
1210 + 'message' => __( 'Vigilant own files have been changed: repair Vigilant from File Integrity before anything else.', 'vigilante' ),
1211 + );
1212 + } elseif ( 'warning' === $vg_self_tone ) {
1213 + $recommendations[] = array(
1214 + 'icon' => 'shield',
1215 + 'priority' => 'high',
1216 + 'tab' => 'file-integrity',
1217 + 'message' => __( 'Vigilant self-protection needs your attention: File Integrity says what it found and what to do.', 'vigilante' ),
1218 + );
1219 + } elseif ( 'none' === $vg_self_tone ) {
1220 + // The score holds back the points of a check that has not run yet,
1221 + // so the card has to say why instead of just showing a lower number.
1222 + $recommendations[] = array(
1223 + 'icon' => 'shield',
1224 + 'priority' => 'high',
1225 + 'tab' => 'file-integrity',
1226 + 'message' => __( 'Vigilant has not checked its own files yet: run a scan from File Integrity.', 'vigilante' ),
1227 + );
1228 + }
1229 +
854 1230 // Critical: Firewall disabled
855 1231 if ( empty( $options['modules']['firewall'] ) ) {
856 1232 $recommendations[] = array(
857 1233 'icon' => 'warning',
@@ -1215,8 +1591,12 @@
1215 1591 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'HSTS', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'HSTS', 'vigilante' ), 'label_en' => 'HSTS', 'keywords' => _x( 'hsts strict transport security ssl tls https headers', 'settings search keywords', 'vigilante' ) ),
1216 1592 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Content Security Policy', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Content Security Policy', 'vigilante' ), 'label_en' => 'Content Security Policy', 'keywords' => _x( 'content security policy csp xss headers', 'settings search keywords', 'vigilante' ) ),
1217 1593 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Server Identity', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Server Signature', 'vigilante' ), 'label_en' => 'Server Signature', 'keywords' => _x( 'server signature fingerprint banner', 'settings search keywords', 'vigilante' ) ),
1218 1594 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Server Identity', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Remove Fingerprinting Headers', 'vigilante' ), 'label_en' => 'Remove Fingerprinting Headers', 'keywords' => _x( 'remove fingerprinting headers fingerprint banner header http', 'settings search keywords', 'vigilante' ) ),
1595 + // Security Headers - Cross-Origin Policies
1596 + array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Cross-Origin Policies', 'vigilante' ), 'anchor' => 'vigilante-section-headers-cross-origin', 'label' => __( 'Cross-Origin-Opener-Policy (COOP)', 'vigilante' ), 'label_en' => 'Cross-Origin-Opener-Policy (COOP)', 'keywords' => _x( 'coop cross-origin opener policy popup popups window opener tag assistant google isolation browsing context headers', 'settings search keywords', 'vigilante' ) ),
1597 + array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Cross-Origin Policies', 'vigilante' ), 'anchor' => 'vigilante-section-headers-cross-origin', 'label' => __( 'Cross-Origin-Embedder-Policy (COEP)', 'vigilante' ), 'label_en' => 'Cross-Origin-Embedder-Policy (COEP)', 'keywords' => _x( 'coep cross-origin embedder policy require-corp credentialless embed embeds iframe fonts headers', 'settings search keywords', 'vigilante' ) ),
1598 + array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Cross-Origin Policies', 'vigilante' ), 'anchor' => 'vigilante-section-headers-cross-origin', 'label' => __( 'Cross-Origin-Resource-Policy (CORP)', 'vigilante' ), 'label_en' => 'Cross-Origin-Resource-Policy (CORP)', 'keywords' => _x( 'corp cross-origin resource policy hotlink hotlinking cdn images assets headers', 'settings search keywords', 'vigilante' ) ),
1219 1599 // Login Security
1220 1600 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Custom login URL', 'vigilante' ), 'label_en' => 'Custom login URL', 'keywords' => _x( 'custom login url signin log-in access slug', 'settings search keywords', 'vigilante' ) ),
1221 1601 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Two-Factor Authentication', 'vigilante' ), 'label_en' => 'Two-Factor Authentication', 'keywords' => _x( 'two-factor authentication 2fa mfa otp totp authenticator', 'settings search keywords', 'vigilante' ) ),
1222 1602 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( '2FA', 'vigilante' ), 'label_en' => '2FA', 'keywords' => _x( '2fa two-factor mfa otp totp authenticator', 'settings search keywords', 'vigilante' ) ),
@@ -1250,8 +1630,9 @@
1250 1630 // File Integrity
1251 1631 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' ) ),
1252 1632 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' ) ),
1253 1633 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' ) ),
1634 + 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' ) ),
1254 1635 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' ) ),
1255 1636 // Security Audit
1256 1637 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' ) ),
1257 1638 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' ) ),
@@ -1404,13 +1785,17 @@
1404 1785 );
1405 1786
1406 1787 wp_localize_script( 'vigilante-admin', 'vigilanteAdmin', array(
1407 1788 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
1789 + 'selfBoxUrl' => esc_url( admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' ) ),
1408 1790 'nonce' => wp_create_nonce( 'vigilante_admin_nonce' ),
1409 1791 'currentUserId' => get_current_user_id(),
1410 1792 'logoutUrl' => wp_logout_url( wp_login_url() ),
1411 1793 'adminUrl' => admin_url( 'admin.php?page=vigilante' ),
1412 1794 'searchIndex' => $this->get_search_index(),
1795 + // The scan repaints this table from JavaScript, so the same gate
1796 + // has to travel with it or half the screen keeps the dead button.
1797 + 'approvalLocked' => $this->critical_approval_locked(),
1413 1798 'underAttack' => array(
1414 1799 'active' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->is_active(),
1415 1800 'remaining' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->get_remaining_time(),
1416 1801 ),
@@ -1450,8 +1835,13 @@
1450 1835 'file' => __( 'File', 'vigilante' ),
1451 1836 'reason' => __( 'Reason', 'vigilante' ),
1452 1837 'type' => __( 'Type', 'vigilante' ),
1453 1838 'unknown' => __( 'Unknown', 'vigilante' ),
1839 + 'selfType' => __( 'Vigilant (self)', 'vigilante' ),
1840 + 'logWhatHappened' => __( 'What happened', 'vigilante' ),
1841 + 'logWhatItMeans' => __( 'What it means', 'vigilante' ),
1842 + 'logWhatToDo' => __( 'What to do', 'vigilante' ),
1843 + 'logSelfSeeDetails' => __( 'Open File Integrity for the full detail', 'vigilante' ),
1454 1844 'modifiedFiles' => __( 'Modified Files', 'vigilante' ),
1455 1845 'modifiedDescription' => __( 'These files (apparently) differ from the original WordPress or plugin versions.', 'vigilante' ),
1456 1846 'extraFiles' => __( 'Extra Files', 'vigilante' ),
1457 1847 'extra' => __( 'Extra', 'vigilante' ),
@@ -1477,13 +1867,17 @@
1477 1867 'criticalConfigTitle' => __( 'Critical config files modified', 'vigilante' ),
1478 1868 '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' ),
1479 1869 'approve' => __( 'Approve', 'vigilante' ),
1480 1870 'approving' => __( 'Approving...', 'vigilante' ),
1871 + 'approvalLockedNotice' => $this->critical_approval_notice(),
1481 1872 'criticalApproved' => __( 'Change approved. Next scan will use the current state as baseline.', 'vigilante' ),
1482 1873 'reviewChanges' => __( 'Review changes', 'vigilante' ),
1483 1874 'hideChanges' => __( 'Hide changes', 'vigilante' ),
1484 1875 'changes' => __( 'Changes', 'vigilante' ),
1485 1876 'diffUnavailable' => __( 'Diff not available for this file (baseline was created before diff tracking was added). Approve to enable diff on future changes.', 'vigilante' ),
1877 + 'diffNetwork' => __( 'This file belongs to the whole network, so its line changes are only shown to network administrators, on the main site.', 'vigilante' ),
1878 + 'diffRescan' => __( 'Run a new scan to see the line changes of this file.', 'vigilante' ),
1879 + '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' ),
1486 1880 'diffEmpty' => __( 'No line-level changes detected (may be whitespace or reordering).', 'vigilante' ),
1487 1881 'diffLines' => __( 'lines', 'vigilante' ),
1488 1882 // Under Attack mode strings
1489 1883 'underAttackConfirmActivate' => __( 'Activate Under Attack mode? All visitors will see a verification page for the next 4 hours.', 'vigilante' ),
@@ -1539,8 +1933,9 @@
1539 1933 'logType' => __( 'Type', 'vigilante' ),
1540 1934 'logAction' => __( 'Action', 'vigilante' ),
1541 1935 'logSeverity' => __( 'Severity', 'vigilante' ),
1542 1936 'logMessage' => __( 'Message', 'vigilante' ),
1937 + 'logRequestUri' => __( 'Address', 'vigilante' ),
1543 1938 'logClient' => __( 'Client', 'vigilante' ),
1544 1939 'logUser' => __( 'User', 'vigilante' ),
1545 1940 'logIpAddress' => __( 'IP Address', 'vigilante' ),
1546 1941 'logUserAgent' => __( 'User Agent', 'vigilante' ),
@@ -1698,9 +2093,89 @@
1698 2093
1699 2094 /**
1700 2095 * Show admin notices
1701 2096 */
2097 + /**
2098 + * Self-protection notice.
2099 + *
2100 + * A change to Vigilant own files is shown on every admin screen, because
2101 + * waiting for someone to open the plugin is exactly what an attacker who
2102 + * patched it would want. A warning is shown only on Vigilant screens, so
2103 + * the notice that matters is not diluted. Neither is dismissible, both are
2104 + * for administrators only, and on a network the steps depend on whether
2105 + * the person can install plugins at all.
2106 + */
2107 + public function maybe_show_self_protection_notice() {
2108 + if ( ! current_user_can( 'manage_options' ) ) {
2109 + return;
2110 + }
2111 + $summary = $this->self_integrity_summary();
2112 + $tone = $summary['tone'];
2113 + if ( ! in_array( $tone, array( 'critical', 'off', 'warning' ), true ) ) {
2114 + return;
2115 + }
2116 +
2117 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the screen slug to decide where a notice is shown; no action taken.
2118 + $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
2119 + $on_vigilante = ( 0 === strpos( $page, 'vigilante' ) );
2120 + if ( 'warning' === $tone && ! $on_vigilante ) {
2121 + return;
2122 + }
2123 + $alarma = in_array( $tone, array( 'critical', 'off' ), true );
2124 +
2125 + $link = admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' );
2126 + $network = is_multisite() && ! current_user_can( 'update_plugins' );
2127 + ?>
2128 + <div class="notice notice-<?php echo $alarma ? 'error' : 'warning'; ?> vigilante-self-notice">
2129 + <p class="vigilante-self-notice-title">
2130 + <span class="dashicons dashicons-shield" aria-hidden="true"></span>
2131 + <strong>
2132 + <?php
2133 + if ( 'critical' === $tone ) {
2134 + esc_html_e( 'Vigilant detected changes in its own files', 'vigilante' );
2135 + } elseif ( 'off' === $tone ) {
2136 + esc_html_e( 'Vigilant self-protection is switched off by code', 'vigilante' );
2137 + } else {
2138 + echo esc_html( $this->self_integrity_headline( $tone, $summary['state'] ) );
2139 + }
2140 + ?>
2141 + </strong>
2142 + </p>
2143 + <p class="vigilante-self-notice-text">
2144 + <span>
2145 + <?php
2146 + if ( 'critical' === $tone ) {
2147 + esc_html_e( 'Your security plugin may have been tampered with, and while that is true nothing it reports can be trusted.', 'vigilante' );
2148 + } elseif ( 'off' === $tone ) {
2149 + esc_html_e( 'Nothing is checking that Vigilant own files are intact. File Integrity says which file switches it off.', 'vigilante' );
2150 + } else {
2151 + esc_html_e( 'File Integrity says what was found, what it means and what to do about it.', 'vigilante' );
2152 + }
2153 + if ( $network ) {
2154 + 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' );
2155 + }
2156 + ?>
2157 + </span>
2158 + <?php
2159 + /*
2160 + * One button, on the same line as the text it belongs to, and it
2161 + * leads to the whole story. Repairing from a notice, without
2162 + * seeing which files, what it means and what it will do, is
2163 + * asking someone to fix what they have not read: the repair
2164 + * button lives in the card, after all that.
2165 + */
2166 + ?>
2167 + <a class="button button-primary button-small" href="<?php echo esc_url( $link ); ?>">
2168 + <?php esc_html_e( 'See what to do', 'vigilante' ); ?>
2169 + </a>
2170 + </p>
2171 + </div>
2172 + <?php
2173 + }
2174 +
1702 2175 public function show_admin_notices() {
2176 + $this->maybe_show_self_protection_notice();
2177 +
1703 2178 // Activation notice
1704 2179 if ( get_transient( 'vigilante_activated' ) ) {
1705 2180 ?>
1706 2181 <div class="notice notice-success is-dismissible vigilante-activation-notice">
@@ -1745,8 +2220,21 @@
1745 2220 </p>
1746 2221 <p>
1747 2222 <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>
1748 2223 </p>
2224 + <?php
2225 + // The cache-bypass rules could not be written (a host where
2226 + // WordPress cannot write files by itself, a held lock, a
2227 + // failed read-back): show them, so they can be added by hand.
2228 + $ua_instance = new Vigilante_Under_Attack( $this->settings, $this->activity_log );
2229 + if ( $ua_instance->cache_rules_missing() ) :
2230 + ?>
2231 + <p>
2232 + <strong><?php esc_html_e( 'The cache-bypass rules could not be written to your .htaccess.', 'vigilante' ); ?></strong>
2233 + <?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' ); ?>
2234 + </p>
2235 + <textarea readonly rows="9" class="large-text code" onclick="this.select();"><?php echo esc_textarea( Vigilante_Under_Attack::get_cache_bypass_block() ); ?></textarea>
2236 + <?php endif; ?>
1749 2237 </div>
1750 2238 <?php
1751 2239 }
1752 2240 }
@@ -1978,8 +2466,41 @@
1978 2466 return ! Vigilante_Settings::can_write_shared_files();
1979 2467 }
1980 2468
1981 2469 /**
2470 + * Whether this is the main site and the user cannot change what it builds the shared files from
2471 + *
2472 + * See Vigilante_Settings::get_main_site_file_settings(). On a subsite those
2473 + * settings only act on that site, so they are never locked there.
2474 + *
2475 + * @since 2.11.6
2476 + *
2477 + * @return bool
2478 + */
2479 + private function main_site_files_locked() {
2480 + return $this->shared_files_locked() && Vigilante_Settings::owns_shared_files();
2481 + }
2482 +
2483 + /**
2484 + * Sentence added to a bulk change when some settings were left as they were
2485 + *
2486 + * Importing a file, applying a preset and restoring the defaults touch every
2487 + * section at once, so the user is told that the shared file settings did
2488 + * not move.
2489 + *
2490 + * @since 2.11.6
2491 + *
2492 + * @return string Empty when the user can change every setting.
2493 + */
2494 + private function locked_file_settings_message() {
2495 + if ( ! Vigilante_Settings::get_locked_file_settings() ) {
2496 + return '';
2497 + }
2498 +
2499 + return ' ' . __( 'The settings that end up in wp-config.php or .htaccess were left as they were.', 'vigilante' ) . ' ' . Vigilante_Settings::get_shared_files_notice();
2500 + }
2501 +
2502 + /**
1982 2503 * Print the shared-files notice for a section that cannot be edited here
1983 2504 *
1984 2505 * @since 2.9.8
1985 2506 */
@@ -1994,8 +2515,109 @@
1994 2515 <?php
1995 2516 }
1996 2517
1997 2518 /**
2519 + * Acting on another user's account needs permission over that user
2520 + *
2521 + * Since 2.10.3 the handlers behind these tools ask for edit_user over the
2522 + * target, which is the rule WordPress itself applies. On a network the core
2523 + * grants edit_user only to network administrators, so for anybody else these
2524 + * controls do nothing. Better to say so than to paint a button that silently
2525 + * skips every user.
2526 + *
2527 + * @since 2.10.4
2528 + * @return bool
2529 + */
2530 + private function forwarded_chain_readings() {
2531 + // Shown, not decided on: the firewall resolves the address elsewhere.
2532 + $chain = Vigilante_IP_Utils::trusted_forwarded_for();
2533 +
2534 + if ( '' === $chain ) {
2535 + return array();
2536 + }
2537 +
2538 + $public = array();
2539 +
2540 + foreach ( explode( ',', $chain ) as $entry ) {
2541 + $address = Vigilante_IP_Utils::unmap_ipv4( trim( $entry ) );
2542 +
2543 + if ( filter_var( $address, FILTER_VALIDATE_IP ) && ! Vigilante_IP_Utils::is_own_network( $address ) ) {
2544 + $public[] = $address;
2545 + }
2546 + }
2547 +
2548 + if ( count( $public ) < 2 ) {
2549 + return array();
2550 + }
2551 +
2552 + return array(
2553 + 'now' => Vigilante_IP_Utils::client_from_chain( $chain ),
2554 + 'before' => $public[0],
2555 + );
2556 + }
2557 +
2558 + /**
2559 + * Whether the user tools of this screen are out of reach for this user
2560 + *
2561 + * @return bool
2562 + */
2563 + private function user_actions_locked() {
2564 + // On a single site edit_user maps to edit_users, which a custom role with
2565 + // manage_options may lack: since 2.11.8 approving and rejecting a pending
2566 + // registration ask for it, so the buttons have to say so there too.
2567 + return is_multisite() ? ! current_user_can( 'manage_network_users' ) : ! current_user_can( 'edit_users' );
2568 + }
2569 +
2570 + /**
2571 + * Print the notice for user tools that cannot be used from this site
2572 + *
2573 + * @since 2.10.4
2574 + */
2575 + private function render_user_actions_notice() {
2576 + if ( ! $this->user_actions_locked() ) {
2577 + return;
2578 + }
2579 + ?>
2580 + <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;">
2581 + <?php if ( is_multisite() ) : ?>
2582 + <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>
2583 + <?php else : ?>
2584 + <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>
2585 + <?php endif; ?>
2586 + </div>
2587 + <?php
2588 + }
2589 +
2590 + /**
2591 + * Approving a change to the shared config files needs the network
2592 + *
2593 + * Since 2.11.3 the handler behind the Approve button asks for
2594 + * manage_network_options, because the two files it approves, wp-config.php
2595 + * and the root .htaccess, belong to the installation, and so does the
2596 + * record of them. The button, though, went on being painted for everybody,
2597 + * so the administrator of a subsite saw the warning, saw the button,
2598 + * pressed it and got "Permission denied" with no explanation. That is
2599 + * exactly what user_actions_locked() above exists to avoid, one release
2600 + * later and one screen over. Flagged by @calzbert.
2601 + *
2602 + * @since 2.11.4
2603 + * @return bool
2604 + */
2605 + private function critical_approval_locked() {
2606 + return is_multisite() && ! current_user_can( 'manage_network_options' );
2607 + }
2608 +
2609 + /**
2610 + * The line that replaces the Approve button where it cannot be used
2611 + *
2612 + * @since 2.11.4
2613 + * @return string
2614 + */
2615 + private function critical_approval_notice() {
2616 + 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' );
2617 + }
2618 +
2619 + /**
1998 2620 * Check if module is disabled and render warning
1999 2621 *
2000 2622 * @param string $module_key Module key.
2001 2623 * @return bool True if disabled.
@@ -2056,8 +2678,22 @@
2056 2678 $categories = isset( $last_scan['categories'] ) && is_array( $last_scan['categories'] ) ? $last_scan['categories'] : array();
2057 2679 $weekly_enabled = ! isset( $analyzer_settings['weekly_scan_enabled'] ) || ! empty( $analyzer_settings['weekly_scan_enabled'] );
2058 2680 $email_enabled = ! empty( $analyzer_settings['email_on_regression'] );
2059 2681
2682 + /*
2683 + * Self-protection caps the score, and it does so here and not only in
2684 + * the stored report: the report is reused until the next Security
2685 + * Check, so tampering found after it ran would otherwise be shown next
2686 + * to the score the site earned before it happened.
2687 + */
2688 + $self_summary = $this->self_integrity_summary();
2689 + $self_capped = in_array( $self_summary['tone'], array( 'critical', 'off' ), true )
2690 + || ( 'self_integrity' === ( isset( $last_scan['capped_by'] ) ? $last_scan['capped_by'] : '' ) );
2691 + if ( $self_capped && $has_data && $score > Vigilante_Security_Analyzer::SCORE_CAP_ON_TAMPER ) {
2692 + $score = Vigilante_Security_Analyzer::SCORE_CAP_ON_TAMPER;
2693 + $grade = 'E';
2694 + }
2695 +
2060 2696 $quality = self::analyzer_quality_tag( $score );
2061 2697 ?>
2062 2698 <div class="vigilante-analyzer" id="vigilante-analyzer"
2063 2699 data-has-data="<?php echo $has_data ? '1' : '0'; ?>">
@@ -2082,8 +2718,19 @@
2082 2718 </button>
2083 2719 </div>
2084 2720 </div>
2085 2721
2722 + <?php if ( $self_capped ) : ?>
2723 + <p class="vigilante-analyzer-capped">
2724 + <span class="dashicons dashicons-shield" aria-hidden="true"></span>
2725 + <strong><?php esc_html_e( 'This score is not reliable right now.', 'vigilante' ); ?></strong>
2726 + <?php esc_html_e( 'Vigilant own files have been changed, and every other result on this page is produced by that same code. The score is held at the bottom of the scale until the files verify clean again.', 'vigilante' ); ?>
2727 + <a href="<?php echo esc_url( admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' ) ); ?>">
2728 + <?php esc_html_e( 'See what to do', 'vigilante' ); ?>
2729 + </a>
2730 + </p>
2731 + <?php endif; ?>
2732 +
2086 2733 <div class="vigilante-analyzer-summary">
2087 2734 <div class="vigilante-analyzer-score-card">
2088 2735 <?php if ( $has_data && $grade ) : ?>
2089 2736 <div class="vigilante-score-circle vigilante-grade-<?php echo esc_attr( strtolower( $grade ) ); ?>">
@@ -2457,8 +3104,58 @@
2457 3104
2458 3105 /**
2459 3106 * Render dashboard tab
2460 3107 */
3108 + /**
3109 + * One line strip at the top of the Dashboard tab: the state of Vigilant own
3110 + * files, above the Configuration Score and the Security Check, because
3111 + * nothing else on this screen means much while it is red.
3112 + */
3113 + private function render_self_protection_strip() {
3114 + $summary = $this->self_integrity_summary();
3115 + $tone = $summary['tone'];
3116 + $state = $summary['state'];
3117 + $count = count( $summary['findings'] );
3118 + $link = admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' );
3119 + ?>
3120 + <div class="vigilante-self-strip vigilante-self-strip--<?php echo esc_attr( $tone ); ?>">
3121 + <span class="dashicons dashicons-shield" aria-hidden="true"></span>
3122 + <strong><?php esc_html_e( 'Vigilant self-protection', 'vigilante' ); ?></strong>
3123 + <span class="vigilante-self-strip-state"><?php echo esc_html( $this->self_integrity_headline( $tone, $state ) ); ?></span>
3124 + <?php if ( $count > 0 ) : ?>
3125 + <span class="vigilante-self-strip-count">
3126 + <?php
3127 + printf(
3128 + /* translators: %d: number of findings about Vigilant own files */
3129 + esc_html( _n( '%d finding', '%d findings', $count, 'vigilante' ) ),
3130 + (int) $count
3131 + );
3132 + ?>
3133 + </span>
3134 + <?php elseif ( ! empty( $state['last_check'] ) && 'off' !== $tone ) : ?>
3135 + <span class="vigilante-self-strip-count">
3136 + <?php
3137 + printf(
3138 + /* translators: %s: human time difference, like "2 hours" */
3139 + esc_html__( 'checked %s ago', 'vigilante' ),
3140 + esc_html( human_time_diff( (int) $state['last_check'], time() ) )
3141 + );
3142 + ?>
3143 + </span>
3144 + <?php endif; ?>
3145 + <a href="<?php echo esc_url( $link ); ?>">
3146 + <?php
3147 + if ( in_array( $tone, array( 'critical', 'off', 'warning' ), true ) ) {
3148 + esc_html_e( 'See what to do', 'vigilante' );
3149 + } else {
3150 + esc_html_e( 'See details', 'vigilante' );
3151 + }
3152 + ?>
3153 + </a>
3154 + </div>
3155 + <?php
3156 + }
3157 +
2461 3158 private function render_tab_dashboard() {
2462 3159 $options = $this->settings->get_all_options();
2463 3160 $module_labels = $this->settings->get_module_labels();
2464 3161 $module_descriptions = $this->settings->get_module_descriptions();
@@ -2478,8 +3175,9 @@
2478 3175 $analyzer_categories_def = Vigilante_Security_Analyzer::get_categories();
2479 3176 $analyzer_settings = isset( $options['security_analyzer'] ) ? $options['security_analyzer'] : array();
2480 3177 ?>
2481 3178 <div class="vigilante-dashboard">
3179 + <?php $this->render_self_protection_strip(); ?>
2482 3180 <div class="vigilante-status-card">
2483 3181 <h2><?php esc_html_e( 'Configuration Score', 'vigilante' ); ?></h2>
2484 3182 <p class="vigilante-score-kind description">
2485 3183 <?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' ); ?>
@@ -2539,14 +3237,15 @@
2539 3237
2540 3238 <?php $this->render_analyzer_widget( $analyzer_last_scan, $analyzer_history, $analyzer_categories_def, $analyzer_settings ); ?>
2541 3239
2542 3240 <div class="vigilante-modules-grid">
2543 - <h2><?php esc_html_e( 'Security Modules', 'vigilante' ); ?></h2>
3241 + <h2 id="vigilante-section-dashboard-modules"><?php esc_html_e( 'Security Modules', 'vigilante' ); ?></h2>
2544 3242 <p class="description"><?php esc_html_e( 'Enable or disable security modules. Each module controls a tab with detailed settings.', 'vigilante' ); ?></p>
2545 3243 <div class="vigilante-modules-list">
2546 3244 <?php foreach ( $options['modules'] as $module => $enabled ) :
2547 3245 $label = isset( $module_labels[ $module ] ) ? $module_labels[ $module ] : ucwords( str_replace( '_', ' ', $module ) );
2548 3246 $description = isset( $module_descriptions[ $module ] ) ? $module_descriptions[ $module ] : '';
3247 + $vg_module_locked = $this->main_site_files_locked() && in_array( $module, Vigilante_Settings::get_main_site_file_settings()['modules'], true );
2549 3248 ?>
2550 3249 <div class="vigilante-module-item <?php echo $enabled ? 'enabled' : 'disabled'; ?>">
2551 3250 <div class="vigilante-module-header">
2552 3251 <span class="vigilante-module-status"></span>
@@ -2559,8 +3258,9 @@
2559 3258 <input type="checkbox"
2560 3259 name="modules[<?php echo esc_attr( $module ); ?>]"
2561 3260 value="1"
2562 3261 <?php checked( $enabled ); ?>
3262 + <?php disabled( $vg_module_locked ); ?>
2563 3263 aria-label="<?php echo esc_attr( $toggle_label ); ?>"
2564 3264 data-module="<?php echo esc_attr( $module ); ?>">
2565 3265 <span class="vigilante-toggle-slider"></span>
2566 3266 </label>
@@ -2567,8 +3267,11 @@
2567 3267 </div>
2568 3268 <?php if ( $description ) : ?>
2569 3269 <p class="vigilante-module-desc"><?php echo esc_html( $description ); ?></p>
2570 3270 <?php endif; ?>
3271 + <?php if ( $vg_module_locked ) : ?>
3272 + <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>
3273 + <?php endif; ?>
2571 3274 </div>
2572 3275 <?php endforeach; ?>
2573 3276 </div>
2574 3277 </div>
@@ -2600,9 +3303,9 @@
2600 3303 $ua_remaining_hours = floor( $ua_remaining / 3600 );
2601 3304 $ua_remaining_mins = floor( ( $ua_remaining % 3600 ) / 60 );
2602 3305 ?>
2603 3306 <div class="vigilante-preset-card vigilante-under-attack-card <?php echo $ua_active ? 'vigilante-under-attack-active' : ''; ?>">
2604 - <h3>
3307 + <h3 id="vigilante-section-dashboard-under-attack">
2605 3308 <span class="dashicons dashicons-shield"></span>
2606 3309 <?php esc_html_e( 'Under Attack', 'vigilante' ); ?>
2607 3310 </h3>
2608 3311 <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>
@@ -2958,14 +3661,21 @@
2958 3661 <?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' ); ?>
2959 3662 </p>
2960 3663 </div>
2961 3664
3665 + <?php $vg_main_locked = $this->main_site_files_locked(); ?>
3666 + <?php if ( $vg_main_locked ) : ?>
3667 + <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;">
3668 + <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>
3669 + </div>
3670 + <?php endif; ?>
3671 +
2962 3672 <table class="form-table">
2963 3673 <tr>
2964 3674 <th scope="row"><?php esc_html_e( 'Block Bad Query Strings', 'vigilante' ); ?></th>
2965 3675 <td>
2966 3676 <label>
2967 - <input type="checkbox" name="firewall[block_bad_query_strings]" value="1" <?php checked( ! empty( $options['block_bad_query_strings'] ) ); ?>>
3677 + <input type="checkbox" name="firewall[block_bad_query_strings]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( ! empty( $options['block_bad_query_strings'] ) ); ?>>
2968 3678 <?php esc_html_e( 'Block malicious query string patterns', 'vigilante' ); ?>
2969 3679 </label>
2970 3680 </td>
2971 3681 </tr>
@@ -3008,9 +3718,9 @@
3008 3718 <tr>
3009 3719 <th scope="row"><?php esc_html_e( 'Block Bad Bots', 'vigilante' ); ?></th>
3010 3720 <td>
3011 3721 <label>
3012 - <input type="checkbox" name="firewall[block_bad_bots]" value="1" <?php checked( ! empty( $options['block_bad_bots'] ) ); ?>>
3722 + <input type="checkbox" name="firewall[block_bad_bots]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( ! empty( $options['block_bad_bots'] ) ); ?>>
3013 3723 <?php esc_html_e( 'Block known malicious bots and scanners', 'vigilante' ); ?>
3014 3724 </label>
3015 3725 </td>
3016 3726 </tr>
@@ -3124,8 +3834,29 @@
3124 3834 </table>
3125 3835 </div>
3126 3836 <?php endif; ?>
3127 3837
3838 + <?php
3839 + // Since 2.11.8 X-Forwarded-For is read from its end, where the proxy
3840 + // writes. The administrator's own request shows whether that end is
3841 + // a CDN or a balancer for everybody here. Cross review of 2.11.8.
3842 + $xff_readings = $this->forwarded_chain_readings();
3843 + if ( $xff_readings ) :
3844 + ?>
3845 + <div id="vigilante-xff-chain-notice" class="notice notice-warning inline" style="margin:10px 0 16px;padding:8px 12px;">
3846 + <p style="margin:0;">
3847 + <?php
3848 + printf(
3849 + /* translators: 1: last address in the header, the one Vigilant reads, 2: first address in the header, which a visitor can write */
3850 + 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' ),
3851 + esc_html( $xff_readings['now'] ),
3852 + esc_html( $xff_readings['before'] )
3853 + );
3854 + ?>
3855 + </p>
3856 + </div>
3857 + <?php endif; ?>
3858 +
3128 3859 <h3><?php esc_html_e( 'IP Lists', 'vigilante' ); ?></h3>
3129 3860 <p class="description">
3130 3861 <?php
3131 3862 printf(
@@ -3139,9 +3870,9 @@
3139 3870 <tr>
3140 3871 <th scope="row"><label for="vigilante-f-firewall-trusted-proxy-header"><?php esc_html_e( 'Visitor IP detection', 'vigilante' ); ?></label></th>
3141 3872 <td>
3142 3873 <?php $proxy_header = $options['trusted_proxy_header'] ?? ''; ?>
3143 - <select id="vigilante-f-firewall-trusted-proxy-header" name="firewall[trusted_proxy_header]">
3874 + <select id="vigilante-f-firewall-trusted-proxy-header" name="firewall[trusted_proxy_header]" <?php disabled( $vg_main_locked ); ?>>
3144 3875 <option value="" <?php selected( $proxy_header, '' ); ?>><?php esc_html_e( 'Direct connection, only REMOTE_ADDR (recommended)', 'vigilante' ); ?></option>
3145 3876 <option value="cf-connecting-ip" <?php selected( $proxy_header, 'cf-connecting-ip' ); ?>><?php esc_html_e( 'Behind Cloudflare (CF-Connecting-IP)', 'vigilante' ); ?></option>
3146 3877 <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>
3147 3878 <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>
@@ -3151,11 +3882,23 @@
3151 3882 </p>
3152 3883 </td>
3153 3884 </tr>
3154 3885 <tr>
3886 + <th scope="row"><label for="vigilante-f-firewall-trusted-proxies"><?php esc_html_e( 'Trusted proxy IPs', 'vigilante' ); ?></label></th>
3887 + <td>
3888 + <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>
3889 + <p class="description">
3890 + <?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' ); ?>
3891 + <?php if ( in_array( $proxy_header, array( 'x-forwarded-for', 'x-real-ip' ), true ) && empty( $options['trusted_proxies'] ) ) : ?>
3892 + <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>
3893 + <?php endif; ?>
3894 + </p>
3895 + </td>
3896 + </tr>
3897 + <tr>
3155 3898 <th scope="row"><label for="vigilante-f-firewall-ip-whitelist"><?php esc_html_e( 'IP Whitelist', 'vigilante' ); ?></label></th>
3156 3899 <td>
3157 - <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>
3900 + <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>
3158 3901 <p class="description">
3159 3902 <?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' ); ?>
3160 3903 <br>
3161 3904 <?php
@@ -3194,9 +3937,9 @@
3194 3937 <table class="form-table">
3195 3938 <tr>
3196 3939 <th scope="row"><label for="vigilante-f-firewall-ua-whitelist"><?php esc_html_e( 'User-Agent Whitelist', 'vigilante' ); ?></label></th>
3197 3940 <td>
3198 - <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>
3941 + <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>
3199 3942 <p class="description"><?php esc_html_e( 'One User-Agent per line. These will bypass all firewall checks. Example: ManageWP, MainWP, UptimeRobot.', 'vigilante' ); ?></p>
3200 3943 </td>
3201 3944 </tr>
3202 3945 <tr>
@@ -3484,9 +4227,9 @@
3484 4227 $two_factor = $options['two_factor'] ?? array();
3485 4228 $two_factor_enabled = ! empty( $two_factor['enabled'] );
3486 4229 ?>
3487 4230 <div class="vigilante-settings-section vigilante-lockout-section">
3488 - <h2><?php esc_html_e( 'Login Protection Status', 'vigilante' ); ?></h2>
4231 + <h2 id="vigilante-section-login-status"><?php esc_html_e( 'Login Protection Status', 'vigilante' ); ?></h2>
3489 4232
3490 4233 <table class="form-table">
3491 4234 <tr>
3492 4235 <th scope="row"><?php esc_html_e( 'Current settings', 'vigilante' ); ?></th>
@@ -3646,9 +4389,9 @@
3646 4389 $excluded = $two_factor['excluded_users'] ?? array();
3647 4390 $method = $two_factor['method'] ?? 'email';
3648 4391 $grace_days = $two_factor['grace_period_days'] ?? 3;
3649 4392 ?>
3650 - <h3>
4393 + <h3 id="vigilante-section-login-2fa">
3651 4394 <?php esc_html_e( 'Two-Factor Authentication (2FA)', 'vigilante' ); ?>
3652 4395 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
3653 4396 <span class="vigilante-method-badge database"><?php esc_html_e( 'Database', 'vigilante' ); ?></span>
3654 4397 </h3>
@@ -3847,8 +4590,128 @@
3847 4590
3848 4591 /**
3849 4592 * Render security headers tab
3850 4593 */
4594 + /**
4595 + * Offer back the header settings the 2.9.8 migration wiped.
4596 + *
4597 + * Rendered outside the settings form on purpose, so its buttons can never
4598 + * submit it, and only when there is something to actually change. Shows the
4599 + * difference before anything is written: nothing is applied that the owner
4600 + * has not seen first.
4601 + *
4602 + * @since 2.10.0
4603 + */
4604 + private function render_headers_recovery_offer() {
4605 + /*
4606 + * On a network the .htaccess belongs to every site and only the main one
4607 + * writes it, so this is not a decision a subsite gets to make. Its own
4608 + * security_headers options are inert anyway: what the network serves
4609 + * comes from the file the main site owns. Without this gate a subsite
4610 + * administrator was shown a Restore button that could only ever answer
4611 + * with a permission error, which is worse than showing nothing.
4612 + */
4613 + if ( ! Vigilante_Settings::can_write_shared_files() ) {
4614 + return;
4615 + }
4616 +
4617 + if ( ! Vigilante_Htaccess_Recovery::is_available() ) {
4618 + /*
4619 + * Already restored. Offer to take it back for as long as the previous
4620 + * section is still stored: a restore that cannot be undone is a second
4621 + * irreversible change on top of the one being repaired.
4622 + */
4623 + if ( Vigilante_Htaccess_Recovery::has_undo() ) {
4624 + ?>
4625 + <div class="notice notice-info inline" id="vigilante-headers-recovery-undo">
4626 + <p>
4627 + <?php esc_html_e( 'The Security Headers settings were restored from the copy Vigilant had kept of your .htaccess.', 'vigilante' ); ?>
4628 + <button type="button" class="button button-small" id="vigilante-recovery-undo">
4629 + <?php esc_html_e( 'Undo the restore', 'vigilante' ); ?>
4630 + </button>
4631 + </p>
4632 + </div>
4633 + <?php
4634 + }
4635 +
4636 + return;
4637 + }
4638 +
4639 + $rows = Vigilante_Htaccess_Recovery::get_diff( $this->settings );
4640 +
4641 + if ( empty( $rows ) ) {
4642 + return;
4643 + }
4644 +
4645 + $snapshot = Vigilante_Htaccess_Recovery::get_snapshot();
4646 + $taken = isset( $snapshot['time'] ) ? (int) $snapshot['time'] : 0;
4647 + $block = Vigilante_Htaccess_Recovery::get_raw_block();
4648 + ?>
4649 + <div class="vigilante-settings-section" id="vigilante-headers-recovery">
4650 + <h2><?php esc_html_e( 'Recover your previous header settings', 'vigilante' ); ?></h2>
4651 + <p>
4652 + <?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' ); ?>
4653 + </p>
4654 + <?php if ( $taken ) : ?>
4655 + <p class="description">
4656 + <?php
4657 + printf(
4658 + /* translators: %s: date and time the .htaccess copy was taken. */
4659 + esc_html__( 'Copy taken on %s.', 'vigilante' ),
4660 + esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $taken ) )
4661 + );
4662 + ?>
4663 + </p>
4664 + <?php endif; ?>
4665 +
4666 + <table class="widefat striped">
4667 + <thead>
4668 + <tr>
4669 + <th scope="col"><?php esc_html_e( 'Setting', 'vigilante' ); ?></th>
4670 + <th scope="col"><?php esc_html_e( 'Now', 'vigilante' ); ?></th>
4671 + <th scope="col"><?php esc_html_e( 'Would be restored to', 'vigilante' ); ?></th>
4672 + </tr>
4673 + </thead>
4674 + <tbody>
4675 + <?php foreach ( $rows as $row ) : ?>
4676 + <tr>
4677 + <th scope="row"><?php echo esc_html( $row['label'] ); ?></th>
4678 + <td><?php echo esc_html( $row['current'] ); ?></td>
4679 + <td>
4680 + <?php echo esc_html( $row['recovered'] ); ?>
4681 + <?php if ( ! empty( $row['detail'] ) ) : ?>
4682 + <br><span class="description"><?php echo esc_html( $row['detail'] ); ?></span>
4683 + <?php endif; ?>
4684 + </td>
4685 + </tr>
4686 + <?php endforeach; ?>
4687 + </tbody>
4688 + </table>
4689 +
4690 + <p class="description">
4691 + <?php esc_html_e( 'Only these settings are written. The .htaccess is then rebuilt from them, the same way saving this tab rebuilds it. The stored copy of the file is never written back, so nothing your host, your cache plugin or your CDN added to it is touched.', 'vigilante' ); ?>
4692 + </p>
4693 +
4694 + <?php if ( '' !== $block ) : ?>
4695 + <details>
4696 + <summary><?php esc_html_e( 'Show the saved .htaccess block', 'vigilante' ); ?></summary>
4697 + <textarea readonly rows="12" class="large-text code" onclick="this.select();"><?php echo esc_textarea( $block ); ?></textarea>
4698 + </details>
4699 + <?php endif; ?>
4700 +
4701 + <p class="submit vigilante-submit-buttons">
4702 + <button type="button" class="button button-primary" id="vigilante-recovery-restore">
4703 + <?php esc_html_e( 'Restore these settings', 'vigilante' ); ?>
4704 + </button>
4705 + <button type="button" class="button" id="vigilante-recovery-dismiss">
4706 + <?php esc_html_e( 'No thanks, keep what I have', 'vigilante' ); ?>
4707 + </button>
4708 + </p>
4709 + <div id="vigilante-recovery-result"></div>
4710 + </div>
4711 + <?php
4712 + }
4713 +
3851 4714 private function render_tab_headers() {
3852 4715 $is_disabled = $this->render_module_disabled_notice( 'security_headers' );
3853 4716 // Every setting on this tab ends up in .htaccess, so on a subsite the
3854 4717 // whole tab is somebody else's, values included.
@@ -3854,8 +4717,10 @@
3854 4717 // whole tab is somebody else's, values included.
3855 4718 $vg_shared_locked = $this->shared_files_locked();
3856 4719 $options = $this->get_section_for_display( 'security_headers' );
3857 4720 ?>
4721 + <?php $this->render_headers_recovery_offer(); ?>
4722 +
3858 4723 <form class="vigilante-settings-form <?php echo $is_disabled ? 'vigilante-form-disabled' : ''; ?>" data-section="security_headers" <?php echo $is_disabled ? 'inert' : ''; ?>>
3859 4724 <?php $this->render_shared_files_notice(); ?>
3860 4725 <div id="vigilante-section-headers-main" class="vigilante-settings-section <?php echo $vg_shared_locked ? 'vigilante-form-disabled' : ''; ?>" <?php echo $vg_shared_locked ? 'inert' : ''; ?>>
3861 4726 <h2>
@@ -3897,9 +4762,9 @@
3897 4762 </td>
3898 4763 </tr>
3899 4764 </table>
3900 4765
3901 - <h3><?php esc_html_e( 'Content Security Policy', 'vigilante' ); ?></h3>
4766 + <h3 id="vigilante-section-headers-csp"><?php esc_html_e( 'Content Security Policy', 'vigilante' ); ?></h3>
3902 4767 <table class="form-table">
3903 4768 <tr>
3904 4769 <th scope="row"><?php esc_html_e( 'Enable CSP', 'vigilante' ); ?></th>
3905 4770 <td>
@@ -3919,9 +4784,9 @@
3919 4784 </td>
3920 4785 </tr>
3921 4786 </table>
3922 4787
3923 - <h3><?php esc_html_e( 'HTTPS', 'vigilante' ); ?></h3>
4788 + <h3 id="vigilante-section-headers-force-https"><?php esc_html_e( 'HTTPS', 'vigilante' ); ?></h3>
3924 4789 <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>
3925 4790 <table class="form-table">
3926 4791 <tr>
3927 4792 <th scope="row"><?php esc_html_e( 'Redirect HTTP to HTTPS', 'vigilante' ); ?></th>
@@ -3964,9 +4829,9 @@
3964 4829 </td>
3965 4830 </tr>
3966 4831 </table>
3967 4832
3968 - <h3><?php esc_html_e( 'HSTS (HTTP Strict Transport Security)', 'vigilante' ); ?></h3>
4833 + <h3 id="vigilante-section-headers-hsts"><?php esc_html_e( 'HSTS (HTTP Strict Transport Security)', 'vigilante' ); ?></h3>
3969 4834 <?php $vig_home_https = ( 0 === strpos( (string) get_option( 'home' ), 'https://' ) ); ?>
3970 4835 <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>
3971 4836 <?php if ( ! $vig_home_https ) : ?>
3972 4837 <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>
@@ -4007,9 +4872,9 @@
4007 4872 </td>
4008 4873 </tr>
4009 4874 </table>
4010 4875
4011 - <h3><?php esc_html_e( 'Server Identity', 'vigilante' ); ?></h3>
4876 + <h3 id="vigilante-section-headers-fingerprint"><?php esc_html_e( 'Server Identity', 'vigilante' ); ?></h3>
4012 4877 <p class="description"><?php esc_html_e( 'Hide identifying information that servers expose in responses.', 'vigilante' ); ?></p>
4013 4878 <table class="form-table">
4014 4879 <tr>
4015 4880 <th scope="row"><?php esc_html_e( 'Server Signature', 'vigilante' ); ?></th>
@@ -4031,8 +4896,55 @@
4031 4896 </tr>
4032 4897 </table>
4033 4898 </div>
4034 4899
4900 + <?php $vg_cop = ( isset( $options['cross_origin_policies'] ) && is_array( $options['cross_origin_policies'] ) ) ? $options['cross_origin_policies'] : array(); ?>
4901 + <div id="vigilante-section-headers-cross-origin" class="vigilante-settings-section <?php echo $vg_shared_locked ? 'vigilante-form-disabled' : ''; ?>" <?php echo $vg_shared_locked ? 'inert' : ''; ?>>
4902 + <h2>
4903 + <?php esc_html_e( 'Cross-Origin Policies', 'vigilante' ); ?>
4904 + <span class="vigilante-method-badge htaccess"><?php esc_html_e( 'HTACCESS', 'vigilante' ); ?></span>
4905 + </h2>
4906 + <p><?php esc_html_e( 'Control how other origins may open, embed or fetch your site. Vigilant already sends these headers with the values below.', 'vigilante' ); ?></p>
4907 +
4908 + <table class="form-table">
4909 + <tr>
4910 + <th scope="row"><label for="vigilante-f-security-headers-coop"><?php esc_html_e( 'Cross-Origin-Opener-Policy (COOP)', 'vigilante' ); ?></label></th>
4911 + <td>
4912 + <select id="vigilante-f-security-headers-coop" name="security_headers[cross_origin_policies][opener_policy]">
4913 + <option value="" <?php selected( empty( $vg_cop['opener_policy'] ) ); ?>><?php esc_html_e( 'Disabled (header not sent)', 'vigilante' ); ?></option>
4914 + <option value="unsafe-none" <?php selected( $vg_cop['opener_policy'] ?? '', 'unsafe-none' ); ?>>unsafe-none</option>
4915 + <option value="same-origin-allow-popups" <?php selected( $vg_cop['opener_policy'] ?? '', 'same-origin-allow-popups' ); ?>><?php esc_html_e( 'same-origin-allow-popups (recommended)', 'vigilante' ); ?></option>
4916 + <option value="same-origin" <?php selected( $vg_cop['opener_policy'] ?? '', 'same-origin' ); ?>>same-origin</option>
4917 + </select>
4918 + <p class="description"><?php esc_html_e( '&#9432; Cuts the link between your site and a window from another origin that opened it. Side effect: external tools that open your site in a new tab and talk to it through window.opener, such as Google Tag Assistant, will report that they cannot connect. Pick unsafe-none or Disabled if you need those tools.', 'vigilante' ); ?></p>
4919 + </td>
4920 + </tr>
4921 + <tr>
4922 + <th scope="row"><label for="vigilante-f-security-headers-coep"><?php esc_html_e( 'Cross-Origin-Embedder-Policy (COEP)', 'vigilante' ); ?></label></th>
4923 + <td>
4924 + <select id="vigilante-f-security-headers-coep" name="security_headers[cross_origin_policies][embedder_policy]">
4925 + <option value="unsafe-none" <?php selected( ( $vg_cop['embedder_policy'] ?? 'unsafe-none' ), 'unsafe-none' ); ?>><?php esc_html_e( 'unsafe-none (header not sent)', 'vigilante' ); ?></option>
4926 + <option value="credentialless" <?php selected( $vg_cop['embedder_policy'] ?? '', 'credentialless' ); ?>>credentialless</option>
4927 + <option value="require-corp" <?php selected( $vg_cop['embedder_policy'] ?? '', 'require-corp' ); ?>>require-corp</option>
4928 + </select>
4929 + <p class="description"><?php esc_html_e( '&#9432; Requires every cross-origin resource to opt in. require-corp can block third-party images, fonts, videos and embeds that do not send their own CORP or CORS headers.', 'vigilante' ); ?></p>
4930 + </td>
4931 + </tr>
4932 + <tr>
4933 + <th scope="row"><label for="vigilante-f-security-headers-corp"><?php esc_html_e( 'Cross-Origin-Resource-Policy (CORP)', 'vigilante' ); ?></label></th>
4934 + <td>
4935 + <select id="vigilante-f-security-headers-corp" name="security_headers[cross_origin_policies][resource_policy]">
4936 + <option value="" <?php selected( empty( $vg_cop['resource_policy'] ) ); ?>><?php esc_html_e( 'Disabled (header not sent)', 'vigilante' ); ?></option>
4937 + <option value="same-site" <?php selected( $vg_cop['resource_policy'] ?? '', 'same-site' ); ?>>same-site</option>
4938 + <option value="same-origin" <?php selected( $vg_cop['resource_policy'] ?? '', 'same-origin' ); ?>>same-origin</option>
4939 + <option value="cross-origin" <?php selected( $vg_cop['resource_policy'] ?? '', 'cross-origin' ); ?>><?php esc_html_e( 'cross-origin (recommended)', 'vigilante' ); ?></option>
4940 + </select>
4941 + <p class="description"><?php esc_html_e( '&#9432; Declares who may load resources from this site. same-origin stops hotlinking, but it also breaks CDNs, feed readers and any external service that fetches your images or files.', 'vigilante' ); ?></p>
4942 + </td>
4943 + </tr>
4944 + </table>
4945 + </div>
4946 +
4035 4947 <p class="submit vigilante-submit-buttons">
4036 4948 <?php if ( ! $vg_shared_locked ) : ?>
4037 4949 <button type="submit" class="button button-primary vigilante-save-btn" data-original-text="<?php esc_attr_e( 'Save Settings', 'vigilante' ); ?>">
4038 4950 <?php esc_html_e( 'Save Settings', 'vigilante' ); ?>
@@ -4347,8 +5259,16 @@
4347 5259 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
4348 5260 </h2>
4349 5261 <p><?php esc_html_e( 'Limit the number of simultaneous sessions per user.', 'vigilante' ); ?></p>
4350 5262
5263 + <?php if ( Vigilante_User_Security::session_limit_is_network_wide() ) : ?>
5264 + <div class="notice notice-warning inline">
5265 + <p>
5266 + <?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' ); ?>
5267 + </p>
5268 + </div>
5269 + <?php endif; ?>
5270 +
4351 5271 <table class="form-table">
4352 5272 <tr>
4353 5273 <th scope="row"><?php esc_html_e( 'Enable Session Limits', 'vigilante' ); ?></th>
4354 5274 <td>
@@ -4551,8 +5471,11 @@
4551 5471 <h2 class="vigilante-tools-header">
4552 5472 <?php esc_html_e( 'User security tools', 'vigilante' ); ?>
4553 5473 </h2>
4554 5474
5475 + <?php $this->render_user_actions_notice(); ?>
5476 + <?php if ( ! $this->user_actions_locked() ) : ?>
5477 +
4555 5478 <!-- Force Password Reset -->
4556 5479 <div class="vigilante-tool-box">
4557 5480 <h3><?php esc_html_e( 'Force password reset', 'vigilante' ); ?></h3>
4558 5481 <p class="description"><?php esc_html_e( 'Force users to reset their password. Useful after a security incident. Users will receive an email with a reset link.', 'vigilante' ); ?></p>
@@ -4685,12 +5608,20 @@
4685 5608 </div>
4686 5609
4687 5610 <!-- Pending Registrations -->
4688 5611 <?php
4689 - $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log );
5612 + // Enforcement-only: this instance exists to read the queue, and the
5613 + // flag keeps it from registering the module's own hooks a second
5614 + // time. It is not inert, and saying it was would be a false comment:
5615 + // init_enforcement_hooks() does add its three filters again, on top
5616 + // of the ones already registered. They are idempotent (the same
5617 + // methods of an equivalent instance, deciding on the same user meta),
5618 + // so running them twice in an admin request changes nothing, which is
5619 + // why this is accepted rather than worked around.
5620 + $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log, true );
4690 5621 $pending_users = $user_security->get_pending_users();
4691 5622 ?>
4692 - <div class="vigilante-tool-box vigilante-pending-users-section">
5623 + <div id="vigilante-section-users-pending" class="vigilante-tool-box vigilante-pending-users-section">
4693 5624 <h3>
4694 5625 <?php esc_html_e( 'Pending registrations', 'vigilante' ); ?>
4695 5626 <?php if ( count( $pending_users ) > 0 ) : ?>
4696 5627 <span class="vigilante-badge vigilante-badge-warning"><?php echo esc_html( count( $pending_users ) ); ?></span>
@@ -4696,9 +5627,20 @@
4696 5627 <span class="vigilante-badge vigilante-badge-warning"><?php echo esc_html( count( $pending_users ) ); ?></span>
4697 5628 <?php endif; ?>
4698 5629 </h3>
4699 5630
4700 - <?php if ( empty( $registration['enabled'] ) ) : ?>
5631 + <?php
5632 + /*
5633 + * The queue is shown whenever there is somebody in it, even with
5634 + * the feature off. Since 2.11.10 an account already waiting stays
5635 + * blocked when the feature is switched off, which is the point:
5636 + * turning a setting off must not quietly let in people an
5637 + * administrator decided not to approve. But hiding the table then
5638 + * left them locked out with no button anywhere to approve or
5639 + * reject them. Found by the cross review of 2.11.10.
5640 + */
5641 + ?>
5642 + <?php if ( empty( $registration['enabled'] ) && empty( $pending_users ) ) : ?>
4701 5643 <p class="description">
4702 5644 <span class="dashicons dashicons-info" style="color: #72aee6;"></span>
4703 5645 <?php esc_html_e( 'Registration approval is disabled. Enable it in the settings above to require manual approval for new users.', 'vigilante' ); ?>
4704 5646 </p>
@@ -4707,8 +5649,9 @@
4707 5649 <span class="dashicons dashicons-yes-alt"></span>
4708 5650 <p><?php esc_html_e( 'No pending registrations.', 'vigilante' ); ?></p>
4709 5651 </div>
4710 5652 <?php else : ?>
5653 + <?php $this->render_user_actions_notice(); ?>
4711 5654 <table class="wp-list-table widefat fixed striped vigilante-pending-users-table">
4712 5655 <thead>
4713 5656 <tr>
4714 5657 <th><?php esc_html_e( 'User', 'vigilante' ); ?></th>
@@ -4718,9 +5661,9 @@
4718 5661 </tr>
4719 5662 </thead>
4720 5663 <tbody>
4721 5664 <?php foreach ( $pending_users as $pending_user ) :
4722 - $pending_since = get_user_meta( $pending_user->ID, 'vigilante_pending_since', true );
5665 + $pending_since = get_user_meta( $pending_user->ID, Vigilante_User_Security::site_user_meta_key( 'vigilante_pending_since' ), true );
4723 5666 ?>
4724 5667 <tr data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>">
4725 5668 <td>
4726 5669 <?php echo get_avatar( $pending_user->ID, 32 ); ?>
@@ -4737,12 +5680,12 @@
4737 5680 }
4738 5681 ?>
4739 5682 </td>
4740 5683 <td>
4741 - <button type="button" class="button button-small vigilante-approve-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>">
5684 + <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() ); ?>>
4742 5685 <?php esc_html_e( 'Approve', 'vigilante' ); ?>
4743 5686 </button>
4744 - <button type="button" class="button button-small vigilante-reject-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>" style="color: #d63638;">
5687 + <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() ); ?>>
4745 5688 <?php esc_html_e( 'Reject', 'vigilante' ); ?>
4746 5689 </button>
4747 5690 </td>
4748 5691 </tr>
@@ -4864,8 +5807,10 @@
4864 5807 </button>
4865 5808 </p>
4866 5809 </div>
4867 5810 </div>
5811 +
5812 + <?php endif; ?>
4868 5813 </div>
4869 5814 <?php
4870 5815 }
4871 5816
@@ -5579,8 +6524,9 @@
5579 6524 'user' => (string) ( $log->user_login ?? '' ),
5580 6525 'ip' => $ip_val,
5581 6526 'user_agent' => $ua_val,
5582 6527 'request_method' => (string) $request_method,
6528 + 'request_uri' => Vigilante_Activity_Log::extract_request_uri( $log->extra_data ?? '' ),
5583 6529 'date' => (string) ( $log->created_at ?? '' ),
5584 6530 'severity' => (string) ( $log->severity ?? 'info' ),
5585 6531 'is_ip_whitelisted' => ( '' !== $ip_val && in_array( $ip_val, $ip_whitelist, true ) ),
5586 6532 'is_ip_blacklisted' => ( '' !== $ip_val && in_array( $ip_val, $ip_blacklist, true ) ),
@@ -5586,8 +6532,19 @@
5586 6532 'is_ip_blacklisted' => ( '' !== $ip_val && in_array( $ip_val, $ip_blacklist, true ) ),
5587 6533 'is_ua_whitelisted' => ( '' !== $ua_val && in_array( $ua_val, $ua_whitelist, true ) ),
5588 6534 'is_ua_blacklisted' => ( '' !== $ua_val && in_array( $ua_val, $ua_blacklist, true ) ),
5589 6535 );
6536 + // Self-protection entries carry what happened, what
6537 + // it means and what to do, from the same catalogue
6538 + // the File Integrity box uses.
6539 + $vg_self_event = Vigilante_Self_Integrity_Guidance::for_log_event(
6540 + (string) ( $log->event_action ?? '' ),
6541 + $log->extra_data ?? '',
6542 + (string) ( $log->severity ?? 'info' )
6543 + );
6544 + if ( null !== $vg_self_event ) {
6545 + $details['self'] = $vg_self_event;
6546 + }
5590 6547 $display_type = isset( $type_labels[ $log->event_type ] ) ? $type_labels[ $log->event_type ] : $log->event_type;
5591 6548 $display_severity = isset( $severity_labels[ $log->severity ] ) ? $severity_labels[ $log->severity ] : $log->severity;
5592 6549 ?>
5593 6550 <tr class="vigilante-severity-<?php echo esc_attr( $log->severity ); ?>">
@@ -5630,15 +6587,439 @@
5630 6587
5631 6588 /**
5632 6589 * Render File Integrity tab
5633 6590 */
6591 + /**
6592 + * Findings of the self-check grouped by the case that explains them, worst
6593 + * first: ten modified files are one case with ten paths, not ten copies of
6594 + * the same explanation.
6595 + *
6596 + * @param array $findings Findings from the state.
6597 + * @return array
6598 + */
6599 + private function self_findings_by_case( $findings ) {
6600 + $groups = array();
6601 + foreach ( (array) $findings as $finding ) {
6602 + if ( ! is_array( $finding ) || 'info' === ( $finding['severity'] ?? '' ) ) {
6603 + continue;
6604 + }
6605 + $guidance = Vigilante_Self_Integrity_Guidance::for_finding( $finding );
6606 + $key = $guidance['key'];
6607 + if ( ! isset( $groups[ $key ] ) ) {
6608 + $guidance['files'] = array();
6609 + $guidance['severity'] = 'warning';
6610 + $groups[ $key ] = $guidance;
6611 + }
6612 + $file = isset( $finding['file'] ) ? (string) $finding['file'] : '';
6613 + if ( '' !== $file && ! in_array( $file, $groups[ $key ]['files'], true ) ) {
6614 + $groups[ $key ]['files'][] = $file;
6615 + }
6616 + if ( 'critical' === ( $finding['severity'] ?? '' ) ) {
6617 + $groups[ $key ]['severity'] = 'critical';
6618 + }
6619 + }
6620 + uasort(
6621 + $groups,
6622 + function ( $a, $b ) {
6623 + $rank = array( 'critical' => 0, 'warning' => 1 );
6624 + $ra = isset( $rank[ $a['severity'] ] ) ? $rank[ $a['severity'] ] : 2;
6625 + $rb = isset( $rank[ $b['severity'] ] ) ? $rank[ $b['severity'] ] : 2;
6626 + return $ra - $rb;
6627 + }
6628 + );
6629 + return $groups;
6630 + }
6631 +
6632 + /**
6633 + * Human label for the context that ran the last self-check.
6634 + *
6635 + * @param string $context Stored context.
6636 + * @return string
6637 + */
6638 + private function self_context_label( $context ) {
6639 + switch ( (string) $context ) {
6640 + case 'scan':
6641 + return __( 'during a file integrity scan', 'vigilante' );
6642 + case 'upgrader':
6643 + return __( 'right after updating Vigilant', 'vigilante' );
6644 + case 'version_change':
6645 + return __( 'after a version change made outside the updater', 'vigilante' );
6646 + case 'migration':
6647 + return __( 'while updating to this version', 'vigilante' );
6648 + case 'activation':
6649 + return __( 'when Vigilant was activated', 'vigilante' );
6650 + case 'watchdog':
6651 + return __( 'from the scheduled task watchdog', 'vigilante' );
6652 + }
6653 + return '';
6654 + }
6655 +
6656 + /**
6657 + * Short line for the badge of the box, the notices and the Dashboard strip.
6658 + *
6659 + * @param string $tone Tone from Vigilante_Self_Integrity::tone().
6660 + * @param array $state State.
6661 + * @return string
6662 + */
6663 + private function self_integrity_headline( $tone, $state ) {
6664 + $files = isset( $state['files_checked'] ) ? (int) $state['files_checked'] : 0;
6665 + $anchors = ( isset( $state['anchors'] ) && is_array( $state['anchors'] ) ) ? $state['anchors'] : array();
6666 + switch ( $tone ) {
6667 + case 'critical':
6668 + return __( 'Changes detected in Vigilant own files', 'vigilante' );
6669 + case 'warning':
6670 + return ( $files < 1 )
6671 + ? __( 'Vigilant could not check its own files', 'vigilante' )
6672 + : __( 'Vigilant self-protection needs your attention', 'vigilante' );
6673 + case 'off':
6674 + return __( 'Self-protection is switched off by code', 'vigilante' );
6675 + case 'none':
6676 + return __( 'Vigilant has not checked its own files yet', 'vigilante' );
6677 + }
6678 + return sprintf(
6679 + /* translators: 1: number of files verified, 2: number of references available, out of three */
6680 + __( 'Verified: %1$d files, %2$d of 3 references', 'vigilante' ),
6681 + $files,
6682 + count( array_filter( $anchors ) )
6683 + );
6684 + }
6685 +
6686 + /**
6687 + * Vigilant self-protection box: the first block of the File Integrity
6688 + * results, with its own colour by severity, what each finding means and
6689 + * how to fix it.
6690 + *
6691 + * It renders whether or not a scan has been stored, because the self-check
6692 + * also runs after every update and once a day: before 3.0.0 this lived in
6693 + * a line above the numeric cards of the last scan, where it was invisible
6694 + * and, without a stored scan, absent.
6695 + *
6696 + * @param array $fi_options File Integrity settings section.
6697 + */
6698 + /**
6699 + * Everything the screens need to say about self-protection, read once:
6700 + * the state, its findings grouped by case, and the tone that colours the
6701 + * box, the menu counter and the notices.
6702 + *
6703 + * @param array|null $fi_options File Integrity settings, read if not given.
6704 + * @return array { state, findings, groups, tone, enabled }
6705 + */
6706 + private function self_integrity_summary( $fi_options = null ) {
6707 + // Four to six screens ask for this in the same page load (menu, notice,
6708 + // box, strip, both scores). The option is cached by the core options
6709 + // layer, but the grouping and the tone are not: memoize per request.
6710 + static $cached = null;
6711 + if ( null !== $cached && ! is_array( $fi_options ) ) {
6712 + return $cached;
6713 + }
6714 + if ( ! is_array( $fi_options ) ) {
6715 + $fi_options = (array) $this->settings->get_section( 'file_integrity' );
6716 + }
6717 + $enabled = Vigilante_Self_Integrity::is_on();
6718 + $state = Vigilante_Self_Integrity::display_state();
6719 + $findings = Vigilante_Self_Integrity::state_findings( $state, $enabled );
6720 + $summary = array(
6721 + 'state' => $state,
6722 + 'findings' => $findings,
6723 + 'groups' => $this->self_findings_by_case( $findings ),
6724 + 'tone' => Vigilante_Self_Integrity::tone( $state, $enabled ),
6725 + 'enabled' => $enabled,
6726 + );
6727 + $cached = $summary;
6728 + return $summary;
6729 + }
6730 +
6731 + private function render_self_protection_box( $fi_options ) {
6732 + $summary = $this->self_integrity_summary( $fi_options );
6733 + $enabled = $summary['enabled'];
6734 + $state = $summary['state'];
6735 + $findings = $summary['findings'];
6736 + $groups = $summary['groups'];
6737 + $tone = $summary['tone'];
6738 + $total = isset( $state['last_findings_total'] ) ? (int) $state['last_findings_total'] : count( $findings );
6739 + $status = array(
6740 + 'status' => isset( $state['last_status'] ) ? (string) $state['last_status'] : '',
6741 + 'files' => isset( $state['files_checked'] ) ? (int) $state['files_checked'] : 0,
6742 + 'anchors' => ( isset( $state['anchors'] ) && is_array( $state['anchors'] ) ) ? $state['anchors'] : array(),
6743 + 'enabled' => $enabled,
6744 + 'has_run' => ! empty( $state['last_check'] ),
6745 + );
6746 +
6747 + /*
6748 + * Nothing to do, nothing to open: one line. A card with a big icon and
6749 + * folded sections for "everything is fine" is furniture, and furniture
6750 + * is what made the previous version of this invisible.
6751 + */
6752 + if ( empty( $groups ) ) {
6753 + $this->render_self_line( $tone, $state, $status );
6754 + return;
6755 + }
6756 +
6757 + $datetime_format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' );
6758 + $context_label = $this->self_context_label( isset( $state['last_context'] ) ? $state['last_context'] : '' );
6759 + $icon = ( 'critical' === $tone ) ? 'dashicons-shield' : 'dashicons-warning';
6760 + ?>
6761 + <div id="vigilante-section-fi-self" class="vigilante-settings-section vigilante-self-box vigilante-self-box--<?php echo esc_attr( $tone ); ?>">
6762 + <div class="vigilante-self-card">
6763 + <div class="vigilante-self-head">
6764 + <span class="vigilante-self-icon"><span class="dashicons <?php echo esc_attr( $icon ); ?>" aria-hidden="true"></span></span>
6765 + <div class="vigilante-self-head-text">
6766 + <h2><?php echo esc_html( $this->self_integrity_headline( $tone, $state ) ); ?></h2>
6767 + <p class="vigilante-self-meta">
6768 + <?php
6769 + printf(
6770 + /* translators: %s: date and time of the last self-check */
6771 + esc_html__( 'Checked on %s', 'vigilante' ),
6772 + esc_html( wp_date( $datetime_format, (int) $state['last_check'] ) )
6773 + );
6774 + if ( '' !== $context_label ) {
6775 + echo ', ' . esc_html( $context_label );
6776 + }
6777 + if ( $status['files'] > 0 ) {
6778 + echo ', ';
6779 + printf(
6780 + /* translators: 1: number of files checked, 2: number of references available, out of three */
6781 + esc_html__( '%1$d files against %2$d of 3 references', 'vigilante' ),
6782 + (int) $status['files'],
6783 + (int) count( array_filter( $status['anchors'] ) )
6784 + );
6785 + }
6786 + ?>
6787 + </p>
6788 + </div>
6789 + </div>
6790 +
6791 + <div class="vigilante-self-cases">
6792 + <?php
6793 + $first = true;
6794 + foreach ( $groups as $group ) {
6795 + $this->render_self_case( $group, $group['files'], $group['severity'], $first );
6796 + $first = false;
6797 + }
6798 +
6799 + if ( in_array( $tone, array( 'critical', 'warning' ), true ) ) {
6800 + $can_repair = class_exists( 'Vigilante_Self_Repair' ) && Vigilante_Self_Repair::can_repair();
6801 + if ( ! $can_repair ) {
6802 + if ( class_exists( 'Vigilante_Self_Repair' ) && ! Vigilante_Self_Repair::folder_is_the_distributed_one() ) {
6803 + $vg_reason = 'renamed';
6804 + } elseif ( ! wp_is_file_mod_allowed( 'capability_update_core' ) ) {
6805 + // With DISALLOW_FILE_MODS nobody can do it from the admin,
6806 + // not even a network administrator: telling a subsite admin
6807 + // to ask theirs would send them to someone equally blocked.
6808 + $vg_reason = 'no_caps';
6809 + } elseif ( is_multisite() && ! current_user_can( 'update_plugins' ) ) {
6810 + $vg_reason = 'network';
6811 + } else {
6812 + $vg_reason = 'no_caps';
6813 + }
6814 + $this->render_self_case(
6815 + array(
6816 + 'title' => __( 'How to repair it here', 'vigilante' ),
6817 + 'meaning' => __( 'This site does not let Vigilant replace its own files from this screen.', 'vigilante' ),
6818 + 'steps' => Vigilante_Self_Integrity_Guidance::manual_steps( $vg_reason ),
6819 + ),
6820 + array(),
6821 + '',
6822 + false
6823 + );
6824 + }
6825 + }
6826 + ?>
6827 + </div>
6828 +
6829 + <?php if ( $total > count( $findings ) ) : ?>
6830 + <p class="description">
6831 + <?php
6832 + printf(
6833 + /* translators: 1: findings shown, 2: findings found in total */
6834 + esc_html__( 'Showing %1$d of %2$d findings: the rest are of the same kind.', 'vigilante' ),
6835 + (int) count( $findings ),
6836 + (int) $total
6837 + );
6838 + ?>
6839 + </p>
6840 + <?php endif; ?>
6841 +
6842 + <?php
6843 + /*
6844 + * Reinstalling fixes files, not a filter someone wrote or a hook
6845 + * someone removed: the button only appears when at least one of the
6846 + * cases on screen is one a clean copy solves. Offering it next to
6847 + * "switched off by code" was telling the person to fix something
6848 + * else.
6849 + */
6850 + $vg_offer_repair = false;
6851 + foreach ( $groups as $vg_group ) {
6852 + if ( ! empty( $vg_group['repair'] ) ) {
6853 + $vg_offer_repair = true;
6854 + break;
6855 + }
6856 + }
6857 + ?>
6858 + <p class="vigilante-self-actions">
6859 + <?php if ( $vg_offer_repair && class_exists( 'Vigilante_Self_Repair' ) && Vigilante_Self_Repair::can_repair() ) : ?>
6860 + <a class="button button-primary" href="<?php echo esc_url( Vigilante_Self_Repair::action_url() ); ?>">
6861 + <?php esc_html_e( 'Repair Vigilant', 'vigilante' ); ?>
6862 + </a>
6863 + <span class="description">
6864 + <?php esc_html_e( 'It downloads a clean copy from WordPress.org, asks before changing anything, and keeps your settings and log.', 'vigilante' ); ?>
6865 + </span>
6866 + <?php else : ?>
6867 + <span class="description">
6868 + <?php esc_html_e( 'After fixing it, run a new scan with the Run Scan Now button above.', 'vigilante' ); ?>
6869 + </span>
6870 + <?php endif; ?>
6871 + </p>
6872 + </div>
6873 + </div>
6874 + <?php
6875 + }
6876 +
6877 + /**
6878 + * The same state when there is nothing to act on: one line inside the same
6879 + * card, so the first block of the results is always the same object.
6880 + *
6881 + * @param string $tone Tone from Vigilante_Self_Integrity::tone().
6882 + * @param array $state State.
6883 + * @param array $status Arguments for the guidance catalogue.
6884 + */
6885 + private function render_self_line( $tone, $state, $status ) {
6886 + $guidance = Vigilante_Self_Integrity_Guidance::for_status( $status );
6887 + $icon = ( 'ok' === $tone ) ? 'dashicons-yes-alt' : 'dashicons-shield';
6888 + ?>
6889 + <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">
6890 + <div class="vigilante-self-card vigilante-self-card--quiet">
6891 + <p class="vigilante-self-line">
6892 + <span class="dashicons <?php echo esc_attr( $icon ); ?>" aria-hidden="true"></span>
6893 + <strong><?php esc_html_e( 'Vigilant self-protection:', 'vigilante' ); ?></strong>
6894 + <span class="vigilante-self-line-state"><?php echo esc_html( $this->self_integrity_headline( $tone, $state ) ); ?></span>
6895 + <?php if ( ! empty( $state['last_check'] ) ) : ?>
6896 + <span class="vigilante-self-line-meta">
6897 + <?php
6898 + printf(
6899 + /* translators: %s: human time difference, like "2 hours" */
6900 + esc_html__( 'checked %s ago', 'vigilante' ),
6901 + esc_html( human_time_diff( (int) $state['last_check'], time() ) )
6902 + );
6903 + ?>
6904 + </span>
6905 + <?php endif; ?>
6906 + </p>
6907 + <?php if ( ! empty( $guidance['steps'] ) && 'none' === $tone ) : ?>
6908 + <p class="description vigilante-self-line-help"><?php echo esc_html( $guidance['steps'][0] ); ?></p>
6909 + <?php endif; ?>
6910 + </div>
6911 + </div>
6912 + <?php
6913 + }
6914 +
6915 + /**
6916 + * One case of the self-protection box, as a row that opens: what it is, how
6917 + * many files, its severity, and inside, the files, what it means and what to
6918 + * do. Plain HTML details, so there is no JavaScript between a finding about
6919 + * the security plugin and the person reading it.
6920 + *
6921 + * @param array $guidance Guidance entry (title, meaning, steps).
6922 + * @param array $files Paths this case was found in.
6923 + * @param string $severity critical|warning, empty for the how-to-repair row.
6924 + * @param bool $open Whether the row starts open.
6925 + */
6926 + private function render_self_case( $guidance, $files = array(), $severity = '', $open = false ) {
6927 + $shown = array_slice( (array) $files, 0, 20 );
6928 + $hidden = count( (array) $files ) - count( $shown );
6929 + ?>
6930 + <details class="vigilante-self-case vigilante-self-case--<?php echo esc_attr( '' !== $severity ? $severity : 'plain' ); ?>"<?php echo $open ? ' open' : ''; ?>>
6931 + <summary>
6932 + <span class="vigilante-self-case-title"><?php echo esc_html( $guidance['title'] ); ?></span>
6933 + <?php if ( ! empty( $files ) ) : ?>
6934 + <span class="vigilante-self-case-count">
6935 + <?php
6936 + printf(
6937 + /* translators: %d: number of files of this finding */
6938 + esc_html( _n( '%d file', '%d files', count( (array) $files ), 'vigilante' ) ),
6939 + (int) count( (array) $files )
6940 + );
6941 + ?>
6942 + </span>
6943 + <?php endif; ?>
6944 + <?php if ( '' !== $severity ) : ?>
6945 + <span class="vigilante-self-case-severity">
6946 + <?php echo esc_html( 'critical' === $severity ? __( 'critical', 'vigilante' ) : __( 'warning', 'vigilante' ) ); ?>
6947 + </span>
6948 + <?php endif; ?>
6949 + </summary>
6950 + <div class="vigilante-self-case-body">
6951 + <?php if ( ! empty( $shown ) ) : ?>
6952 + <ul class="vigilante-self-paths">
6953 + <?php foreach ( $shown as $file ) : ?>
6954 + <li><code><?php echo esc_html( $file ); ?></code></li>
6955 + <?php endforeach; ?>
6956 + <?php if ( $hidden > 0 ) : ?>
6957 + <li>
6958 + <?php
6959 + printf(
6960 + /* translators: %d: number of additional files */
6961 + esc_html__( 'and %d more', 'vigilante' ),
6962 + (int) $hidden
6963 + );
6964 + ?>
6965 + </li>
6966 + <?php endif; ?>
6967 + </ul>
6968 + <?php endif; ?>
6969 + <p class="vigilante-self-meaning">
6970 + <strong><?php esc_html_e( 'What it means:', 'vigilante' ); ?></strong>
6971 + <?php echo esc_html( $guidance['meaning'] ); ?>
6972 + </p>
6973 + <?php if ( ! empty( $guidance['steps'] ) ) : ?>
6974 + <p class="vigilante-self-todo"><strong><?php esc_html_e( 'What to do:', 'vigilante' ); ?></strong></p>
6975 + <ol class="vigilante-self-steps">
6976 + <?php foreach ( $guidance['steps'] as $step ) : ?>
6977 + <li><?php echo esc_html( $step ); ?></li>
6978 + <?php endforeach; ?>
6979 + </ol>
6980 + <?php endif; ?>
6981 + </div>
6982 + </details>
6983 + <?php
6984 + }
6985 +
5634 6986 private function render_tab_file_integrity() {
5635 6987 $is_disabled = $this->render_module_disabled_notice( 'file_integrity' );
5636 6988 $options = $this->settings->get_section( 'file_integrity' );
6989 + // On the main site of a network the critical-file scan is the network's
6990 + // canary for a change to wp-config.php or the root .htaccess, so a
6991 + // main-site admin without network rights cannot turn it off. Since
6992 + // 2.11.8; see Vigilante_Settings::get_main_site_file_settings().
6993 + $vg_main_locked = $this->main_site_files_locked();
5637 6994 $last_scan = get_option( 'vigilante_last_integrity_scan' );
5638 6995 $last_results = get_option( 'vigilante_last_integrity_results' );
5639 6996 $ignored_files = get_option( 'vigilante_ignored_files', array() );
5640 6997
6998 + /*
6999 + * Vigilant own findings are shown in their own box, the first of the
7000 + * results, with what each one means and how to fix it. They are taken
7001 + * out of the generic tables here (and out of the counters above them)
7002 + * so the same finding is not reported twice and so no row of the
7003 + * security plugin's own files offers an Ignore button. The stored
7004 + * results keep them: the scan email reads its own section from there.
7005 + */
7006 + if ( is_array( $last_results ) ) {
7007 + foreach ( array( 'modified', 'suspicious', 'extra', 'missing' ) as $vg_bucket ) {
7008 + if ( empty( $last_results[ $vg_bucket ] ) || ! is_array( $last_results[ $vg_bucket ] ) ) {
7009 + continue;
7010 + }
7011 + $last_results[ $vg_bucket ] = array_values(
7012 + array_filter(
7013 + $last_results[ $vg_bucket ],
7014 + function ( $vg_item ) {
7015 + return ! ( is_array( $vg_item ) && 'vigilante_self' === ( $vg_item['type'] ?? '' ) );
7016 + }
7017 + )
7018 + );
7019 + }
7020 + }
7021 +
5641 7022 // Backward compat: convert old notify_on_changes to notify_level
5642 7023 $notify_level = $options['notify_level'] ?? '';
5643 7024 if ( empty( $notify_level ) ) {
5644 7025 $notify_level = ! empty( $options['notify_on_changes'] ) ? 'all' : 'disabled';
@@ -5760,10 +7141,13 @@
5760 7141 <?php esc_html_e( 'Uploads directory (detect PHP files, double extensions, .htaccess)', 'vigilante' ); ?>
5761 7142 </label>
5762 7143 <br>
5763 7144 <label>
5764 - <input type="checkbox" name="file_integrity[scan_critical_config]" value="1" <?php checked( $options['scan_critical_config'] ?? true ); ?>>
7145 + <input type="checkbox" name="file_integrity[scan_critical_config]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( $options['scan_critical_config'] ?? true ); ?>>
5765 7146 <?php esc_html_e( 'Critical config files (wp-config.php, .htaccess baseline monitoring)', 'vigilante' ); ?>
7147 + <?php if ( $vg_main_locked ) : ?>
7148 + <span class="description" style="display:block;margin-left:24px;"><?php echo esc_html( Vigilante_Settings::get_shared_files_notice() ); ?></span>
7149 + <?php endif; ?>
5766 7150 </label>
5767 7151 <br>
5768 7152 <label>
5769 7153 <input type="checkbox" name="file_integrity[check_closed_plugins]" value="1" <?php checked( $options['check_closed_plugins'] ?? true ); ?>>
@@ -5818,8 +7202,10 @@
5818 7202 </form>
5819 7203
5820 7204 <div id="vigilante-scan-results" class="vigilante-settings-section" style="display:none;"></div>
5821 7205
7206 + <?php $this->render_self_protection_box( $options ); ?>
7207 +
5822 7208 <?php if ( $last_scan || $has_closed || $closed_last_check > 0 ) : ?>
5823 7209 <div id="vigilante-section-fi-last-scan" class="vigilante-settings-section">
5824 7210 <h2><?php esc_html_e( 'Last Scan Results', 'vigilante' ); ?></h2>
5825 7211 <?php if ( $last_scan ) : ?>
@@ -5944,8 +7330,11 @@
5944 7330 }
5945 7331 } else {
5946 7332 $file_path = (string) $item;
5947 7333 }
7334 + if ( 'vigilante_self' === $file_type ) {
7335 + $file_type = __( 'Vigilant (self)', 'vigilante' );
7336 + }
5948 7337 ?>
5949 7338 <tr>
5950 7339 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
5951 7340 <td><code style="color: #d63638;"><?php echo esc_html( $file_path ); ?></code></td>
@@ -5985,8 +7374,11 @@
5985 7374 foreach ( $last_results['extra'] as $item ) {
5986 7375 $file_path = is_array( $item ) ? ( $item['file'] ?? '' ) : (string) $item;
5987 7376 $file_reason = is_array( $item ) ? ( $item['reason'] ?? __( 'Unknown', 'vigilante' ) ) : __( 'Unknown', 'vigilante' );
5988 7377 $file_type = is_array( $item ) ? ( $item['type'] ?? 'unknown' ) : 'unknown';
7378 + if ( 'vigilante_self' === $file_type ) {
7379 + $file_type = __( 'Vigilant (self)', 'vigilante' );
7380 + }
5989 7381 ?>
5990 7382 <tr>
5991 7383 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
5992 7384 <td><code style="color: #b32d2e;"><?php echo esc_html( $file_path ); ?></code></td>
@@ -6017,8 +7409,19 @@
6017 7409 $regular_modified[] = $item;
6018 7410 }
6019 7411 }
6020 7412 }
7413 + // A missing file of Vigilant is critical, and the missing
7414 + // files of the generic scan have no table: it is listed with
7415 + // the modified files, or the tab said "All files passed" with
7416 + // a module deleted.
7417 + if ( $last_results && ! empty( $last_results['missing'] ) && is_array( $last_results['missing'] ) ) {
7418 + foreach ( $last_results['missing'] as $item ) {
7419 + if ( is_array( $item ) && 'vigilante_self' === ( $item['type'] ?? '' ) ) {
7420 + $regular_modified[] = $item;
7421 + }
7422 + }
7423 + }
6021 7424 ?>
6022 7425
6023 7426 <?php if ( ! empty( $critical_modified ) ) : ?>
6024 7427 <div class="vigilante-file-list vigilante-critical-config-files">
@@ -6042,9 +7445,15 @@
6042 7445 $crit_diff = $crit_item['diff'] ?? array();
6043 7446 $crit_id = sanitize_html_class( $crit_file );
6044 7447 $added_count = is_array( $crit_diff ) ? count( $crit_diff['added'] ?? array() ) : 0;
6045 7448 $removed_count = is_array( $crit_diff ) ? count( $crit_diff['removed'] ?? array() ) : 0;
6046 - $diff_unavailable = is_array( $crit_diff ) && ! empty( $crit_diff['unavailable'] );
7449 + // The lines of a shared file are for whoever approves it. Results
7450 + // stored before 2.11.8 on the main site still carry them, so the
7451 + // screen asks too, not only the scan that wrote them.
7452 + $diff_network = ( is_array( $crit_diff ) && ! empty( $crit_diff['network'] ) ) || $this->critical_approval_locked();
7453 + $diff_rescan = is_array( $crit_diff ) && ! empty( $crit_diff['rescan'] );
7454 + $diff_redaction = is_array( $crit_diff ) && ! empty( $crit_diff['redaction'] );
7455 + $diff_unavailable = $diff_network || ( is_array( $crit_diff ) && ! empty( $crit_diff['unavailable'] ) );
6047 7456 ?>
6048 7457 <tr>
6049 7458 <td><code style="color: #e36210;"><?php echo esc_html( $crit_file ); ?></code></td>
6050 7459 <td>
@@ -6067,18 +7476,36 @@
6067 7476 <td>
6068 7477 <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' ); ?>">
6069 7478 <?php esc_html_e( 'Review changes', 'vigilante' ); ?>
6070 7479 </button>
6071 - <button type="button" class="button button-small button-primary vigilante-approve-critical-file" data-file="<?php echo esc_attr( $crit_file ); ?>">
6072 - <?php esc_html_e( 'Approve', 'vigilante' ); ?>
6073 - </button>
7480 + <?php if ( $this->critical_approval_locked() ) : ?>
7481 + <span class="description" style="display:block;margin-top:4px;">
7482 + <?php echo esc_html( $this->critical_approval_notice() ); ?>
7483 + </span>
7484 + <?php else : ?>
7485 + <button type="button" class="button button-small button-primary vigilante-approve-critical-file" data-file="<?php echo esc_attr( $crit_file ); ?>">
7486 + <?php esc_html_e( 'Approve', 'vigilante' ); ?>
7487 + </button>
7488 + <?php endif; ?>
6074 7489 </td>
6075 7490 </tr>
6076 7491 <tr id="vigilante-critical-content-<?php echo esc_attr( $crit_id ); ?>" class="vigilante-critical-content-row" style="display:none;">
6077 7492 <td colspan="3" style="padding: 0;">
6078 7493 <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;">
6079 - <?php if ( $diff_unavailable ) : ?>
7494 + <?php if ( $diff_network ) : ?>
6080 7495 <p style="color: #50575e; font-style: italic; margin: 0;">
7496 + <?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' ); ?>
7497 + </p>
7498 + <?php elseif ( $diff_rescan ) : ?>
7499 + <p style="color: #50575e; font-style: italic; margin: 0;">
7500 + <?php esc_html_e( 'Run a new scan to see the line changes of this file.', 'vigilante' ); ?>
7501 + </p>
7502 + <?php elseif ( $diff_redaction ) : ?>
7503 + <p style="color: #50575e; font-style: italic; margin: 0;">
7504 + <?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' ); ?>
7505 + </p>
7506 + <?php elseif ( $diff_unavailable ) : ?>
7507 + <p style="color: #50575e; font-style: italic; margin: 0;">
6081 7508 <?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' ); ?>
6082 7509 </p>
6083 7510 <?php elseif ( empty( $crit_diff['added'] ) && empty( $crit_diff['removed'] ) ) : ?>
6084 7511 <p style="color: #50575e; font-style: italic; margin: 0;">
@@ -6106,9 +7533,9 @@
6106 7533 <?php endif; ?>
6107 7534
6108 7535 <?php if ( $has_closed ) : ?>
6109 7536 <div class="vigilante-file-list vigilante-closed-plugins">
6110 - <h3 style="color: #d63638;"><?php esc_html_e( 'Closed + Removed Plugins', 'vigilante' ); ?></h3>
7537 + <h3 id="vigilante-section-fi-closed-plugins" style="color: #d63638;"><?php esc_html_e( 'Closed + Removed Plugins', 'vigilante' ); ?></h3>
6111 7538 <p class="description" style="color: #d63638;">
6112 7539 <?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' ); ?>
6113 7540 </p>
6114 7541 <table class="wp-list-table widefat striped">
@@ -6193,8 +7620,11 @@
6193 7620 }
6194 7621 } else {
6195 7622 $file_path = (string) $item;
6196 7623 }
7624 + if ( 'vigilante_self' === $file_type ) {
7625 + $file_type = __( 'Vigilant (self)', 'vigilante' );
7626 + }
6197 7627 ?>
6198 7628 <tr>
6199 7629 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
6200 7630 <td><code><?php echo esc_html( $file_path ); ?></code></td>
@@ -6208,9 +7638,16 @@
6208 7638 </table>
6209 7639 </div>
6210 7640 <?php endif; ?>
6211 7641
6212 - <?php if ( $last_results && empty( $last_results['modified'] ) && empty( $last_results['suspicious'] ) && empty( $last_results['extra'] ) && ! $has_closed ) : ?>
7642 + <?php
7643 + // "All files passed" is about every file, and Vigilant's own
7644 + // are files too: with the self-protection box in red or amber
7645 + // above, this line contradicted it (its findings no longer
7646 + // travel in the tables below).
7647 + $vg_self_alarm = in_array( $this->self_integrity_summary( $options )['tone'], array( 'critical', 'off', 'warning' ), true );
7648 + ?>
7649 + <?php if ( $last_results && ! $vg_self_alarm && empty( $critical_modified ) && empty( $regular_modified ) && empty( $last_results['suspicious'] ) && empty( $last_results['extra'] ) && ! $has_closed ) : ?>
6213 7650 <p class="vigilante-all-clear" style="color: #00a32a; font-weight: bold;">
6214 7651 <?php esc_html_e( 'Good Job! All files passed integrity check. No issues found.', 'vigilante' ); ?>
6215 7652 </p>
6216 7653 <?php endif; ?>
@@ -6317,8 +7754,15 @@
6317 7754 if ( ! current_user_can( 'manage_options' ) ) {
6318 7755 wp_die( esc_html__( 'Permission denied.', 'vigilante' ), 403 );
6319 7756 }
6320 7757
7758 + // The archive carries wp-config.php, which a whole network shares. On a
7759 + // network manage_options is held by every subsite administrator, so the
7760 + // same gate the writers use applies here.
7761 + if ( ! Vigilante_Settings::can_write_shared_files() ) {
7762 + wp_die( esc_html( Vigilante_Settings::get_shared_files_notice() ), 403 );
7763 + }
7764 +
6321 7765 $backup_manager = new Vigilante_Backup_Manager();
6322 7766 $result = $backup_manager->stream_files_zip();
6323 7767
6324 7768 // stream_files_zip() exits on success; only a WP_Error returns here.
@@ -6407,10 +7851,30 @@
6407 7851
6408 7852 // Read ONLY saved options from database (not merged with defaults)
6409 7853 $saved_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
6410 7854
6411 - $rejected_ips = array();
7855 + // What is stored before this request changes anything: the shared file
7856 + // settings this user may not change are put back from here (2.11.6).
7857 + $stored_options = $saved_options;
7858 + $locked = Vigilante_Settings::get_locked_file_settings();
6412 7859
7860 + if ( isset( $locked[ $section ] ) && true === $locked[ $section ] ) {
7861 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7862 + }
7863 +
7864 + // A module switch is a single key, so refusing says more than a success
7865 + // that changed nothing, and the dashboard puts the toggle back.
7866 + if ( 'modules' === $section && isset( $locked['modules'], $data['modules'] ) && is_array( $locked['modules'] ) && is_array( $data['modules'] ) ) {
7867 + foreach ( array_keys( $data['modules'] ) as $vg_module ) {
7868 + if ( in_array( sanitize_key( $vg_module ), $locked['modules'], true ) ) {
7869 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7870 + }
7871 + }
7872 + }
7873 +
7874 + $rejected_ips = array();
7875 + $rejected_proxies = array();
7876 +
6413 7877 // Handle modules
6414 7878 if ( 'modules' === $section && isset( $data['modules'] ) ) {
6415 7879 if ( ! isset( $saved_options['modules'] ) ) {
6416 7880 $saved_options['modules'] = array();
@@ -6434,9 +7898,9 @@
6434 7898 // went straight into the option. An entry the matcher can never
6435 7899 // match still sits in a security list looking like protection,
6436 7900 // so the ones that cannot match are dropped and reported back
6437 7901 // instead of being stored in silence.
6438 - $rejected_ips = $this->filter_ip_lists( $section, $processed );
7902 + $rejected_ips = $this->filter_ip_lists( $section, $processed, $rejected_proxies );
6439 7903
6440 7904 // Save the processed section
6441 7905 $saved_options[ $section ] = $processed;
6442 7906
@@ -6447,8 +7911,10 @@
6447 7911
6448 7912 // Clear cache before saving
6449 7913 wp_cache_delete( Vigilante_Settings::OPTION_NAME, 'options' );
6450 7914
7915 + $saved_options = Vigilante_Settings::keep_locked_file_settings( $saved_options, $stored_options );
7916 +
6451 7917 // Save to database
6452 7918 update_option( Vigilante_Settings::OPTION_NAME, $saved_options );
6453 7919
6454 7920 // Clear the settings cache
@@ -6510,8 +7976,21 @@
6510 7976 implode( ', ', array_map( 'esc_html', $rejected_ips ) )
6511 7977 );
6512 7978 }
6513 7979
7980 + if ( ! empty( $rejected_proxies ) ) {
7981 + $message .= ' ' . sprintf(
7982 + /* translators: %s: comma separated list of the trusted proxy entries that were not saved. */
7983 + _n(
7984 + 'A trusted proxy must be an exact IP or a CIDR range, not a wildcard, so this entry was not saved: %s',
7985 + 'A trusted proxy must be an exact IP or a CIDR range, not a wildcard, so these entries were not saved: %s',
7986 + count( $rejected_proxies ),
7987 + 'vigilante'
7988 + ),
7989 + implode( ', ', array_map( 'esc_html', $rejected_proxies ) )
7990 + );
7991 + }
7992 +
6514 7993 wp_send_json_success( $message );
6515 7994 }
6516 7995
6517 7996 /**
@@ -6522,9 +8001,21 @@
6522 8001 * @param string $section Section being saved.
6523 8002 * @param array $processed Section data, edited in place.
6524 8003 * @return array Entries that were dropped, for the message back to the user.
6525 8004 */
6526 - private function filter_ip_lists( $section, &$processed ) {
8005 + private function filter_ip_lists( $section, &$processed, &$rejected_proxies = array() ) {
8006 + $rejected_proxies = array();
8007 +
8008 + // Trusted proxies feed an identity decision, so only exact addresses and
8009 + // CIDR ranges belong there: a wildcard is stripped with its own message,
8010 + // never stored looking effective. The matcher ignores it anyway (see
8011 + // Vigilante_IP_Utils::in_list_ip_or_cidr), this stops it persisting.
8012 + if ( 'firewall' === $section && isset( $processed['trusted_proxies'] ) && is_array( $processed['trusted_proxies'] ) ) {
8013 + $split = Vigilante_IP_Utils::split_list_ip_or_cidr( $processed['trusted_proxies'] );
8014 + $processed['trusted_proxies'] = $split['valid'];
8015 + $rejected_proxies = $split['rejected'];
8016 + }
8017 +
6527 8018 $lists = array(
6528 8019 'firewall' => array( 'ip_whitelist', 'ip_blacklist' ),
6529 8020 'login_security' => array( 'ip_whitelist' ),
6530 8021 );
@@ -6871,13 +8362,27 @@
6871 8362
6872 8363 // Sanitize imported data recursively
6873 8364 $imported = map_deep( $imported, 'sanitize_text_field' );
6874 8365
6875 - // Validate structure
6876 - $defaults = $this->settings->get_default_options();
6877 - $merged = array_replace_recursive( $defaults, $imported );
8366 + // Validate structure: only sections and keys of the schema survive, and
8367 + // every value takes the type of its default. Until 2.11.0 this was an
8368 + // array_replace_recursive() of the file over the defaults, so any key in
8369 + // the file, known or not, landed in vigilante_options (S7). Sections
8370 + // the file does not carry keep their defaults; a section it does carry
8371 + // replaces the default one whole, because validate_options() has
8372 + // already filled in whatever the file left out.
8373 + $defaults = $this->settings->get_default_options();
8374 + $validated = $this->settings->validate_options( $imported );
8375 + $merged = $defaults;
6878 8376
8377 + foreach ( $validated as $section => $data ) {
8378 + if ( is_array( $data ) ) {
8379 + $merged[ $section ] = $data;
8380 + }
8381 + }
8382 +
6879 8383 // Save
8384 + $merged = Vigilante_Settings::keep_locked_file_settings( $merged, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
6880 8385 update_option( Vigilante_Settings::OPTION_NAME, $merged );
6881 8386 $this->settings->clear_cache();
6882 8387
6883 8388 // Re-evaluate the active preset marker. The imported config may match
@@ -6900,9 +8405,9 @@
6900 8405 if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
6901 8406 wp_schedule_single_event( time() + 5, 'vigilante_under_attack_post_scan' );
6902 8407 }
6903 8408
6904 - wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) );
8409 + wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
6905 8410 }
6906 8411
6907 8412 /**
6908 8413 * Detect whether a vigilante_options array matches a known preset.
@@ -7005,9 +8510,11 @@
7005 8510 $preset = isset( $_POST['preset'] ) ? sanitize_key( $_POST['preset'] ) : '';
7006 8511
7007 8512 // Handle reset to defaults
7008 8513 if ( 'reset' === $preset ) {
7009 - $defaults = Vigilante_Settings::get_defaults_preserving_user_data( get_option( Vigilante_Settings::OPTION_NAME, array() ) );
8514 + $stored_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
8515 + $defaults = Vigilante_Settings::get_defaults_preserving_user_data( $stored_options );
8516 + $defaults = Vigilante_Settings::keep_locked_file_settings( $defaults, $stored_options );
7010 8517 update_option( Vigilante_Settings::OPTION_NAME, $defaults );
7011 8518 $this->settings->clear_cache();
7012 8519
7013 8520 // Clear active preset
@@ -7015,9 +8522,9 @@
7015 8522
7016 8523 // Apply file changes after reset
7017 8524 $this->apply_all_file_changes( $defaults );
7018 8525
7019 - wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) );
8526 + wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) . $this->locked_file_settings_message() );
7020 8527 return;
7021 8528 }
7022 8529
7023 8530 $presets = $this->settings->get_presets();
@@ -7043,8 +8550,9 @@
7043 8550 // invent keys that are missing on both sides.
7044 8551 $current = Vigilante_Settings::merge_preset( $this->settings->get_default_options(), $current );
7045 8552
7046 8553 $merged = Vigilante_Settings::merge_preset( $current, $preset_options );
8554 + $merged = Vigilante_Settings::keep_locked_file_settings( $merged, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7047 8555
7048 8556 update_option( Vigilante_Settings::OPTION_NAME, $merged );
7049 8557 $this->settings->clear_cache();
7050 8558
@@ -7053,9 +8561,9 @@
7053 8561
7054 8562 // Apply file changes after preset
7055 8563 $this->apply_all_file_changes( $merged );
7056 8564
7057 - wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) );
8565 + wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
7058 8566 }
7059 8567
7060 8568 /**
7061 8569 * AJAX: Reset a specific section to defaults
@@ -7090,27 +8598,19 @@
7090 8598 * On a subsite, the settings written to wp-config.php and .htaccess are
7091 8599 * the main site's business. Resetting the local copy of those would only
7092 8600 * make this screen disagree with the file, so they are carried over
7093 8601 * untouched, and a section that is nothing but shared settings is not
7094 - * reset at all.
8602 + * reset at all. On the main site, a user without network rights keeps
8603 + * the ones the shared files are built from as well (2.11.6).
7095 8604 */
7096 - if ( ! Vigilante_Settings::can_write_shared_files() ) {
7097 - $shared = Vigilante_Settings::get_shared_file_settings();
8605 + $locked = Vigilante_Settings::get_locked_file_settings();
7098 8606
7099 - if ( isset( $shared[ $section ] ) ) {
7100 - if ( true === $shared[ $section ] ) {
7101 - wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7102 - }
7103 -
7104 - foreach ( $shared[ $section ] as $shared_key ) {
7105 - if ( array_key_exists( $shared_key, (array) $current_options[ $section ] ) ) {
7106 - $new_values[ $shared_key ] = $current_options[ $section ][ $shared_key ];
7107 - }
7108 - }
7109 - }
8607 + if ( isset( $locked[ $section ] ) && true === $locked[ $section ] ) {
8608 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7110 8609 }
7111 8610
7112 8611 $current_options[ $section ] = $new_values;
8612 + $current_options = Vigilante_Settings::keep_locked_file_settings( $current_options, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7113 8613
7114 8614 // Save
7115 8615 update_option( Vigilante_Settings::OPTION_NAME, $current_options );
7116 8616 $this->settings->clear_cache();
@@ -7193,8 +8693,19 @@
7193 8693 // Save new results
7194 8694 update_option( 'vigilante_last_integrity_scan', time() );
7195 8695 update_option( 'vigilante_last_integrity_results', $results );
7196 8696
8697 + // On the main site the scan does compute the lines of wp-config.php and
8698 + // .htaccess, for the network administrator. Somebody without network
8699 + // rights gets the change and its sizes, not the lines.
8700 + if ( $this->critical_approval_locked() && ! empty( $results['modified'] ) && is_array( $results['modified'] ) ) {
8701 + foreach ( $results['modified'] as $index => $item ) {
8702 + if ( is_array( $item ) && 'critical_config' === ( $item['type'] ?? '' ) ) {
8703 + $results['modified'][ $index ]['diff'] = Vigilante_File_Integrity::network_only_diff();
8704 + }
8705 + }
8706 + }
8707 +
7197 8708 wp_send_json_success( array(
7198 8709 'message' => __( 'Scan completed.', 'vigilante' ),
7199 8710 'results' => $results,
7200 8711 'ignored_count' => count( get_option( 'vigilante_ignored_files', array() ) ),
@@ -7228,11 +8739,46 @@
7228 8739 if ( ! current_user_can( 'manage_options' ) ) {
7229 8740 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
7230 8741 }
7231 8742
8743 + $results = get_option( 'vigilante_last_integrity_results' );
8744 + $scanned_at = get_option( 'vigilante_last_integrity_scan' );
8745 +
7232 8746 delete_option( 'vigilante_last_integrity_results' );
7233 8747 delete_option( 'vigilante_last_integrity_scan' );
7234 8748
8749 + /*
8750 + * A pending change to wp-config.php or the root .htaccess is closed by
8751 + * approving it, which takes the network. Clearing the results was one
8752 + * more way to close it without, until the next scan: the ignore list was
8753 + * shut in 2.11.8 and this button was left open, found by the cross
8754 + * review of 2.11.8. So for somebody who cannot approve, those entries
8755 + * stay and everything else goes.
8756 + */
8757 + // The findings about Vigilant's own files stay too: they report for the
8758 + // whole network, and ignoring them already takes network rights.
8759 + if ( $this->critical_approval_locked() && is_array( $results ) ) {
8760 + $kept = array();
8761 + $found = false;
8762 + foreach ( array( 'modified', 'missing', 'suspicious', 'extra' ) as $bucket ) {
8763 + $kept[ $bucket ] = array_values(
8764 + array_filter(
8765 + isset( $results[ $bucket ] ) && is_array( $results[ $bucket ] ) ? $results[ $bucket ] : array(),
8766 + function ( $item ) {
8767 + return is_array( $item ) && in_array( $item['type'] ?? '', array( 'critical_config', 'vigilante_self' ), true );
8768 + }
8769 + )
8770 + );
8771 + $found = $found || ! empty( $kept[ $bucket ] );
8772 + }
8773 +
8774 + if ( $found ) {
8775 + $results = array_merge( $results, $kept );
8776 + update_option( 'vigilante_last_integrity_results', $results );
8777 + update_option( 'vigilante_last_integrity_scan', $scanned_at ? $scanned_at : time() );
8778 + }
8779 + }
8780 +
7235 8781 if ( $this->database ) {
7236 8782 $this->database->clear_file_hashes();
7237 8783 }
7238 8784
@@ -7258,8 +8804,23 @@
7258 8804 if ( empty( $file ) ) {
7259 8805 wp_send_json_error( __( 'No file specified.', 'vigilante' ) );
7260 8806 }
7261 8807
8808 + // A change to a shared file is closed by approving it, and approving it
8809 + // takes the network. Ignoring it would close the same warning without.
8810 + if ( $this->critical_approval_locked() && in_array( $file, array( 'wp-config.php', '.htaccess' ), true ) ) {
8811 + wp_send_json_error( $this->critical_approval_notice() );
8812 + }
8813 +
8814 + // Vigilant's own files are never ignored, on any site: silencing the
8815 + // check that says the security plugin was changed is the one button
8816 + // an attacker would want on this screen. Since 3.0.0 those findings
8817 + // are not rows of these tables either, so nothing in the interface
8818 + // sends them here; this is the door, not the label.
8819 + if ( Vigilante_Self_Integrity::is_own_file_path( $file ) ) {
8820 + 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' ) );
8821 + }
8822 +
7262 8823 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database );
7263 8824 $file_integrity->ignore_file( $file );
7264 8825
7265 8826 // Also remove the file from stored scan results so UI updates
@@ -7323,12 +8884,16 @@
7323 8884 if ( ! is_array( $raw_files ) ) {
7324 8885 wp_send_json_error( __( 'Invalid request.', 'vigilante' ) );
7325 8886 }
7326 8887
7327 - $files = array();
8888 + $files = array();
8889 + $locked = $this->critical_approval_locked();
8890 + $shared = $locked ? array( 'wp-config.php', '.htaccess' ) : array();
7328 8891 foreach ( $raw_files as $f ) {
7329 8892 $clean = sanitize_text_field( $f );
7330 - if ( '' !== $clean ) {
8893 + // Same rule as ajax_ignore_file(): the two shared files when the
8894 + // network locks them, and Vigilant's own files always.
8895 + if ( '' !== $clean && ! in_array( $clean, $shared, true ) && ! Vigilante_Self_Integrity::is_own_file_path( $clean ) ) {
7331 8896 $files[] = $clean;
7332 8897 }
7333 8898 }
7334 8899