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