PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.2
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.2
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 +310 -7 2.11.6 → 3.0.2 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.11.6
6 + * Version: 3.0.2
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.11.6' );
27 +define( 'VIGILANTE_VERSION', '3.0.2' );
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__ ) );
@@ -136,8 +136,11 @@
136 136 require_once VIGILANTE_INCLUDES_DIR . 'class-activity-log.php';
137 137 require_once VIGILANTE_INCLUDES_DIR . 'class-audit-alerts.php';
138 138 require_once VIGILANTE_INCLUDES_DIR . 'class-file-integrity.php';
139 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';
140 143 require_once VIGILANTE_INCLUDES_DIR . 'class-under-attack.php';
141 144 require_once VIGILANTE_INCLUDES_DIR . 'class-database-backup.php';
142 145 require_once VIGILANTE_INCLUDES_DIR . 'class-database-prefix.php';
143 146 require_once VIGILANTE_INCLUDES_DIR . 'class-security-analyzer.php';
@@ -158,8 +161,18 @@
158 161
159 162 // Post-Under Attack scan (one-shot, scheduled by Vigilante_Under_Attack::deactivate).
160 163 add_action( 'vigilante_under_attack_post_scan', 'vigilante_run_post_under_attack_scan' );
161 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 +
162 175 // Initialize core components only - modules will be initialized at init
163 176 add_action( 'init', 'vigilante_init_plugin', 1 );
164 177 }
165 178
@@ -203,8 +216,15 @@
203 216 */
204 217 public $activity_log;
205 218
206 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 + /**
207 227 * Get single instance of the class
208 228 *
209 229 * @return Vigilante_Main
210 230 */
@@ -263,8 +283,65 @@
263 283 // that earlier versions kept in the options table, on this site and, on
264 284 // a network, on every site of it.
265 285 Vigilante_Backup_Manager::maybe_purge_stored_copies();
266 286
287 + /*
288 + * One-time (3.0.1): put back the lists that every version up to 3.0.0
289 + * blanked without being asked.
290 + *
291 + * Saving any tab wrote array() over every list of that section whose key
292 + * the submitted form did not carry, and four of those lists have no
293 + * field on any screen, so their key could never be carried. Pressing
294 + * Save on the Firewall tab emptied allowed_http_methods; pressing it on
295 + * Users emptied insecure_usernames and the roles of registration
296 + * approval. Nothing showed, because reading the settings merged the
297 + * shipped values over the top of whatever was stored.
298 + *
299 + * 3.0.1 stops doing that: an empty stored list now means empty, which is
300 + * what makes unticking every role work. On a site that had been saved,
301 + * an empty allowed_http_methods would make the firewall answer 403 to
302 + * every method, the front page included.
303 + *
304 + * So the key is removed, not filled in: with no key the setting takes
305 + * its shipped value again, which is exactly what it was worth before,
306 + * and it follows the shipped value if that ever changes. Only these
307 + * four, and only when empty. Every other list has a field on screen, so
308 + * an empty one was emptied by somebody who meant it, and it is left
309 + * alone. Runs once per site, tracked by an option and not a transient.
310 + */
311 + if ( ! get_option( 'vigilante_blank_lists_restored' ) ) {
312 + $vig_stored = get_option( Vigilante_Settings::OPTION_NAME, array() );
313 + $vig_changed = false;
314 +
315 + if ( is_array( $vig_stored ) ) {
316 + $vig_blanked = array(
317 + array( 'firewall', 'allowed_http_methods' ),
318 + array( 'user_security', 'insecure_usernames' ),
319 + array( 'rest_api_security', 'allowed_public_endpoints' ),
320 + array( 'user_security', 'registration_approval', 'affected_roles' ),
321 + );
322 +
323 + foreach ( $vig_blanked as $vig_path ) {
324 + if ( 2 === count( $vig_path ) ) {
325 + if ( isset( $vig_stored[ $vig_path[0] ][ $vig_path[1] ] ) && array() === $vig_stored[ $vig_path[0] ][ $vig_path[1] ] ) {
326 + unset( $vig_stored[ $vig_path[0] ][ $vig_path[1] ] );
327 + $vig_changed = true;
328 + }
329 + } elseif ( isset( $vig_stored[ $vig_path[0] ][ $vig_path[1] ][ $vig_path[2] ] ) && array() === $vig_stored[ $vig_path[0] ][ $vig_path[1] ][ $vig_path[2] ] ) {
330 + unset( $vig_stored[ $vig_path[0] ][ $vig_path[1] ][ $vig_path[2] ] );
331 + $vig_changed = true;
332 + }
333 + }
334 + }
335 +
336 + if ( $vig_changed ) {
337 + update_option( Vigilante_Settings::OPTION_NAME, $vig_stored );
338 + $this->settings->clear_cache();
339 + }
340 +
341 + update_option( 'vigilante_blank_lists_restored', 1, false );
342 + }
343 +
267 344 // One-time migration (2.9.0): add '.css' to File Integrity's excluded
268 345 // extensions on existing installs. Stylesheets are rewritten so often by
269 346 // themes and optimizer plugins that they were the main post-update false
270 347 // positive. New installs get it from the defaults; this brings existing
@@ -364,15 +441,45 @@
364 441
365 442 // User Security
366 443 if ( ! empty( $options['modules']['user_security'] ) ) {
367 444 new Vigilante_User_Security( $this->settings, $this->activity_log );
445 + } else {
446 + /*
447 + * Turning the module off must not quietly unlock the accounts it
448 + * already locked. A forced password reset and a registration waiting
449 + * for approval are marks written on somebody's account, with their
450 + * sessions already destroyed and the activity log saying they cannot
451 + * get in; until 2.11.10 both stopped being enforced the moment this
452 + * toggle went off and every one of those accounts logged in again
453 + * with its old password. Only the enforcing half is registered here.
454 + */
455 + new Vigilante_User_Security( $this->settings, $this->activity_log, true );
368 456 }
369 457
370 458 // Login Security
459 + $login_security = null;
460 +
371 461 if ( ! empty( $options['modules']['login_security'] ) ) {
372 462 $login_security = new Vigilante_Login_Security( $this->settings, $this->database, $this->activity_log );
373 -
374 - // Two-Factor Authentication (only if login security module is active)
463 + }
464 +
465 + /*
466 + * Two factor on a network is decided for the whole network, so it has to
467 + * be ENFORCED on the whole network too. Gating these two on this site's
468 + * module toggle left the last leg of the bypass open: the administrator
469 + * of any subsite can turn Login Security off on their own site, which
470 + * takes them out of the picture but not out of the network, and the
471 + * session cookie WordPress issues there is valid on every host of it. So
472 + * a login sent to that subsite registered no second factor check at all
473 + * and the cookie it handed back opened the main site. Reproduced over
474 + * HTTP by the second cross review of 2.11.10.
475 + *
476 + * Same reasoning, and the same shape, as the enforcement-only User
477 + * Security above: on a network the enforcing half is registered whatever
478 + * this site says. On a single site there is no other site to protect and
479 + * the toggle means what it says.
480 + */
481 + if ( is_multisite() || null !== $login_security ) {
375 482 new Vigilante_Two_Factor_Email( $this->settings, $this->database, $this->activity_log, $login_security );
376 483 new Vigilante_Two_Factor_TOTP( $this->settings, $this->database, $this->activity_log, $login_security );
377 484 }
378 485
@@ -433,8 +540,16 @@
433 540
434 541 // Under Attack mode - always loaded (independent of modules)
435 542 new Vigilante_Under_Attack( $this->settings, $this->activity_log );
436 543
544 + // Self-protection - always loaded, NOT gated by modules.file_integrity:
545 + // the version change and watchdog paths must stay alive with the File
546 + // Integrity module off. There is no setting to gate it: see
547 + // Vigilante_Self_Integrity::is_on(). Hooks are registered here and only here;
548 + // every other new of the class is a plain object.
549 + $this->self_integrity = new Vigilante_Self_Integrity( $this->settings, $this->activity_log );
550 + $this->self_integrity->init_hooks();
551 +
437 552 // Admin interface
438 553 if ( is_admin() ) {
439 554 new Vigilante_Admin( $this->settings, $this->database, $this->activity_log );
440 555 }
@@ -578,8 +693,10 @@
578 693 * backoff for something that is already being handled.
579 694 */
580 695 $locked = false;
581 696 $incomplete = false;
697 + $unreadable = false;
698 + $settled = array( 'locked', 'block_incomplete', 'read_failed' );
582 699
583 700 if ( $needs_protection_block ) {
584 701 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-protection.php';
585 702 $result = ( new Vigilante_Htaccess_Protection( $this->settings ) )->apply_rules( true );
@@ -585,9 +702,10 @@
585 702 $result = ( new Vigilante_Htaccess_Protection( $this->settings ) )->apply_rules( true );
586 703 $code = is_wp_error( $result ) ? $result->get_error_code() : ( true === $result ? '' : 'unexpected_result' );
587 704 $locked = $locked || 'locked' === $code;
588 705 $incomplete = $incomplete || 'block_incomplete' === $code;
589 - $failed = $failed || ( '' !== $code && 'locked' !== $code && 'block_incomplete' !== $code );
706 + $unreadable = $unreadable || 'read_failed' === $code;
707 + $failed = $failed || ( '' !== $code && ! in_array( $code, $settled, true ) );
590 708 $rewrote = true;
591 709 }
592 710
593 711 if ( ! $locked && ! empty( $options['modules']['security_headers'] ) ) {
@@ -595,9 +713,10 @@
595 713 $result = ( new Vigilante_Security_Headers( $this->settings ) )->apply_rules( true );
596 714 $code = is_wp_error( $result ) ? $result->get_error_code() : ( true === $result ? '' : 'unexpected_result' );
597 715 $locked = $locked || 'locked' === $code;
598 716 $incomplete = $incomplete || 'block_incomplete' === $code;
599 - $failed = $failed || ( '' !== $code && 'locked' !== $code && 'block_incomplete' !== $code );
717 + $unreadable = $unreadable || 'read_failed' === $code;
718 + $failed = $failed || ( '' !== $code && ! in_array( $code, $settled, true ) );
600 719 $rewrote = true;
601 720 }
602 721
603 722 if ( $locked ) {
@@ -639,11 +758,28 @@
639 758 'warning'
640 759 );
641 760 }
642 761
762 + /*
763 + * Same for a .htaccess that PHP can write but not read: since 2.11.8 it is
764 + * left as it is rather than replaced by the Vigilant rules alone, and
765 + * that does not mend itself either. The first version of that fix left
766 + * it to the hourly retry, with a message about read only files; found by
767 + * the cross review of 2.11.8.
768 + */
769 + if ( $unreadable && $this->activity_log ) {
770 + $this->activity_log->log(
771 + 'system',
772 + 'server_rules_read_failed',
773 + __( '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' ),
774 + array( 'version' => VIGILANTE_VERSION ),
775 + 'warning'
776 + );
777 + }
778 +
643 779 $this->mark_server_files_synced();
644 780
645 - if ( $rewrote && ! $incomplete && $this->activity_log ) {
781 + if ( $rewrote && ! $incomplete && ! $unreadable && $this->activity_log ) {
646 782 $this->activity_log->log(
647 783 'system',
648 784 'server_rules_refreshed',
649 785 sprintf(
@@ -730,8 +866,26 @@
730 866 }
731 867 }
732 868 }
733 869
870 + // Self-protection from cron, for sites nobody opens the admin of: the
871 + // watchdog of Vigilant's own scheduled events, and the check for a
872 + // version change made outside the WordPress updater (FTP, manual).
873 + // The version change goes first: it is the path that reports a downgrade
874 + // by email, and the watchdog's own daily check would otherwise get there
875 + // before it.
876 + if ( $this->self_integrity ) {
877 + if ( $this->self_integrity->is_enabled() ) {
878 + $this->self_integrity->detect_version_change();
879 + $this->self_integrity->run_watchdog();
880 + } else {
881 + // Switched off by code: the one line that still has to be
882 + // written, or an installation that stopped checking itself
883 + // would do it in silence.
884 + $this->self_integrity->audit_off_state();
885 + }
886 + }
887 +
734 888 // Log maintenance
735 889 $this->activity_log->log( 'system', 'maintenance', __( 'Daily maintenance completed', 'vigilante' ) );
736 890 }
737 891
@@ -838,8 +992,157 @@
838 992 }
839 993
840 994 $under_attack = new Vigilante_Under_Attack( $settings, $activity_log );
841 995 $under_attack->run_analyzer_scan( 'all' );
996 +}
997 +
998 +/**
999 + * Verify Vigilant's own files right after the WordPress updater replaced them
1000 + *
1001 + * Runs as the OLD code with the NEW files already on disk, so the class reads
1002 + * everything from disk (the Version header, the manifest) and nothing from
1003 + * constants in memory. The first update to 3.0.0 is not seen here, because the
1004 + * code that receives the hook is 2.x: that one is covered by the 3.0.0
1005 + * migration block of Vigilante_Admin::run_migrations().
1006 + *
1007 + * Plugin_Upgrader::upgrade() passes the updated plugin in 'plugin', and
1008 + * bulk_upgrade() passes the list in 'plugins'; both are read. Replacing the
1009 + * plugin by uploading its zip, from the screen or with `wp plugin install
1010 + * --force`, goes through Plugin_Upgrader::install() instead, whose context
1011 + * names no plugin (wp-admin/includes/class-plugin-upgrader.php, install()):
1012 + * plugin_info() reads it from the folder that was written.
1013 + *
1014 + * The check itself waits for the end of the request (see
1015 + * vigilante_verify_after_upgrade()): WP_Upgrader::run() fires this hook also
1016 + * when the install failed, and restores the previous copy of the plugin on
1017 + * shutdown, so checking here reported the half-moved folder of a failed update
1018 + * as tampering, by email.
1019 + *
1020 + * @since 3.0.0
1021 + *
1022 + * @param WP_Upgrader|mixed $upgrader Upgrader instance.
1023 + * @param array $hook_extra Update context.
1024 + */
1025 +function vigilante_on_upgrader_process_complete( $upgrader, $hook_extra ) {
1026 + if ( ! is_array( $hook_extra ) || 'plugin' !== ( isset( $hook_extra['type'] ) ? $hook_extra['type'] : '' ) ) {
1027 + return;
1028 + }
1029 +
1030 + $action = isset( $hook_extra['action'] ) ? $hook_extra['action'] : '';
1031 + $files = array();
1032 + if ( 'update' === $action ) {
1033 + $files = ( ! empty( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) ? $hook_extra['plugins'] : array();
1034 + if ( ! empty( $hook_extra['plugin'] ) ) {
1035 + $files[] = $hook_extra['plugin'];
1036 + }
1037 + } elseif ( 'install' === $action && is_object( $upgrader ) && method_exists( $upgrader, 'plugin_info' ) ) {
1038 + $installed = $upgrader->plugin_info();
1039 + if ( is_string( $installed ) && '' !== $installed ) {
1040 + $files[] = $installed;
1041 + }
1042 + } else {
1043 + return;
1044 + }
1045 + if ( ! in_array( VIGILANTE_PLUGIN_BASENAME, array_map( 'strval', $files ), true ) ) {
1046 + return;
1047 + }
1048 +
1049 + // After WP_Upgrader::restore_temp_backup() (shutdown, priority 10) and
1050 + // before WP_Upgrader::delete_temp_backup() (priority 100).
1051 + if ( false === has_action( 'shutdown', 'vigilante_verify_after_upgrade' ) ) {
1052 + add_action( 'shutdown', 'vigilante_verify_after_upgrade', 50 );
1053 + }
1054 +}
1055 +
1056 +/**
1057 + * The check after an update, at the end of the request
1058 + *
1059 + * By now a failed update has had its previous copy restored, so the files
1060 + * on disk are the ones the site will run. Hooked by
1061 + * vigilante_on_upgrader_process_complete(); a bulk update with Vigilant in
1062 + * the list hooks it once.
1063 + *
1064 + * @since 3.0.0
1065 + */
1066 +function vigilante_verify_after_upgrade() {
1067 + if ( ! class_exists( 'Vigilante_Settings' ) ) {
1068 + require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
1069 + }
1070 + if ( ! class_exists( 'Vigilante_Self_Integrity' ) ) {
1071 + require_once VIGILANTE_INCLUDES_DIR . 'class-self-integrity-guidance.php';
1072 + require_once VIGILANTE_INCLUDES_DIR . 'class-self-integrity.php';
1073 + }
1074 +
1075 + $settings = new Vigilante_Settings();
1076 + $activity_log = null;
1077 + if ( class_exists( 'Vigilante_Activity_Log' ) && class_exists( 'Vigilante_Database' ) ) {
1078 + $database = new Vigilante_Database();
1079 + $activity_log = new Vigilante_Activity_Log( $settings, $database );
1080 + }
1081 +
1082 + $self_integrity = new Vigilante_Self_Integrity( $settings, $activity_log );
1083 + $self_integrity->handle_upgrader( vigilante_upgrader_wrote_files_on_disk() );
1084 +}
1085 +
1086 +/**
1087 + * Whether the files on disk are the ones the WordPress updater wrote in this request
1088 + *
1089 + * The marker of vigilante_mark_upgrader_wrote() says WordPress wrote the
1090 + * folder, not that the folder is still that copy at the end of the request:
1091 + * the automatic updater puts the previous copy back when the updated plugin
1092 + * breaks its loopback request, and a failed install is restored on shutdown.
1093 + * The manifest identifies the copy, so the updater is trusted only when the
1094 + * manifest on disk is the one it wrote.
1095 + *
1096 + * @since 3.0.0
1097 + *
1098 + * @return bool
1099 + */
1100 +function vigilante_upgrader_wrote_files_on_disk() {
1101 + $mark = isset( $GLOBALS['vigilante_upgrader_wrote'] ) ? $GLOBALS['vigilante_upgrader_wrote'] : null;
1102 + if ( ! is_array( $mark ) || ! array_key_exists( 'manifest', $mark ) ) {
1103 + return false;
1104 + }
1105 + return $mark['manifest'] === Vigilante_Self_Integrity::manifest_fingerprint_of( VIGILANTE_PLUGIN_DIR );
1106 +}
1107 +
1108 +/**
1109 + * Remember that the WordPress updater wrote Vigilant's folder in this request
1110 + *
1111 + * upgrader_post_install runs once per package, after the files are in place
1112 + * and before upgrader_process_complete. The hook that follows names every
1113 + * plugin of a bulk update, including the ones that were skipped, so the check
1114 + * after the update only trusts the updater when this marker says it really
1115 + * replaced the folder. Only a folder of that name in the plugins directory
1116 + * counts (a theme can have the same name), and the marker keeps the
1117 + * fingerprint of the manifest that was written, which
1118 + * vigilante_upgrader_wrote_files_on_disk() compares with the disk at the end.
1119 + *
1120 + * @since 3.0.0
1121 + *
1122 + * @param bool|WP_Error $response Installation response.
1123 + * @param array $hook_extra Extra arguments passed to hooked filters.
1124 + * @param array $result Installation result data.
1125 + * @return bool|WP_Error The response, unchanged.
1126 + */
1127 +function vigilante_mark_upgrader_wrote( $response, $hook_extra, $result ) {
1128 + if ( is_wp_error( $response ) || ! is_array( $result ) || ! isset( $result['destination_name'], $result['destination'], $result['local_destination'] ) ) {
1129 + return $response;
1130 + }
1131 + if ( dirname( VIGILANTE_PLUGIN_BASENAME ) !== $result['destination_name'] ) {
1132 + return $response;
1133 + }
1134 + if ( untrailingslashit( wp_normalize_path( (string) $result['local_destination'] ) ) !== untrailingslashit( wp_normalize_path( WP_PLUGIN_DIR ) ) ) {
1135 + return $response;
1136 + }
1137 + if ( ! class_exists( 'Vigilante_Self_Integrity' ) ) {
1138 + require_once VIGILANTE_INCLUDES_DIR . 'class-self-integrity-guidance.php';
1139 + require_once VIGILANTE_INCLUDES_DIR . 'class-self-integrity.php';
1140 + }
1141 + $GLOBALS['vigilante_upgrader_wrote'] = array(
1142 + 'manifest' => Vigilante_Self_Integrity::manifest_fingerprint_of( (string) $result['destination'] ),
1143 + );
1144 + return $response;
842 1145 }
843 1146
844 1147 /**
845 1148 * Plugin activation hook