PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.0
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.0
3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
vigilante / vigilante.php

vigilante.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 3.0.0, at vigilante.php

1,174 lines 49.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Plugin Name: Vigilant - 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner…
4 * Plugin URI: https://servicios.ayudawp.com
5 * Description: Complete security solution for WordPress. Firewall, 2FA, security headers, login protection, file integrity monitoring, activity logging and more.
6 * Version: 3.0.0
7 * Author: Fernando Tellado
8 * Author URI: https://ayudawp.com
9 * Text Domain: vigilante
10 * Requires at least: 6.2
11 * Tested up to: 7.1
12 * Requires PHP: 7.4
13 * License: GPL v2 or later
14 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
15 *
16 * @package Vigilante
17 */
18
19 // Prevent direct access
20 if ( ! defined( 'ABSPATH' ) ) {
21 exit;
22 }
23
24 /**
25 * Plugin constants
26 */
27 define( 'VIGILANTE_VERSION', '3.0.0' );
28 define( 'VIGILANTE_PLUGIN_FILE', __FILE__ );
29 define( 'VIGILANTE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
30 define( 'VIGILANTE_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
31 define( 'VIGILANTE_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
32 define( 'VIGILANTE_INCLUDES_DIR', VIGILANTE_PLUGIN_DIR . 'includes/' );
33 define( 'VIGILANTE_ADMIN_DIR', VIGILANTE_PLUGIN_DIR . 'admin/' );
34 define( 'VIGILANTE_ASSETS_URL', VIGILANTE_PLUGIN_URL . 'assets/' );
35
36 // Backup directory outside plugin folder (persists through updates)
37 define( 'VIGILANTE_BACKUP_DIR', WP_CONTENT_DIR . '/vigilante-backups/' );
38
39 // Minimum requirements
40 define( 'VIGILANTE_MIN_PHP_VERSION', '7.4' );
41 define( 'VIGILANTE_MIN_WP_VERSION', '5.0' );
42
43 /**
44 * Check minimum requirements before loading
45 *
46 * @return bool True if requirements are met
47 */
48 function vigilante_check_requirements() {
49 $meets_requirements = true;
50
51 // Check PHP version
52 if ( version_compare( PHP_VERSION, VIGILANTE_MIN_PHP_VERSION, '<' ) ) {
53 $meets_requirements = false;
54 }
55
56 // Check WordPress version
57 global $wp_version;
58 if ( version_compare( $wp_version, VIGILANTE_MIN_WP_VERSION, '<' ) ) {
59 $meets_requirements = false;
60 }
61
62 if ( ! $meets_requirements ) {
63 add_action( 'admin_notices', 'vigilante_requirements_notice' );
64 }
65
66 return $meets_requirements;
67 }
68
69 /**
70 * Display requirements notice - called at admin_notices (after init)
71 */
72 function vigilante_requirements_notice() {
73 global $wp_version;
74 $errors = array();
75
76 if ( version_compare( PHP_VERSION, VIGILANTE_MIN_PHP_VERSION, '<' ) ) {
77 $errors[] = sprintf(
78 /* translators: 1: Current PHP version, 2: Required PHP version */
79 __( 'Vigilant requires PHP %2$s or higher. You are running PHP %1$s.', 'vigilante' ),
80 PHP_VERSION,
81 VIGILANTE_MIN_PHP_VERSION
82 );
83 }
84
85 if ( version_compare( $wp_version, VIGILANTE_MIN_WP_VERSION, '<' ) ) {
86 $errors[] = sprintf(
87 /* translators: 1: Current WordPress version, 2: Required WordPress version */
88 __( 'Vigilant requires WordPress %2$s or higher. You are running WordPress %1$s.', 'vigilante' ),
89 $wp_version,
90 VIGILANTE_MIN_WP_VERSION
91 );
92 }
93
94 foreach ( $errors as $error ) {
95 printf(
96 '<div class="notice notice-error"><p>%s</p></div>',
97 esc_html( $error )
98 );
99 }
100 }
101
102 /**
103 * Load plugin files
104 */
105 function vigilante_load_plugin() {
106 // Check requirements first
107 if ( ! vigilante_check_requirements() ) {
108 return;
109 }
110
111 // Load core classes (no translations used in these)
112 require_once VIGILANTE_INCLUDES_DIR . 'class-database.php';
113 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
114 require_once VIGILANTE_INCLUDES_DIR . 'class-ip-utils.php';
115 require_once VIGILANTE_INCLUDES_DIR . 'class-backup-manager.php';
116 require_once VIGILANTE_INCLUDES_DIR . 'class-activator.php';
117 require_once VIGILANTE_INCLUDES_DIR . 'class-deactivator.php';
118
119 // Load security module files (just loading, not initializing)
120 require_once VIGILANTE_INCLUDES_DIR . 'class-firewall.php';
121 require_once VIGILANTE_INCLUDES_DIR . 'class-security-headers.php';
122 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-protection.php';
123 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-recovery.php';
124 require_once VIGILANTE_INCLUDES_DIR . 'class-wpconfig-security.php';
125 require_once VIGILANTE_INCLUDES_DIR . 'class-https-enforcer.php';
126 require_once VIGILANTE_INCLUDES_DIR . 'class-rest-api-security.php';
127 require_once VIGILANTE_INCLUDES_DIR . 'class-user-security.php';
128 require_once VIGILANTE_INCLUDES_DIR . 'class-login-security.php';
129 require_once VIGILANTE_INCLUDES_DIR . 'trait-two-factor-session.php';
130 require_once VIGILANTE_INCLUDES_DIR . 'class-two-factor-email.php';
131 require_once VIGILANTE_INCLUDES_DIR . 'class-two-factor-totp.php';
132 require_once VIGILANTE_INCLUDES_DIR . 'class-email-template.php';
133 require_once VIGILANTE_INCLUDES_DIR . 'class-comment-security.php';
134 require_once VIGILANTE_INCLUDES_DIR . 'class-head-cleaner.php';
135 require_once VIGILANTE_INCLUDES_DIR . 'class-feed-manager.php';
136 require_once VIGILANTE_INCLUDES_DIR . 'class-activity-log.php';
137 require_once VIGILANTE_INCLUDES_DIR . 'class-audit-alerts.php';
138 require_once VIGILANTE_INCLUDES_DIR . 'class-file-integrity.php';
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';
143 require_once VIGILANTE_INCLUDES_DIR . 'class-under-attack.php';
144 require_once VIGILANTE_INCLUDES_DIR . 'class-database-backup.php';
145 require_once VIGILANTE_INCLUDES_DIR . 'class-database-prefix.php';
146 require_once VIGILANTE_INCLUDES_DIR . 'class-security-analyzer.php';
147
148 // Load admin classes
149 if ( is_admin() ) {
150 require_once VIGILANTE_ADMIN_DIR . 'class-admin-analyzer-ajax.php';
151 require_once VIGILANTE_ADMIN_DIR . 'class-admin-recovery-ajax.php';
152 require_once VIGILANTE_ADMIN_DIR . 'class-admin-audit-alerts-ajax.php';
153 require_once VIGILANTE_ADMIN_DIR . 'class-admin.php';
154 }
155
156 // Weekly Security Analyzer cron (registered even outside admin so it fires on cron hit).
157 add_action( 'vigilante_analyzer_weekly_scan', 'vigilante_run_analyzer_cron' );
158
159 // Daily plugin status check (closed-in-wp.org detection).
160 add_action( 'vigilante_plugin_status_check', 'vigilante_run_plugin_status_check' );
161
162 // Post-Under Attack scan (one-shot, scheduled by Vigilante_Under_Attack::deactivate).
163 add_action( 'vigilante_under_attack_post_scan', 'vigilante_run_post_under_attack_scan' );
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
175 // Initialize core components only - modules will be initialized at init
176 add_action( 'init', 'vigilante_init_plugin', 1 );
177 }
178
179 /**
180 * Initialize plugin at init hook (translations are ready)
181 */
182 function vigilante_init_plugin() {
183 Vigilante_Main::get_instance();
184 }
185
186 /**
187 * Main plugin class - Singleton pattern
188 */
189 final class Vigilante_Main {
190
191 /**
192 * Single instance of the class
193 *
194 * @var Vigilante_Main|null
195 */
196 private static $instance = null;
197
198 /**
199 * Settings instance
200 *
201 * @var Vigilante_Settings
202 */
203 public $settings;
204
205 /**
206 * Database instance
207 *
208 * @var Vigilante_Database
209 */
210 public $database;
211
212 /**
213 * Activity log instance
214 *
215 * @var Vigilante_Activity_Log
216 */
217 public $activity_log;
218
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 /**
227 * Get single instance of the class
228 *
229 * @return Vigilante_Main
230 */
231 public static function get_instance() {
232 if ( null === self::$instance ) {
233 self::$instance = new self();
234 }
235 return self::$instance;
236 }
237
238 /**
239 * Constructor - private to enforce singleton
240 */
241 private function __construct() {
242 $this->init_core();
243 $this->init_modules();
244 $this->init_hooks();
245 }
246
247 /**
248 * Prevent cloning
249 */
250 private function __clone() {}
251
252 /**
253 * Prevent unserializing
254 *
255 * @throws Exception Always throws exception.
256 */
257 public function __wakeup() {
258 throw new Exception( 'Cannot unserialize singleton' );
259 }
260
261 /**
262 * Initialize core components
263 */
264 private function init_core() {
265 $this->database = new Vigilante_Database();
266 $this->settings = new Vigilante_Settings();
267 $this->activity_log = new Vigilante_Activity_Log( $this->settings, $this->database );
268
269 // Auto-create/update tables when DB version is outdated (handles file-only updates)
270 if ( $this->database->needs_update() ) {
271 $this->database->create_tables();
272 }
273
274 // One-time cleanup: versions before 2.7.0 wrote config backups (including
275 // wp-config.php) as files under wp-content/vigilante-backups/. Those now
276 // live in the database, so remove anything left on disk.
277 if ( ! get_option( 'vigilante_legacy_backups_cleaned' ) ) {
278 Vigilante_Backup_Manager::cleanup_legacy_files();
279 update_option( 'vigilante_legacy_backups_cleaned', 1, false );
280 }
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
287 // One-time migration (2.9.0): add '.css' to File Integrity's excluded
288 // extensions on existing installs. Stylesheets are rewritten so often by
289 // themes and optimizer plugins that they were the main post-update false
290 // positive. New installs get it from the defaults; this brings existing
291 // sites in line without touching any other setting. Additive, idempotent.
292 if ( ! get_option( 'vigilante_css_exclusion_migrated' ) ) {
293 $fi = $this->settings->get_section( 'file_integrity' );
294 if ( is_array( $fi ) ) {
295 $ext = ( isset( $fi['excluded_extensions'] ) && is_array( $fi['excluded_extensions'] ) )
296 ? $fi['excluded_extensions']
297 : array();
298 if ( ! in_array( '.css', $ext, true ) ) {
299 $ext[] = '.css';
300 $fi['excluded_extensions'] = $ext;
301 $this->settings->update_section( 'file_integrity', $fi );
302 }
303 }
304 update_option( 'vigilante_css_exclusion_migrated', 1, false );
305 }
306
307 // One-time on upgrade to 2.9.0: drop any cached WordPress.org checksum
308 // manifests. The new comparison is array-aware and self-corrects a cached
309 // array-md5 value, but a manifest cached by an older version while wp.org
310 // was still propagating a new release could otherwise keep producing
311 // false "modified" results until it expires (up to 24h). Flushing on
312 // upgrade guarantees a clean slate on the very release that fixes them;
313 // the next scan refetches fresh manifests. One-time, bulk, no caching.
314 if ( ! get_option( 'vigilante_checksum_cache_flushed_290' ) ) {
315 global $wpdb;
316 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-time 2.9.0 migration dropping stale checksum transients so the new comparison starts clean.
317 "DELETE FROM {$wpdb->options}
318 WHERE option_name LIKE '\\_transient\\_vigilante\\_plugin\\_checksums\\_%'
319 OR option_name LIKE '\\_transient\\_timeout\\_vigilante\\_plugin\\_checksums\\_%'
320 OR option_name LIKE '\\_transient\\_vigilante\\_theme\\_checksums\\_%'
321 OR option_name LIKE '\\_transient\\_timeout\\_vigilante\\_theme\\_checksums\\_%'
322 OR option_name LIKE '\\_transient\\_vigilante\\_core\\_checksums\\_%'
323 OR option_name LIKE '\\_transient\\_timeout\\_vigilante\\_core\\_checksums\\_%'"
324 );
325 update_option( 'vigilante_checksum_cache_flushed_290', 1, false );
326 }
327 }
328
329 /**
330 * Initialize security modules based on settings
331 */
332 private function init_modules() {
333 $options = $this->settings->get_all_options();
334
335 // Self-heal: a UI bug in earlier 2.4.x betas could leave a section's
336 // top-level 'enabled' flag set to false because the section forms do
337 // not render a checkbox for that field — saving any tab caused the
338 // generic save handler to treat the missing field as "unchecked" and
339 // store it as false. If the master module toggle on the Dashboard is
340 // on but the section flag is off, restore it here so the module's
341 // hooks can attach. Idempotent: noop on healthy installs.
342 $sections = array(
343 'firewall',
344 'security_headers',
345 'login_security',
346 'rest_api_security',
347 'user_security',
348 'wp_hardening',
349 'file_integrity',
350 'activity_log',
351 );
352 $heal_changed = false;
353 foreach ( $sections as $section_name ) {
354 if ( ! empty( $options['modules'][ $section_name ] )
355 && isset( $options[ $section_name ] )
356 && is_array( $options[ $section_name ] )
357 && array_key_exists( 'enabled', $options[ $section_name ] )
358 && empty( $options[ $section_name ]['enabled'] ) ) {
359 $options[ $section_name ]['enabled'] = true;
360 $heal_changed = true;
361 }
362 }
363 if ( $heal_changed ) {
364 update_option( Vigilante_Settings::OPTION_NAME, $options );
365 $this->settings->clear_cache();
366 $options = $this->settings->get_all_options();
367 }
368
369 // Firewall - runs early to block threats
370 if ( ! empty( $options['modules']['firewall'] ) ) {
371 new Vigilante_Firewall( $this->settings, $this->activity_log );
372 }
373
374 // Security Headers - rules are applied via .htaccess, no runtime hooks needed
375 // HTTPS Enforcer still needs runtime hooks
376 if ( ! empty( $options['modules']['security_headers'] ) ) {
377 new Vigilante_Https_Enforcer( $this->settings );
378 }
379
380 // REST API Security
381 if ( ! empty( $options['modules']['rest_api_security'] ) ) {
382 new Vigilante_Rest_Api_Security( $this->settings );
383 }
384
385 // User Security
386 if ( ! empty( $options['modules']['user_security'] ) ) {
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 );
399 }
400
401 // Login Security
402 $login_security = null;
403
404 if ( ! empty( $options['modules']['login_security'] ) ) {
405 $login_security = new Vigilante_Login_Security( $this->settings, $this->database, $this->activity_log );
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 ) {
425 new Vigilante_Two_Factor_Email( $this->settings, $this->database, $this->activity_log, $login_security );
426 new Vigilante_Two_Factor_TOTP( $this->settings, $this->database, $this->activity_log, $login_security );
427 }
428
429 // WordPress Hardening (includes comments, head cleaner, feeds)
430 if ( ! empty( $options['modules']['wp_hardening'] ) ) {
431 new Vigilante_Comment_Security( $this->settings );
432 new Vigilante_Head_Cleaner( $this->settings );
433 new Vigilante_Feed_Manager( $this->settings );
434 }
435
436 // File Integrity Scanner
437 if ( ! empty( $options['modules']['file_integrity'] ) ) {
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
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();
471 }
472
473 // Activity Log is always initialized (core component)
474 // Logging is gated by the modules.activity_log toggle and per-type flags
475
476 // Audit Alerts engine - an alerting layer on top of Security Audit.
477 // Only instantiated when Security Audit is on, because it reacts to the
478 // events the activity log records (a passive subscriber, no per-module
479 // coupling). Both alert legs are opt-in, off by default.
480 if ( ! empty( $options['modules']['activity_log'] ) ) {
481 new Vigilante_Audit_Alerts( $this->settings, $this->activity_log );
482 }
483
484 // Under Attack mode - always loaded (independent of modules)
485 new Vigilante_Under_Attack( $this->settings, $this->activity_log );
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
495 // Admin interface
496 if ( is_admin() ) {
497 new Vigilante_Admin( $this->settings, $this->database, $this->activity_log );
498 }
499 }
500
501 /**
502 * Initialize WordPress hooks
503 */
504 private function init_hooks() {
505 // Plugin action links
506 add_filter( 'plugin_action_links_' . VIGILANTE_PLUGIN_BASENAME, array( $this, 'add_action_links' ) );
507
508 // Scheduled tasks
509 add_action( 'vigilante_daily_maintenance', array( $this, 'daily_maintenance' ) );
510 add_action( 'vigilante_hourly_checks', array( $this, 'hourly_checks' ) );
511
512 // AJAX handlers
513 add_action( 'wp_ajax_vigilante_dismiss_notice', array( $this, 'ajax_dismiss_notice' ) );
514
515 // Regenerate critical file baseline after Vigilante modifies wp-config.php or .htaccess
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 );
520 }
521
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 /**
751 * Update the critical file baseline after Vigilante writes to a monitored file
752 *
753 * @param string $filename File that was modified (e.g. 'wp-config.php').
754 */
755 public function on_critical_file_written( $filename ) {
756 if ( ! class_exists( 'Vigilante_File_Integrity' ) ) {
757 require_once VIGILANTE_INCLUDES_DIR . 'class-file-integrity.php';
758 }
759
760 $fi = new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
761 $fi->update_critical_file_baseline( $filename );
762 }
763
764 /**
765 * Add plugin action links
766 *
767 * @param array $links Existing links.
768 * @return array Modified links.
769 */
770 public function add_action_links( $links ) {
771 $plugin_links = array(
772 '<a href="' . esc_url( admin_url( 'admin.php?page=vigilante' ) ) . '">' . esc_html__( 'Security Settings', 'vigilante' ) . '</a>',
773 );
774 return array_merge( $plugin_links, $links );
775 }
776
777 /**
778 * Daily maintenance tasks
779 */
780 public function daily_maintenance() {
781 // Clean old activity logs
782 $this->activity_log->cleanup_old_logs();
783
784 // Clean old login attempts
785 $this->database->cleanup_old_login_attempts();
786
787 // Clean expired 2FA codes and trusted devices
788 $this->database->cleanup_expired_2fa_codes();
789 $this->database->cleanup_expired_trusted_devices();
790
791 // Remove sensitive files (readme.html, license.txt, licencia.txt)
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();
797 if ( ! empty( $advanced['remove_readme'] ) ) {
798 $readme_path = ABSPATH . 'readme.html';
799 if ( file_exists( $readme_path ) ) {
800 wp_delete_file( $readme_path );
801 }
802 }
803 if ( ! empty( $advanced['remove_license'] ) ) {
804 $license_files = array( 'license.txt', 'licencia.txt' );
805 foreach ( $license_files as $license_file ) {
806 $license_path = ABSPATH . $license_file;
807 if ( file_exists( $license_path ) ) {
808 wp_delete_file( $license_path );
809 }
810 }
811 }
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
831 // Log maintenance
832 $this->activity_log->log( 'system', 'maintenance', __( 'Daily maintenance completed', 'vigilante' ) );
833 }
834
835 /**
836 * Hourly checks
837 */
838 public function hourly_checks() {
839 // File integrity scans are handled by the File_Integrity class own cron schedule
840 // based on the configured scan_frequency (daily/weekly).
841 }
842
843 /**
844 * AJAX handler for dismissing notices
845 */
846 public function ajax_dismiss_notice() {
847 check_ajax_referer( 'vigilante_dismiss_notice', 'nonce' );
848
849 if ( ! current_user_can( 'manage_options' ) ) {
850 wp_die( -1 );
851 }
852
853 $notice_id = isset( $_POST['notice_id'] ) ? sanitize_key( $_POST['notice_id'] ) : '';
854
855 if ( $notice_id ) {
856 $dismissed = get_option( 'vigilante_dismissed_notices', array() );
857 $dismissed[ $notice_id ] = time();
858 update_option( 'vigilante_dismissed_notices', $dismissed );
859 }
860
861 wp_send_json_success();
862 }
863 }
864
865 /**
866 * Cron handler for the weekly Security Analyzer scan.
867 *
868 * Resolves the shared Vigilante_Security_Analyzer (lazily; no cost when the
869 * cron is not firing) and lets it run the scan + regression email logic.
870 */
871 function vigilante_run_analyzer_cron() {
872 if ( ! class_exists( 'Vigilante_Security_Analyzer' ) ) {
873 require_once VIGILANTE_INCLUDES_DIR . 'class-security-analyzer.php';
874 }
875 if ( ! class_exists( 'Vigilante_Settings' ) ) {
876 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
877 }
878
879 $settings = new Vigilante_Settings();
880 $activity_log = null;
881 if ( class_exists( 'Vigilante_Activity_Log' ) && class_exists( 'Vigilante_Database' ) ) {
882 $database = new Vigilante_Database();
883 $activity_log = new Vigilante_Activity_Log( $settings, $database );
884 }
885
886 $analyzer = new Vigilante_Security_Analyzer( $settings, $activity_log );
887 $analyzer->cron_weekly_scan();
888 }
889
890 /**
891 * Cron handler for the daily plugin status check.
892 *
893 * Resolves the shared Vigilante_Plugin_Status lazily so the daily cron has no
894 * cost while it is not firing.
895 */
896 function vigilante_run_plugin_status_check() {
897 if ( ! class_exists( 'Vigilante_Plugin_Status' ) ) {
898 require_once VIGILANTE_INCLUDES_DIR . 'class-plugin-status.php';
899 }
900 if ( ! class_exists( 'Vigilante_Settings' ) ) {
901 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
902 }
903
904 $settings = new Vigilante_Settings();
905 $activity_log = null;
906 if ( class_exists( 'Vigilante_Activity_Log' ) && class_exists( 'Vigilante_Database' ) ) {
907 $database = new Vigilante_Database();
908 $activity_log = new Vigilante_Activity_Log( $settings, $database );
909 }
910
911 $checker = new Vigilante_Plugin_Status( $settings, $activity_log );
912 $checker->run_scheduled_check();
913 }
914
915 /**
916 * Run a Security Analyzer full scan after Under Attack mode deactivates.
917 *
918 * Scheduled one-shot from Vigilante_Under_Attack::deactivate() so the dashboard
919 * reflects the restored configuration with the slow HTTP/header probes the
920 * mode prevented from running safely while it was active.
921 */
922 function vigilante_run_post_under_attack_scan() {
923 if ( ! class_exists( 'Vigilante_Under_Attack' ) ) {
924 require_once VIGILANTE_INCLUDES_DIR . 'class-under-attack.php';
925 }
926 if ( ! class_exists( 'Vigilante_Settings' ) ) {
927 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
928 }
929
930 $settings = new Vigilante_Settings();
931 $activity_log = null;
932 if ( class_exists( 'Vigilante_Activity_Log' ) && class_exists( 'Vigilante_Database' ) ) {
933 $database = new Vigilante_Database();
934 $activity_log = new Vigilante_Activity_Log( $settings, $database );
935 }
936
937 $under_attack = new Vigilante_Under_Attack( $settings, $activity_log );
938 $under_attack->run_analyzer_scan( 'all' );
939 }
940
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 /**
1091 * Plugin activation hook
1092 */
1093 function vigilante_activate() {
1094 require_once VIGILANTE_INCLUDES_DIR . 'class-database.php';
1095 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
1096 require_once VIGILANTE_INCLUDES_DIR . 'class-backup-manager.php';
1097 require_once VIGILANTE_INCLUDES_DIR . 'class-activator.php';
1098
1099 Vigilante_Activator::activate();
1100 }
1101 register_activation_hook( __FILE__, 'vigilante_activate' );
1102
1103 /**
1104 * Plugin deactivation hook
1105 *
1106 * @param bool $network_wide Whether core is deactivating the plugin for the whole network.
1107 */
1108 function vigilante_deactivate( $network_wide = false ) {
1109 require_once VIGILANTE_INCLUDES_DIR . 'class-database.php';
1110 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
1111 require_once VIGILANTE_INCLUDES_DIR . 'class-backup-manager.php';
1112 require_once VIGILANTE_INCLUDES_DIR . 'class-wpconfig-security.php';
1113 require_once VIGILANTE_INCLUDES_DIR . 'class-deactivator.php';
1114
1115 Vigilante_Deactivator::deactivate( (bool) $network_wide );
1116 }
1117 register_deactivation_hook( __FILE__, 'vigilante_deactivate' );
1118
1119 /**
1120 * Initialize plugin after WordPress loads
1121 */
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 }
1166
1167 /**
1168 * Helper function to get plugin instance
1169 *
1170 * @return Vigilante_Main
1171 */
1172 function vigilante() {
1173 return Vigilante_Main::get_instance();
1174 }