PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.0
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.0
3.0.1 3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 All 89 releases
← All changes | vigilante.php +553 -9 2.9.8 → 3.0.0 View file →
@@ -2,9 +2,9 @@
2 2 /**
3 3 * Plugin Name: Vigilant - 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner…
4 4 * Plugin URI: https://servicios.ayudawp.com
5 5 * Description: Complete security solution for WordPress. Firewall, 2FA, security headers, login protection, file integrity monitoring, activity logging and more.
6 - * Version: 2.9.8
6 + * Version: 3.0.0
7 7 * Author: Fernando Tellado
8 8 * Author URI: https://ayudawp.com
9 9 * Text Domain: vigilante
10 10 * Requires at least: 6.2
@@ -23,9 +23,9 @@
23 23
24 24 /**
25 25 * Plugin constants
26 26 */
27 -define( 'VIGILANTE_VERSION', '2.9.8' );
27 +define( 'VIGILANTE_VERSION', '3.0.0' );
28 28 define( 'VIGILANTE_PLUGIN_FILE', __FILE__ );
29 29 define( 'VIGILANTE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
30 30 define( 'VIGILANTE_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
31 31 define( 'VIGILANTE_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
@@ -119,13 +119,15 @@
119 119 // Load security module files (just loading, not initializing)
120 120 require_once VIGILANTE_INCLUDES_DIR . 'class-firewall.php';
121 121 require_once VIGILANTE_INCLUDES_DIR . 'class-security-headers.php';
122 122 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-protection.php';
123 + require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-recovery.php';
123 124 require_once VIGILANTE_INCLUDES_DIR . 'class-wpconfig-security.php';
124 125 require_once VIGILANTE_INCLUDES_DIR . 'class-https-enforcer.php';
125 126 require_once VIGILANTE_INCLUDES_DIR . 'class-rest-api-security.php';
126 127 require_once VIGILANTE_INCLUDES_DIR . 'class-user-security.php';
127 128 require_once VIGILANTE_INCLUDES_DIR . 'class-login-security.php';
129 + require_once VIGILANTE_INCLUDES_DIR . 'trait-two-factor-session.php';
128 130 require_once VIGILANTE_INCLUDES_DIR . 'class-two-factor-email.php';
129 131 require_once VIGILANTE_INCLUDES_DIR . 'class-two-factor-totp.php';
130 132 require_once VIGILANTE_INCLUDES_DIR . 'class-email-template.php';
131 133 require_once VIGILANTE_INCLUDES_DIR . 'class-comment-security.php';
@@ -134,8 +136,11 @@
134 136 require_once VIGILANTE_INCLUDES_DIR . 'class-activity-log.php';
135 137 require_once VIGILANTE_INCLUDES_DIR . 'class-audit-alerts.php';
136 138 require_once VIGILANTE_INCLUDES_DIR . 'class-file-integrity.php';
137 139 require_once VIGILANTE_INCLUDES_DIR . 'class-plugin-status.php';
140 + require_once VIGILANTE_INCLUDES_DIR . 'class-self-integrity-guidance.php';
141 + require_once VIGILANTE_INCLUDES_DIR . 'class-self-integrity.php';
142 + require_once VIGILANTE_INCLUDES_DIR . 'class-self-repair.php';
138 143 require_once VIGILANTE_INCLUDES_DIR . 'class-under-attack.php';
139 144 require_once VIGILANTE_INCLUDES_DIR . 'class-database-backup.php';
140 145 require_once VIGILANTE_INCLUDES_DIR . 'class-database-prefix.php';
141 146 require_once VIGILANTE_INCLUDES_DIR . 'class-security-analyzer.php';
@@ -142,8 +147,9 @@
142 147
143 148 // Load admin classes
144 149 if ( is_admin() ) {
145 150 require_once VIGILANTE_ADMIN_DIR . 'class-admin-analyzer-ajax.php';
151 + require_once VIGILANTE_ADMIN_DIR . 'class-admin-recovery-ajax.php';
146 152 require_once VIGILANTE_ADMIN_DIR . 'class-admin-audit-alerts-ajax.php';
147 153 require_once VIGILANTE_ADMIN_DIR . 'class-admin.php';
148 154 }
149 155
@@ -155,8 +161,18 @@
155 161
156 162 // Post-Under Attack scan (one-shot, scheduled by Vigilante_Under_Attack::deactivate).
157 163 add_action( 'vigilante_under_attack_post_scan', 'vigilante_run_post_under_attack_scan' );
158 164
165 + // Self-protection: verify Vigilant's own files right after the WordPress
166 + // updater replaced them. Priority 10, ahead of the File Integrity handler
167 + // at 20, which skips Vigilant while the self-check is on. Registered outside
168 + // is_admin() because automatic updates run from cron and WP-CLI.
169 + add_action( 'upgrader_process_complete', 'vigilante_on_upgrader_process_complete', 10, 2 );
170 +
171 + // One click repair of Vigilant's own files (admin-post action).
172 + Vigilante_Self_Repair::init();
173 + add_filter( 'upgrader_post_install', 'vigilante_mark_upgrader_wrote', 10, 3 );
174 +
159 175 // Initialize core components only - modules will be initialized at init
160 176 add_action( 'init', 'vigilante_init_plugin', 1 );
161 177 }
162 178
@@ -200,8 +216,15 @@
200 216 */
201 217 public $activity_log;
202 218
203 219 /**
220 + * Self-protection instance, the only one that registers hooks
221 + *
222 + * @var Vigilante_Self_Integrity|null
223 + */
224 + public $self_integrity;
225 +
226 + /**
204 227 * Get single instance of the class
205 228 *
206 229 * @return Vigilante_Main
207 230 */
@@ -255,8 +278,13 @@
255 278 Vigilante_Backup_Manager::cleanup_legacy_files();
256 279 update_option( 'vigilante_legacy_backups_cleaned', 1, false );
257 280 }
258 281
282 + // Once (2.11.6): the copies of wp-config.php, .htaccess and robots.txt
283 + // that earlier versions kept in the options table, on this site and, on
284 + // a network, on every site of it.
285 + Vigilante_Backup_Manager::maybe_purge_stored_copies();
286 +
259 287 // One-time migration (2.9.0): add '.css' to File Integrity's excluded
260 288 // extensions on existing installs. Stylesheets are rewritten so often by
261 289 // themes and optimizer plugins that they were the main post-update false
262 290 // positive. New installs get it from the defaults; this brings existing
@@ -356,15 +384,45 @@
356 384
357 385 // User Security
358 386 if ( ! empty( $options['modules']['user_security'] ) ) {
359 387 new Vigilante_User_Security( $this->settings, $this->activity_log );
388 + } else {
389 + /*
390 + * Turning the module off must not quietly unlock the accounts it
391 + * already locked. A forced password reset and a registration waiting
392 + * for approval are marks written on somebody's account, with their
393 + * sessions already destroyed and the activity log saying they cannot
394 + * get in; until 2.11.10 both stopped being enforced the moment this
395 + * toggle went off and every one of those accounts logged in again
396 + * with its old password. Only the enforcing half is registered here.
397 + */
398 + new Vigilante_User_Security( $this->settings, $this->activity_log, true );
360 399 }
361 400
362 401 // Login Security
402 + $login_security = null;
403 +
363 404 if ( ! empty( $options['modules']['login_security'] ) ) {
364 405 $login_security = new Vigilante_Login_Security( $this->settings, $this->database, $this->activity_log );
365 -
366 - // Two-Factor Authentication (only if login security module is active)
406 + }
407 +
408 + /*
409 + * Two factor on a network is decided for the whole network, so it has to
410 + * be ENFORCED on the whole network too. Gating these two on this site's
411 + * module toggle left the last leg of the bypass open: the administrator
412 + * of any subsite can turn Login Security off on their own site, which
413 + * takes them out of the picture but not out of the network, and the
414 + * session cookie WordPress issues there is valid on every host of it. So
415 + * a login sent to that subsite registered no second factor check at all
416 + * and the cookie it handed back opened the main site. Reproduced over
417 + * HTTP by the second cross review of 2.11.10.
418 + *
419 + * Same reasoning, and the same shape, as the enforcement-only User
420 + * Security above: on a network the enforcing half is registered whatever
421 + * this site says. On a single site there is no other site to protect and
422 + * the toggle means what it says.
423 + */
424 + if ( is_multisite() || null !== $login_security ) {
367 425 new Vigilante_Two_Factor_Email( $this->settings, $this->database, $this->activity_log, $login_security );
368 426 new Vigilante_Two_Factor_TOTP( $this->settings, $this->database, $this->activity_log, $login_security );
369 427 }
370 428
@@ -376,10 +434,41 @@
376 434 }
377 435
378 436 // File Integrity Scanner
379 437 if ( ! empty( $options['modules']['file_integrity'] ) ) {
380 - new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
438 + $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
439 + $file_integrity->init_hooks();
440 + $file_integrity->init_cleanup_hooks();
441 +
381 442 new Vigilante_Plugin_Status( $this->settings, $this->activity_log );
443 + } elseif ( is_admin() ) {
444 + /*
445 + * The module is off, and the cleanup goes on anyway. It is not
446 + * integrity monitoring: it takes out of the database the copy of
447 + * wp-config.php that earlier versions stored, credentials and all.
448 + * Turning the module off is not a decision to keep them.
449 + *
450 + * Two holes closed here, both reported by @calzbert after reading the
451 + * 2.11.3 diff. A site with the module off cleaned itself by neither
452 + * of its own two paths, because both hang off this class. And with
453 + * the module off on the MAIN site, the network sweep was not
454 + * registered either, which is what would have reached every other
455 + * site: the sweep removes each site's option without asking whether
456 + * the module is on over there.
457 + *
458 + * Only in the admin, because both hooks are admin_init and there is
459 + * nothing to gain from building this on a front-end request. Note
460 + * that admin-ajax.php fires admin_init too (wp-admin/admin-ajax.php
461 + * :45), so this also runs on wp_ajax_nopriv_* requests from
462 + * visitors with no session. That is deliberate and it is what the
463 + * module has been doing since 2.11.2: the cleanup asks for no
464 + * capability because it also runs under wp-cron with nobody logged
465 + * in, and all it does is take the plugin's own copy out of the
466 + * database. The network sweep, which does reach across sites, is
467 + * the one that demands manage_network_options.
468 + */
469 + $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
470 + $file_integrity->init_cleanup_hooks();
382 471 }
383 472
384 473 // Activity Log is always initialized (core component)
385 474 // Logging is gated by the modules.activity_log toggle and per-type flags
@@ -394,8 +483,16 @@
394 483
395 484 // Under Attack mode - always loaded (independent of modules)
396 485 new Vigilante_Under_Attack( $this->settings, $this->activity_log );
397 486
487 + // Self-protection - always loaded, NOT gated by modules.file_integrity:
488 + // the version change and watchdog paths must stay alive with the File
489 + // Integrity module off. There is no setting to gate it: see
490 + // Vigilante_Self_Integrity::is_on(). Hooks are registered here and only here;
491 + // every other new of the class is a plain object.
492 + $this->self_integrity = new Vigilante_Self_Integrity( $this->settings, $this->activity_log );
493 + $this->self_integrity->init_hooks();
494 +
398 495 // Admin interface
399 496 if ( is_admin() ) {
400 497 new Vigilante_Admin( $this->settings, $this->database, $this->activity_log );
401 498 }
@@ -416,11 +513,242 @@
416 513 add_action( 'wp_ajax_vigilante_dismiss_notice', array( $this, 'ajax_dismiss_notice' ) );
417 514
418 515 // Regenerate critical file baseline after Vigilante modifies wp-config.php or .htaccess
419 516 add_action( 'vigilante_critical_file_written', array( $this, 'on_critical_file_written' ) );
517 +
518 + // Keep the server layer in step with the installed version.
519 + add_action( 'init', array( $this, 'maybe_sync_server_files' ), 20 );
420 520 }
421 521
422 522 /**
523 + * Rewrite the .htaccess block when the installed version has moved on
524 + *
525 + * Updating the plugin did not touch the file: the block was only rewritten
526 + * on activation or when the Headers or Firewall tab was saved. So a fix
527 + * that lives inside those rules never reached a site that merely updated,
528 + * which is exactly what happened with the connect-src of 2.9.6: the browser
529 + * kept receiving the old policy, and image uploads kept failing on
530 + * WordPress 7.1 until someone pressed Save. This rewrites the block once
531 + * per version, and picks up the rules that an activation from WP-CLI had to
532 + * leave pending because it could not tell what server it was on.
533 + *
534 + * Only the content between the plugin markers is rewritten, the same part
535 + * any save has always rewritten.
536 + *
537 + * @since 2.9.9
538 + */
539 + public function maybe_sync_server_files() {
540 + $pending = (bool) get_option( 'vigilante_server_files_pending' );
541 +
542 + if ( ! $pending && VIGILANTE_VERSION === get_option( 'vigilante_server_files_version' ) ) {
543 + return;
544 + }
545 +
546 + // A failed write is not retried on every request.
547 + if ( (int) get_option( 'vigilante_server_files_retry_after' ) > time() ) {
548 + return;
549 + }
550 +
551 + /*
552 + * A subsite has nothing to do here, ever: the file belongs to the main
553 + * site. Marking it done keeps every request from re-checking.
554 + *
555 + * 2.10.0 asked the wrong question at this point and it cost the whole
556 + * feature on networks. can_write_shared_files() ends in a capability
557 + * check, and this runs on init for every request, so on a network the
558 + * branch below was the one nearly every visitor took: it retired the job
559 + * without having written a thing. The .htaccess was never refreshed after
560 + * an update, and the one-shot snapshot behind it was consumed without
561 + * being taken, so not even a network administrator visiting afterwards
562 + * retried, because the version had already been marked. Reported by
563 + * @calzbert, who found it reading the code.
564 + */
565 + if ( ! Vigilante_Settings::owns_shared_files() ) {
566 + $this->mark_server_files_synced();
567 + return;
568 + }
569 +
570 + require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
571 + $manager = Vigilante_Htaccess_Manager::get_instance();
572 +
573 + if ( ! $manager->is_apache() ) {
574 + // Still on the command line with nothing to learn from: stay pending.
575 + if ( $manager->server_is_unknown() ) {
576 + return;
577 + }
578 +
579 + // Not Apache: there is no block to keep in step.
580 + $this->mark_server_files_synced();
581 + return;
582 + }
583 +
584 + $options = get_option( Vigilante_Settings::OPTION_NAME, array() );
585 + $headers = isset( $options['security_headers'] ) ? (array) $options['security_headers'] : array();
586 + $failed = false;
587 + $rewrote = false;
588 +
589 + /*
590 + * Last chance to keep what the file still says. The rewrites below are
591 + * precisely what overwrites it, and on a site whose header settings the
592 + * 2.9.8 migration reset, this file is the only remaining copy of what the
593 + * owner had actually chosen. Captured here rather than inside the write
594 + * path so it only ever happens on a version change: an ordinary save also
595 + * leaves the file describing the previous values for an instant, and
596 + * capturing there would spend the single slot on a difference the owner
597 + * made deliberately.
598 + */
599 + $wrote_last = (string) get_option( 'vigilante_server_files_version' );
600 +
601 + /*
602 + * And only on the very first sync that arrives from a version older than
603 + * this one. That is the whole window: the file still describes what the
604 + * owner chose, and the rewrite below is what ends it. Gating on the
605 + * version also keeps a future release, one that legitimately changes what
606 + * the block contains, from reading its own improvement as damage and
607 + * offering to undo it.
608 + */
609 + /*
610 + * 2.10.1 and not 2.10.0, deliberately: it gives the networks a second
611 + * chance. On a network 2.10.0 marked this done without writing anything,
612 + * so the window closed with the snapshot untaken. But nothing was
613 + * written, which means the .htaccess on those sites still describes the
614 + * configuration its owner actually chose. Reopening the window one
615 + * version wide is what lets them be recovered after all.
616 + *
617 + * Harmless where it already worked: a site that took a snapshot is
618 + * skipped because one exists, and a site that found nothing to take has
619 + * had its file rewritten to match its settings, so there is still no
620 + * difference to find.
621 + */
622 + if ( '' === $wrote_last || version_compare( $wrote_last, '2.10.1', '<' ) ) {
623 + require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-recovery.php';
624 + Vigilante_Htaccess_Recovery::maybe_capture( $manager->get_content(), $this->settings );
625 + }
626 +
627 + $needs_protection_block = ! empty( $options['modules']['firewall'] )
628 + || ! empty( $headers['hide_server_signature'] )
629 + || ! empty( $headers['remove_fingerprinting_headers'] );
630 +
631 + /*
632 + * A 'locked' result is not a failure: another request is doing this very
633 + * work right now. Returning without marking anything leaves the pending
634 + * state alone, so whichever request wins finishes the job and this one
635 + * stays out of the way. Treating it as a failure would arm the one hour
636 + * backoff for something that is already being handled.
637 + */
638 + $locked = false;
639 + $incomplete = false;
640 + $unreadable = false;
641 + $settled = array( 'locked', 'block_incomplete', 'read_failed' );
642 +
643 + if ( $needs_protection_block ) {
644 + require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-protection.php';
645 + $result = ( new Vigilante_Htaccess_Protection( $this->settings ) )->apply_rules( true );
646 + $code = is_wp_error( $result ) ? $result->get_error_code() : ( true === $result ? '' : 'unexpected_result' );
647 + $locked = $locked || 'locked' === $code;
648 + $incomplete = $incomplete || 'block_incomplete' === $code;
649 + $unreadable = $unreadable || 'read_failed' === $code;
650 + $failed = $failed || ( '' !== $code && ! in_array( $code, $settled, true ) );
651 + $rewrote = true;
652 + }
653 +
654 + if ( ! $locked && ! empty( $options['modules']['security_headers'] ) ) {
655 + require_once VIGILANTE_INCLUDES_DIR . 'class-security-headers.php';
656 + $result = ( new Vigilante_Security_Headers( $this->settings ) )->apply_rules( true );
657 + $code = is_wp_error( $result ) ? $result->get_error_code() : ( true === $result ? '' : 'unexpected_result' );
658 + $locked = $locked || 'locked' === $code;
659 + $incomplete = $incomplete || 'block_incomplete' === $code;
660 + $unreadable = $unreadable || 'read_failed' === $code;
661 + $failed = $failed || ( '' !== $code && ! in_array( $code, $settled, true ) );
662 + $rewrote = true;
663 + }
664 +
665 + if ( $locked ) {
666 + return;
667 + }
668 +
669 + if ( $failed ) {
670 + update_option( 'vigilante_server_files_retry_after', time() + HOUR_IN_SECONDS );
671 +
672 + // A refusal to write the server rules is exactly the kind of thing
673 + // that used to happen in silence, so it is recorded and retried in
674 + // an hour instead of being forgotten.
675 + if ( $this->activity_log ) {
676 + $this->activity_log->log(
677 + 'system',
678 + 'server_rules_write_failed',
679 + __( 'The .htaccess rules could not be rewritten after the update. Vigilant will try again in an hour; if the file is read only, fix its permissions or save the Firewall or Headers tab once.', 'vigilante' ),
680 + array( 'version' => VIGILANTE_VERSION ),
681 + 'warning'
682 + );
683 + }
684 +
685 + return;
686 + }
687 +
688 + /*
689 + * A block with a BEGIN line and no END is not going to mend itself, so
690 + * retrying every hour would only repeat the refusal: it is recorded once
691 + * for this version, with what to do about it, and the job is marked done.
692 + * Saving the Firewall or Headers tab after fixing the file writes the
693 + * rules again.
694 + */
695 + if ( $incomplete && $this->activity_log ) {
696 + $this->activity_log->log(
697 + 'system',
698 + 'server_rules_block_incomplete',
699 + __( 'The .htaccess rules were not rewritten after the update because a Vigilant block in that file has a BEGIN line without its END, and rewriting it would have cut everything below it. Remove the broken block by hand, then save the Firewall or Headers tab.', 'vigilante' ),
700 + array( 'version' => VIGILANTE_VERSION ),
701 + 'warning'
702 + );
703 + }
704 +
705 + /*
706 + * Same for a .htaccess that PHP can write but not read: since 2.11.8 it is
707 + * left as it is rather than replaced by the Vigilant rules alone, and
708 + * that does not mend itself either. The first version of that fix left
709 + * it to the hourly retry, with a message about read only files; found by
710 + * the cross review of 2.11.8.
711 + */
712 + if ( $unreadable && $this->activity_log ) {
713 + $this->activity_log->log(
714 + 'system',
715 + 'server_rules_read_failed',
716 + __( 'The .htaccess rules were not rewritten after the update because PHP can write that file but cannot read it, and writing it without reading it would have removed every other rule in it. Let PHP read the file, then save the Firewall or Headers tab.', 'vigilante' ),
717 + array( 'version' => VIGILANTE_VERSION ),
718 + 'warning'
719 + );
720 + }
721 +
722 + $this->mark_server_files_synced();
723 +
724 + if ( $rewrote && ! $incomplete && ! $unreadable && $this->activity_log ) {
725 + $this->activity_log->log(
726 + 'system',
727 + 'server_rules_refreshed',
728 + sprintf(
729 + /* translators: %s: plugin version. */
730 + __( 'The .htaccess rules were rewritten to match Vigilant %s.', 'vigilante' ),
731 + VIGILANTE_VERSION
732 + ),
733 + array( 'version' => VIGILANTE_VERSION ),
734 + 'info'
735 + );
736 + }
737 + }
738 +
739 + /**
740 + * Record that the server layer matches the installed version
741 + *
742 + * @since 2.9.9
743 + */
744 + private function mark_server_files_synced() {
745 + update_option( 'vigilante_server_files_version', VIGILANTE_VERSION );
746 + delete_option( 'vigilante_server_files_pending' );
747 + delete_option( 'vigilante_server_files_retry_after' );
748 + }
749 +
750 + /**
423 751 * Update the critical file baseline after Vigilante writes to a monitored file
424 752 *
425 753 * @param string $filename File that was modified (e.g. 'wp-config.php').
426 754 */
@@ -460,10 +788,13 @@
460 788 $this->database->cleanup_expired_2fa_codes();
461 789 $this->database->cleanup_expired_trusted_devices();
462 790
463 791 // Remove sensitive files (readme.html, license.txt, licencia.txt)
464 - // WordPress core updates recreate these files, so we clean them daily
465 - $advanced = $this->settings->get_section( 'advanced' );
792 + // WordPress core updates recreate these files, so we clean them daily.
793 + // They sit in the root every site of a network shares, so only the main
794 + // site removes them, from its own settings; until 2.11.6 the daily
795 + // maintenance of any site did.
796 + $advanced = Vigilante_Settings::owns_shared_files() ? $this->settings->get_section( 'advanced' ) : array();
466 797 if ( ! empty( $advanced['remove_readme'] ) ) {
467 798 $readme_path = ABSPATH . 'readme.html';
468 799 if ( file_exists( $readme_path ) ) {
469 800 wp_delete_file( $readme_path );
@@ -478,8 +809,26 @@
478 809 }
479 810 }
480 811 }
481 812
813 + // Self-protection from cron, for sites nobody opens the admin of: the
814 + // watchdog of Vigilant's own scheduled events, and the check for a
815 + // version change made outside the WordPress updater (FTP, manual).
816 + // The version change goes first: it is the path that reports a downgrade
817 + // by email, and the watchdog's own daily check would otherwise get there
818 + // before it.
819 + if ( $this->self_integrity ) {
820 + if ( $this->self_integrity->is_enabled() ) {
821 + $this->self_integrity->detect_version_change();
822 + $this->self_integrity->run_watchdog();
823 + } else {
824 + // Switched off by code: the one line that still has to be
825 + // written, or an installation that stopped checking itself
826 + // would do it in silence.
827 + $this->self_integrity->audit_off_state();
828 + }
829 + }
830 +
482 831 // Log maintenance
483 832 $this->activity_log->log( 'system', 'maintenance', __( 'Daily maintenance completed', 'vigilante' ) );
484 833 }
485 834
@@ -589,8 +938,157 @@
589 938 $under_attack->run_analyzer_scan( 'all' );
590 939 }
591 940
592 941 /**
942 + * Verify Vigilant's own files right after the WordPress updater replaced them
943 + *
944 + * Runs as the OLD code with the NEW files already on disk, so the class reads
945 + * everything from disk (the Version header, the manifest) and nothing from
946 + * constants in memory. The first update to 3.0.0 is not seen here, because the
947 + * code that receives the hook is 2.x: that one is covered by the 3.0.0
948 + * migration block of Vigilante_Admin::run_migrations().
949 + *
950 + * Plugin_Upgrader::upgrade() passes the updated plugin in 'plugin', and
951 + * bulk_upgrade() passes the list in 'plugins'; both are read. Replacing the
952 + * plugin by uploading its zip, from the screen or with `wp plugin install
953 + * --force`, goes through Plugin_Upgrader::install() instead, whose context
954 + * names no plugin (wp-admin/includes/class-plugin-upgrader.php, install()):
955 + * plugin_info() reads it from the folder that was written.
956 + *
957 + * The check itself waits for the end of the request (see
958 + * vigilante_verify_after_upgrade()): WP_Upgrader::run() fires this hook also
959 + * when the install failed, and restores the previous copy of the plugin on
960 + * shutdown, so checking here reported the half-moved folder of a failed update
961 + * as tampering, by email.
962 + *
963 + * @since 3.0.0
964 + *
965 + * @param WP_Upgrader|mixed $upgrader Upgrader instance.
966 + * @param array $hook_extra Update context.
967 + */
968 +function vigilante_on_upgrader_process_complete( $upgrader, $hook_extra ) {
969 + if ( ! is_array( $hook_extra ) || 'plugin' !== ( isset( $hook_extra['type'] ) ? $hook_extra['type'] : '' ) ) {
970 + return;
971 + }
972 +
973 + $action = isset( $hook_extra['action'] ) ? $hook_extra['action'] : '';
974 + $files = array();
975 + if ( 'update' === $action ) {
976 + $files = ( ! empty( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) ? $hook_extra['plugins'] : array();
977 + if ( ! empty( $hook_extra['plugin'] ) ) {
978 + $files[] = $hook_extra['plugin'];
979 + }
980 + } elseif ( 'install' === $action && is_object( $upgrader ) && method_exists( $upgrader, 'plugin_info' ) ) {
981 + $installed = $upgrader->plugin_info();
982 + if ( is_string( $installed ) && '' !== $installed ) {
983 + $files[] = $installed;
984 + }
985 + } else {
986 + return;
987 + }
988 + if ( ! in_array( VIGILANTE_PLUGIN_BASENAME, array_map( 'strval', $files ), true ) ) {
989 + return;
990 + }
991 +
992 + // After WP_Upgrader::restore_temp_backup() (shutdown, priority 10) and
993 + // before WP_Upgrader::delete_temp_backup() (priority 100).
994 + if ( false === has_action( 'shutdown', 'vigilante_verify_after_upgrade' ) ) {
995 + add_action( 'shutdown', 'vigilante_verify_after_upgrade', 50 );
996 + }
997 +}
998 +
999 +/**
1000 + * The check after an update, at the end of the request
1001 + *
1002 + * By now a failed update has had its previous copy restored, so the files
1003 + * on disk are the ones the site will run. Hooked by
1004 + * vigilante_on_upgrader_process_complete(); a bulk update with Vigilant in
1005 + * the list hooks it once.
1006 + *
1007 + * @since 3.0.0
1008 + */
1009 +function vigilante_verify_after_upgrade() {
1010 + if ( ! class_exists( 'Vigilante_Settings' ) ) {
1011 + require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
1012 + }
1013 + if ( ! class_exists( 'Vigilante_Self_Integrity' ) ) {
1014 + require_once VIGILANTE_INCLUDES_DIR . 'class-self-integrity-guidance.php';
1015 + require_once VIGILANTE_INCLUDES_DIR . 'class-self-integrity.php';
1016 + }
1017 +
1018 + $settings = new Vigilante_Settings();
1019 + $activity_log = null;
1020 + if ( class_exists( 'Vigilante_Activity_Log' ) && class_exists( 'Vigilante_Database' ) ) {
1021 + $database = new Vigilante_Database();
1022 + $activity_log = new Vigilante_Activity_Log( $settings, $database );
1023 + }
1024 +
1025 + $self_integrity = new Vigilante_Self_Integrity( $settings, $activity_log );
1026 + $self_integrity->handle_upgrader( vigilante_upgrader_wrote_files_on_disk() );
1027 +}
1028 +
1029 +/**
1030 + * Whether the files on disk are the ones the WordPress updater wrote in this request
1031 + *
1032 + * The marker of vigilante_mark_upgrader_wrote() says WordPress wrote the
1033 + * folder, not that the folder is still that copy at the end of the request:
1034 + * the automatic updater puts the previous copy back when the updated plugin
1035 + * breaks its loopback request, and a failed install is restored on shutdown.
1036 + * The manifest identifies the copy, so the updater is trusted only when the
1037 + * manifest on disk is the one it wrote.
1038 + *
1039 + * @since 3.0.0
1040 + *
1041 + * @return bool
1042 + */
1043 +function vigilante_upgrader_wrote_files_on_disk() {
1044 + $mark = isset( $GLOBALS['vigilante_upgrader_wrote'] ) ? $GLOBALS['vigilante_upgrader_wrote'] : null;
1045 + if ( ! is_array( $mark ) || ! array_key_exists( 'manifest', $mark ) ) {
1046 + return false;
1047 + }
1048 + return $mark['manifest'] === Vigilante_Self_Integrity::manifest_fingerprint_of( VIGILANTE_PLUGIN_DIR );
1049 +}
1050 +
1051 +/**
1052 + * Remember that the WordPress updater wrote Vigilant's folder in this request
1053 + *
1054 + * upgrader_post_install runs once per package, after the files are in place
1055 + * and before upgrader_process_complete. The hook that follows names every
1056 + * plugin of a bulk update, including the ones that were skipped, so the check
1057 + * after the update only trusts the updater when this marker says it really
1058 + * replaced the folder. Only a folder of that name in the plugins directory
1059 + * counts (a theme can have the same name), and the marker keeps the
1060 + * fingerprint of the manifest that was written, which
1061 + * vigilante_upgrader_wrote_files_on_disk() compares with the disk at the end.
1062 + *
1063 + * @since 3.0.0
1064 + *
1065 + * @param bool|WP_Error $response Installation response.
1066 + * @param array $hook_extra Extra arguments passed to hooked filters.
1067 + * @param array $result Installation result data.
1068 + * @return bool|WP_Error The response, unchanged.
1069 + */
1070 +function vigilante_mark_upgrader_wrote( $response, $hook_extra, $result ) {
1071 + if ( is_wp_error( $response ) || ! is_array( $result ) || ! isset( $result['destination_name'], $result['destination'], $result['local_destination'] ) ) {
1072 + return $response;
1073 + }
1074 + if ( dirname( VIGILANTE_PLUGIN_BASENAME ) !== $result['destination_name'] ) {
1075 + return $response;
1076 + }
1077 + if ( untrailingslashit( wp_normalize_path( (string) $result['local_destination'] ) ) !== untrailingslashit( wp_normalize_path( WP_PLUGIN_DIR ) ) ) {
1078 + return $response;
1079 + }
1080 + if ( ! class_exists( 'Vigilante_Self_Integrity' ) ) {
1081 + require_once VIGILANTE_INCLUDES_DIR . 'class-self-integrity-guidance.php';
1082 + require_once VIGILANTE_INCLUDES_DIR . 'class-self-integrity.php';
1083 + }
1084 + $GLOBALS['vigilante_upgrader_wrote'] = array(
1085 + 'manifest' => Vigilante_Self_Integrity::manifest_fingerprint_of( (string) $result['destination'] ),
1086 + );
1087 + return $response;
1088 +}
1089 +
1090 +/**
593 1091 * Plugin activation hook
594 1092 */
595 1093 function vigilante_activate() {
596 1094 require_once VIGILANTE_INCLUDES_DIR . 'class-database.php';
@@ -603,16 +1101,19 @@
603 1101 register_activation_hook( __FILE__, 'vigilante_activate' );
604 1102
605 1103 /**
606 1104 * Plugin deactivation hook
1105 + *
1106 + * @param bool $network_wide Whether core is deactivating the plugin for the whole network.
607 1107 */
608 -function vigilante_deactivate() {
1108 +function vigilante_deactivate( $network_wide = false ) {
609 1109 require_once VIGILANTE_INCLUDES_DIR . 'class-database.php';
610 1110 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
611 1111 require_once VIGILANTE_INCLUDES_DIR . 'class-backup-manager.php';
1112 + require_once VIGILANTE_INCLUDES_DIR . 'class-wpconfig-security.php';
612 1113 require_once VIGILANTE_INCLUDES_DIR . 'class-deactivator.php';
613 1114
614 - Vigilante_Deactivator::deactivate();
1115 + Vigilante_Deactivator::deactivate( (bool) $network_wide );
615 1116 }
616 1117 register_deactivation_hook( __FILE__, 'vigilante_deactivate' );
617 1118
618 1119 /**
@@ -618,8 +1119,51 @@
618 1119 /**
619 1120 * Initialize plugin after WordPress loads
620 1121 */
621 1122 add_action( 'plugins_loaded', 'vigilante_load_plugin' );
1123 +
1124 +/*
1125 + * The hidden wp-admin is answered as early as the request can be judged with
1126 + * certainty, before the theme and the other plugins load. The modules are built
1127 + * on init priority 1, so until 2.9.9 a request that was going to be refused had
1128 + * already paid for the whole boot.
1129 + */
1130 +add_action( 'plugins_loaded', 'vigilante_block_hidden_admin_early', 1 );
1131 +
1132 +/**
1133 + * Cheap gate for the early hidden wp-admin rejection
1134 + *
1135 + * Everything that can be decided without loading a single plugin class is
1136 + * decided here, so the usual request pays nothing more than a couple of
1137 + * comparisons and one option read that WordPress has already cached.
1138 + *
1139 + * @since 2.9.9
1140 + */
1141 +function vigilante_block_hidden_admin_early() {
1142 + if ( ! is_admin() ) {
1143 + return;
1144 + }
1145 +
1146 + $method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'GET';
1147 +
1148 + // POST is how remote managers authenticate, and the later path lets it through too.
1149 + if ( 'GET' !== $method ) {
1150 + return;
1151 + }
1152 +
1153 + $options = get_option( 'vigilante_options', array() );
1154 +
1155 + if ( ! is_array( $options )
1156 + || empty( $options['modules']['login_security'] )
1157 + || empty( $options['login_security']['custom_login_url'] ) ) {
1158 + return;
1159 + }
1160 +
1161 + require_once VIGILANTE_INCLUDES_DIR . 'class-ip-utils.php';
1162 + require_once VIGILANTE_INCLUDES_DIR . 'class-login-security.php';
1163 +
1164 + Vigilante_Login_Security::maybe_block_hidden_admin_early( $options );
1165 +}
622 1166
623 1167 /**
624 1168 * Helper function to get plugin instance
625 1169 *