PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.12
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.12
3.0.5 3.0.4 3.0.3 3.0.2 3.0.1 3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 All 93 releases
← All changes | vigilante.php +360 -11 2.9.4 → 2.11.12 View file →
@@ -1,15 +1,15 @@
1 1 <?php
2 2 /**
3 - * Plugin Name: Vigilant
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.4
6 + * Version: 2.11.12
7 7 * Author: Fernando Tellado
8 8 * Author URI: https://ayudawp.com
9 9 * Text Domain: vigilante
10 10 * Requires at least: 6.2
11 - * Tested up to: 7.0
11 + * Tested up to: 7.1
12 12 * Requires PHP: 7.4
13 13 * License: GPL v2 or later
14 14 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
15 15 *
@@ -23,9 +23,9 @@
23 23
24 24 /**
25 25 * Plugin constants
26 26 */
27 -define( 'VIGILANTE_VERSION', '2.9.4' );
27 +define( 'VIGILANTE_VERSION', '2.11.12' );
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';
@@ -142,8 +144,9 @@
142 144
143 145 // Load admin classes
144 146 if ( is_admin() ) {
145 147 require_once VIGILANTE_ADMIN_DIR . 'class-admin-analyzer-ajax.php';
148 + require_once VIGILANTE_ADMIN_DIR . 'class-admin-recovery-ajax.php';
146 149 require_once VIGILANTE_ADMIN_DIR . 'class-admin-audit-alerts-ajax.php';
147 150 require_once VIGILANTE_ADMIN_DIR . 'class-admin.php';
148 151 }
149 152
@@ -255,8 +258,13 @@
255 258 Vigilante_Backup_Manager::cleanup_legacy_files();
256 259 update_option( 'vigilante_legacy_backups_cleaned', 1, false );
257 260 }
258 261
262 + // Once (2.11.6): the copies of wp-config.php, .htaccess and robots.txt
263 + // that earlier versions kept in the options table, on this site and, on
264 + // a network, on every site of it.
265 + Vigilante_Backup_Manager::maybe_purge_stored_copies();
266 +
259 267 // One-time migration (2.9.0): add '.css' to File Integrity's excluded
260 268 // extensions on existing installs. Stylesheets are rewritten so often by
261 269 // themes and optimizer plugins that they were the main post-update false
262 270 // positive. New installs get it from the defaults; this brings existing
@@ -356,15 +364,45 @@
356 364
357 365 // User Security
358 366 if ( ! empty( $options['modules']['user_security'] ) ) {
359 367 new Vigilante_User_Security( $this->settings, $this->activity_log );
368 + } else {
369 + /*
370 + * Turning the module off must not quietly unlock the accounts it
371 + * already locked. A forced password reset and a registration waiting
372 + * for approval are marks written on somebody's account, with their
373 + * sessions already destroyed and the activity log saying they cannot
374 + * get in; until 2.11.10 both stopped being enforced the moment this
375 + * toggle went off and every one of those accounts logged in again
376 + * with its old password. Only the enforcing half is registered here.
377 + */
378 + new Vigilante_User_Security( $this->settings, $this->activity_log, true );
360 379 }
361 380
362 381 // Login Security
382 + $login_security = null;
383 +
363 384 if ( ! empty( $options['modules']['login_security'] ) ) {
364 385 $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)
386 + }
387 +
388 + /*
389 + * Two factor on a network is decided for the whole network, so it has to
390 + * be ENFORCED on the whole network too. Gating these two on this site's
391 + * module toggle left the last leg of the bypass open: the administrator
392 + * of any subsite can turn Login Security off on their own site, which
393 + * takes them out of the picture but not out of the network, and the
394 + * session cookie WordPress issues there is valid on every host of it. So
395 + * a login sent to that subsite registered no second factor check at all
396 + * and the cookie it handed back opened the main site. Reproduced over
397 + * HTTP by the second cross review of 2.11.10.
398 + *
399 + * Same reasoning, and the same shape, as the enforcement-only User
400 + * Security above: on a network the enforcing half is registered whatever
401 + * this site says. On a single site there is no other site to protect and
402 + * the toggle means what it says.
403 + */
404 + if ( is_multisite() || null !== $login_security ) {
367 405 new Vigilante_Two_Factor_Email( $this->settings, $this->database, $this->activity_log, $login_security );
368 406 new Vigilante_Two_Factor_TOTP( $this->settings, $this->database, $this->activity_log, $login_security );
369 407 }
370 408
@@ -376,10 +414,41 @@
376 414 }
377 415
378 416 // File Integrity Scanner
379 417 if ( ! empty( $options['modules']['file_integrity'] ) ) {
380 - new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
418 + $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
419 + $file_integrity->init_hooks();
420 + $file_integrity->init_cleanup_hooks();
421 +
381 422 new Vigilante_Plugin_Status( $this->settings, $this->activity_log );
423 + } elseif ( is_admin() ) {
424 + /*
425 + * The module is off, and the cleanup goes on anyway. It is not
426 + * integrity monitoring: it takes out of the database the copy of
427 + * wp-config.php that earlier versions stored, credentials and all.
428 + * Turning the module off is not a decision to keep them.
429 + *
430 + * Two holes closed here, both reported by @calzbert after reading the
431 + * 2.11.3 diff. A site with the module off cleaned itself by neither
432 + * of its own two paths, because both hang off this class. And with
433 + * the module off on the MAIN site, the network sweep was not
434 + * registered either, which is what would have reached every other
435 + * site: the sweep removes each site's option without asking whether
436 + * the module is on over there.
437 + *
438 + * Only in the admin, because both hooks are admin_init and there is
439 + * nothing to gain from building this on a front-end request. Note
440 + * that admin-ajax.php fires admin_init too (wp-admin/admin-ajax.php
441 + * :45), so this also runs on wp_ajax_nopriv_* requests from
442 + * visitors with no session. That is deliberate and it is what the
443 + * module has been doing since 2.11.2: the cleanup asks for no
444 + * capability because it also runs under wp-cron with nobody logged
445 + * in, and all it does is take the plugin's own copy out of the
446 + * database. The network sweep, which does reach across sites, is
447 + * the one that demands manage_network_options.
448 + */
449 + $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
450 + $file_integrity->init_cleanup_hooks();
382 451 }
383 452
384 453 // Activity Log is always initialized (core component)
385 454 // Logging is gated by the modules.activity_log toggle and per-type flags
@@ -416,11 +485,242 @@
416 485 add_action( 'wp_ajax_vigilante_dismiss_notice', array( $this, 'ajax_dismiss_notice' ) );
417 486
418 487 // Regenerate critical file baseline after Vigilante modifies wp-config.php or .htaccess
419 488 add_action( 'vigilante_critical_file_written', array( $this, 'on_critical_file_written' ) );
489 +
490 + // Keep the server layer in step with the installed version.
491 + add_action( 'init', array( $this, 'maybe_sync_server_files' ), 20 );
420 492 }
421 493
422 494 /**
495 + * Rewrite the .htaccess block when the installed version has moved on
496 + *
497 + * Updating the plugin did not touch the file: the block was only rewritten
498 + * on activation or when the Headers or Firewall tab was saved. So a fix
499 + * that lives inside those rules never reached a site that merely updated,
500 + * which is exactly what happened with the connect-src of 2.9.6: the browser
501 + * kept receiving the old policy, and image uploads kept failing on
502 + * WordPress 7.1 until someone pressed Save. This rewrites the block once
503 + * per version, and picks up the rules that an activation from WP-CLI had to
504 + * leave pending because it could not tell what server it was on.
505 + *
506 + * Only the content between the plugin markers is rewritten, the same part
507 + * any save has always rewritten.
508 + *
509 + * @since 2.9.9
510 + */
511 + public function maybe_sync_server_files() {
512 + $pending = (bool) get_option( 'vigilante_server_files_pending' );
513 +
514 + if ( ! $pending && VIGILANTE_VERSION === get_option( 'vigilante_server_files_version' ) ) {
515 + return;
516 + }
517 +
518 + // A failed write is not retried on every request.
519 + if ( (int) get_option( 'vigilante_server_files_retry_after' ) > time() ) {
520 + return;
521 + }
522 +
523 + /*
524 + * A subsite has nothing to do here, ever: the file belongs to the main
525 + * site. Marking it done keeps every request from re-checking.
526 + *
527 + * 2.10.0 asked the wrong question at this point and it cost the whole
528 + * feature on networks. can_write_shared_files() ends in a capability
529 + * check, and this runs on init for every request, so on a network the
530 + * branch below was the one nearly every visitor took: it retired the job
531 + * without having written a thing. The .htaccess was never refreshed after
532 + * an update, and the one-shot snapshot behind it was consumed without
533 + * being taken, so not even a network administrator visiting afterwards
534 + * retried, because the version had already been marked. Reported by
535 + * @calzbert, who found it reading the code.
536 + */
537 + if ( ! Vigilante_Settings::owns_shared_files() ) {
538 + $this->mark_server_files_synced();
539 + return;
540 + }
541 +
542 + require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
543 + $manager = Vigilante_Htaccess_Manager::get_instance();
544 +
545 + if ( ! $manager->is_apache() ) {
546 + // Still on the command line with nothing to learn from: stay pending.
547 + if ( $manager->server_is_unknown() ) {
548 + return;
549 + }
550 +
551 + // Not Apache: there is no block to keep in step.
552 + $this->mark_server_files_synced();
553 + return;
554 + }
555 +
556 + $options = get_option( Vigilante_Settings::OPTION_NAME, array() );
557 + $headers = isset( $options['security_headers'] ) ? (array) $options['security_headers'] : array();
558 + $failed = false;
559 + $rewrote = false;
560 +
561 + /*
562 + * Last chance to keep what the file still says. The rewrites below are
563 + * precisely what overwrites it, and on a site whose header settings the
564 + * 2.9.8 migration reset, this file is the only remaining copy of what the
565 + * owner had actually chosen. Captured here rather than inside the write
566 + * path so it only ever happens on a version change: an ordinary save also
567 + * leaves the file describing the previous values for an instant, and
568 + * capturing there would spend the single slot on a difference the owner
569 + * made deliberately.
570 + */
571 + $wrote_last = (string) get_option( 'vigilante_server_files_version' );
572 +
573 + /*
574 + * And only on the very first sync that arrives from a version older than
575 + * this one. That is the whole window: the file still describes what the
576 + * owner chose, and the rewrite below is what ends it. Gating on the
577 + * version also keeps a future release, one that legitimately changes what
578 + * the block contains, from reading its own improvement as damage and
579 + * offering to undo it.
580 + */
581 + /*
582 + * 2.10.1 and not 2.10.0, deliberately: it gives the networks a second
583 + * chance. On a network 2.10.0 marked this done without writing anything,
584 + * so the window closed with the snapshot untaken. But nothing was
585 + * written, which means the .htaccess on those sites still describes the
586 + * configuration its owner actually chose. Reopening the window one
587 + * version wide is what lets them be recovered after all.
588 + *
589 + * Harmless where it already worked: a site that took a snapshot is
590 + * skipped because one exists, and a site that found nothing to take has
591 + * had its file rewritten to match its settings, so there is still no
592 + * difference to find.
593 + */
594 + if ( '' === $wrote_last || version_compare( $wrote_last, '2.10.1', '<' ) ) {
595 + require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-recovery.php';
596 + Vigilante_Htaccess_Recovery::maybe_capture( $manager->get_content(), $this->settings );
597 + }
598 +
599 + $needs_protection_block = ! empty( $options['modules']['firewall'] )
600 + || ! empty( $headers['hide_server_signature'] )
601 + || ! empty( $headers['remove_fingerprinting_headers'] );
602 +
603 + /*
604 + * A 'locked' result is not a failure: another request is doing this very
605 + * work right now. Returning without marking anything leaves the pending
606 + * state alone, so whichever request wins finishes the job and this one
607 + * stays out of the way. Treating it as a failure would arm the one hour
608 + * backoff for something that is already being handled.
609 + */
610 + $locked = false;
611 + $incomplete = false;
612 + $unreadable = false;
613 + $settled = array( 'locked', 'block_incomplete', 'read_failed' );
614 +
615 + if ( $needs_protection_block ) {
616 + require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-protection.php';
617 + $result = ( new Vigilante_Htaccess_Protection( $this->settings ) )->apply_rules( true );
618 + $code = is_wp_error( $result ) ? $result->get_error_code() : ( true === $result ? '' : 'unexpected_result' );
619 + $locked = $locked || 'locked' === $code;
620 + $incomplete = $incomplete || 'block_incomplete' === $code;
621 + $unreadable = $unreadable || 'read_failed' === $code;
622 + $failed = $failed || ( '' !== $code && ! in_array( $code, $settled, true ) );
623 + $rewrote = true;
624 + }
625 +
626 + if ( ! $locked && ! empty( $options['modules']['security_headers'] ) ) {
627 + require_once VIGILANTE_INCLUDES_DIR . 'class-security-headers.php';
628 + $result = ( new Vigilante_Security_Headers( $this->settings ) )->apply_rules( true );
629 + $code = is_wp_error( $result ) ? $result->get_error_code() : ( true === $result ? '' : 'unexpected_result' );
630 + $locked = $locked || 'locked' === $code;
631 + $incomplete = $incomplete || 'block_incomplete' === $code;
632 + $unreadable = $unreadable || 'read_failed' === $code;
633 + $failed = $failed || ( '' !== $code && ! in_array( $code, $settled, true ) );
634 + $rewrote = true;
635 + }
636 +
637 + if ( $locked ) {
638 + return;
639 + }
640 +
641 + if ( $failed ) {
642 + update_option( 'vigilante_server_files_retry_after', time() + HOUR_IN_SECONDS );
643 +
644 + // A refusal to write the server rules is exactly the kind of thing
645 + // that used to happen in silence, so it is recorded and retried in
646 + // an hour instead of being forgotten.
647 + if ( $this->activity_log ) {
648 + $this->activity_log->log(
649 + 'system',
650 + 'server_rules_write_failed',
651 + __( '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' ),
652 + array( 'version' => VIGILANTE_VERSION ),
653 + 'warning'
654 + );
655 + }
656 +
657 + return;
658 + }
659 +
660 + /*
661 + * A block with a BEGIN line and no END is not going to mend itself, so
662 + * retrying every hour would only repeat the refusal: it is recorded once
663 + * for this version, with what to do about it, and the job is marked done.
664 + * Saving the Firewall or Headers tab after fixing the file writes the
665 + * rules again.
666 + */
667 + if ( $incomplete && $this->activity_log ) {
668 + $this->activity_log->log(
669 + 'system',
670 + 'server_rules_block_incomplete',
671 + __( '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' ),
672 + array( 'version' => VIGILANTE_VERSION ),
673 + 'warning'
674 + );
675 + }
676 +
677 + /*
678 + * Same for a .htaccess that PHP can write but not read: since 2.11.8 it is
679 + * left as it is rather than replaced by the Vigilant rules alone, and
680 + * that does not mend itself either. The first version of that fix left
681 + * it to the hourly retry, with a message about read only files; found by
682 + * the cross review of 2.11.8.
683 + */
684 + if ( $unreadable && $this->activity_log ) {
685 + $this->activity_log->log(
686 + 'system',
687 + 'server_rules_read_failed',
688 + __( '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' ),
689 + array( 'version' => VIGILANTE_VERSION ),
690 + 'warning'
691 + );
692 + }
693 +
694 + $this->mark_server_files_synced();
695 +
696 + if ( $rewrote && ! $incomplete && ! $unreadable && $this->activity_log ) {
697 + $this->activity_log->log(
698 + 'system',
699 + 'server_rules_refreshed',
700 + sprintf(
701 + /* translators: %s: plugin version. */
702 + __( 'The .htaccess rules were rewritten to match Vigilant %s.', 'vigilante' ),
703 + VIGILANTE_VERSION
704 + ),
705 + array( 'version' => VIGILANTE_VERSION ),
706 + 'info'
707 + );
708 + }
709 + }
710 +
711 + /**
712 + * Record that the server layer matches the installed version
713 + *
714 + * @since 2.9.9
715 + */
716 + private function mark_server_files_synced() {
717 + update_option( 'vigilante_server_files_version', VIGILANTE_VERSION );
718 + delete_option( 'vigilante_server_files_pending' );
719 + delete_option( 'vigilante_server_files_retry_after' );
720 + }
721 +
722 + /**
423 723 * Update the critical file baseline after Vigilante writes to a monitored file
424 724 *
425 725 * @param string $filename File that was modified (e.g. 'wp-config.php').
426 726 */
@@ -460,10 +760,13 @@
460 760 $this->database->cleanup_expired_2fa_codes();
461 761 $this->database->cleanup_expired_trusted_devices();
462 762
463 763 // 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' );
764 + // WordPress core updates recreate these files, so we clean them daily.
765 + // They sit in the root every site of a network shares, so only the main
766 + // site removes them, from its own settings; until 2.11.6 the daily
767 + // maintenance of any site did.
768 + $advanced = Vigilante_Settings::owns_shared_files() ? $this->settings->get_section( 'advanced' ) : array();
466 769 if ( ! empty( $advanced['remove_readme'] ) ) {
467 770 $readme_path = ABSPATH . 'readme.html';
468 771 if ( file_exists( $readme_path ) ) {
469 772 wp_delete_file( $readme_path );
@@ -603,16 +906,19 @@
603 906 register_activation_hook( __FILE__, 'vigilante_activate' );
604 907
605 908 /**
606 909 * Plugin deactivation hook
910 + *
911 + * @param bool $network_wide Whether core is deactivating the plugin for the whole network.
607 912 */
608 -function vigilante_deactivate() {
913 +function vigilante_deactivate( $network_wide = false ) {
609 914 require_once VIGILANTE_INCLUDES_DIR . 'class-database.php';
610 915 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
611 916 require_once VIGILANTE_INCLUDES_DIR . 'class-backup-manager.php';
917 + require_once VIGILANTE_INCLUDES_DIR . 'class-wpconfig-security.php';
612 918 require_once VIGILANTE_INCLUDES_DIR . 'class-deactivator.php';
613 919
614 - Vigilante_Deactivator::deactivate();
920 + Vigilante_Deactivator::deactivate( (bool) $network_wide );
615 921 }
616 922 register_deactivation_hook( __FILE__, 'vigilante_deactivate' );
617 923
618 924 /**
@@ -618,8 +924,51 @@
618 924 /**
619 925 * Initialize plugin after WordPress loads
620 926 */
621 927 add_action( 'plugins_loaded', 'vigilante_load_plugin' );
928 +
929 +/*
930 + * The hidden wp-admin is answered as early as the request can be judged with
931 + * certainty, before the theme and the other plugins load. The modules are built
932 + * on init priority 1, so until 2.9.9 a request that was going to be refused had
933 + * already paid for the whole boot.
934 + */
935 +add_action( 'plugins_loaded', 'vigilante_block_hidden_admin_early', 1 );
936 +
937 +/**
938 + * Cheap gate for the early hidden wp-admin rejection
939 + *
940 + * Everything that can be decided without loading a single plugin class is
941 + * decided here, so the usual request pays nothing more than a couple of
942 + * comparisons and one option read that WordPress has already cached.
943 + *
944 + * @since 2.9.9
945 + */
946 +function vigilante_block_hidden_admin_early() {
947 + if ( ! is_admin() ) {
948 + return;
949 + }
950 +
951 + $method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'GET';
952 +
953 + // POST is how remote managers authenticate, and the later path lets it through too.
954 + if ( 'GET' !== $method ) {
955 + return;
956 + }
957 +
958 + $options = get_option( 'vigilante_options', array() );
959 +
960 + if ( ! is_array( $options )
961 + || empty( $options['modules']['login_security'] )
962 + || empty( $options['login_security']['custom_login_url'] ) ) {
963 + return;
964 + }
965 +
966 + require_once VIGILANTE_INCLUDES_DIR . 'class-ip-utils.php';
967 + require_once VIGILANTE_INCLUDES_DIR . 'class-login-security.php';
968 +
969 + Vigilante_Login_Security::maybe_block_hidden_admin_early( $options );
970 +}
622 971
623 972 /**
624 973 * Helper function to get plugin instance
625 974 *