PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.1
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.1
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
vigilante / vigilante.php

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

1,231 lines 52.5 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.1
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.1' );
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 /*
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
344 // One-time migration (2.9.0): add '.css' to File Integrity's excluded
345 // extensions on existing installs. Stylesheets are rewritten so often by
346 // themes and optimizer plugins that they were the main post-update false
347 // positive. New installs get it from the defaults; this brings existing
348 // sites in line without touching any other setting. Additive, idempotent.
349 if ( ! get_option( 'vigilante_css_exclusion_migrated' ) ) {
350 $fi = $this->settings->get_section( 'file_integrity' );
351 if ( is_array( $fi ) ) {
352 $ext = ( isset( $fi['excluded_extensions'] ) && is_array( $fi['excluded_extensions'] ) )
353 ? $fi['excluded_extensions']
354 : array();
355 if ( ! in_array( '.css', $ext, true ) ) {
356 $ext[] = '.css';
357 $fi['excluded_extensions'] = $ext;
358 $this->settings->update_section( 'file_integrity', $fi );
359 }
360 }
361 update_option( 'vigilante_css_exclusion_migrated', 1, false );
362 }
363
364 // One-time on upgrade to 2.9.0: drop any cached WordPress.org checksum
365 // manifests. The new comparison is array-aware and self-corrects a cached
366 // array-md5 value, but a manifest cached by an older version while wp.org
367 // was still propagating a new release could otherwise keep producing
368 // false "modified" results until it expires (up to 24h). Flushing on
369 // upgrade guarantees a clean slate on the very release that fixes them;
370 // the next scan refetches fresh manifests. One-time, bulk, no caching.
371 if ( ! get_option( 'vigilante_checksum_cache_flushed_290' ) ) {
372 global $wpdb;
373 $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.
374 "DELETE FROM {$wpdb->options}
375 WHERE option_name LIKE '\\_transient\\_vigilante\\_plugin\\_checksums\\_%'
376 OR option_name LIKE '\\_transient\\_timeout\\_vigilante\\_plugin\\_checksums\\_%'
377 OR option_name LIKE '\\_transient\\_vigilante\\_theme\\_checksums\\_%'
378 OR option_name LIKE '\\_transient\\_timeout\\_vigilante\\_theme\\_checksums\\_%'
379 OR option_name LIKE '\\_transient\\_vigilante\\_core\\_checksums\\_%'
380 OR option_name LIKE '\\_transient\\_timeout\\_vigilante\\_core\\_checksums\\_%'"
381 );
382 update_option( 'vigilante_checksum_cache_flushed_290', 1, false );
383 }
384 }
385
386 /**
387 * Initialize security modules based on settings
388 */
389 private function init_modules() {
390 $options = $this->settings->get_all_options();
391
392 // Self-heal: a UI bug in earlier 2.4.x betas could leave a section's
393 // top-level 'enabled' flag set to false because the section forms do
394 // not render a checkbox for that field — saving any tab caused the
395 // generic save handler to treat the missing field as "unchecked" and
396 // store it as false. If the master module toggle on the Dashboard is
397 // on but the section flag is off, restore it here so the module's
398 // hooks can attach. Idempotent: noop on healthy installs.
399 $sections = array(
400 'firewall',
401 'security_headers',
402 'login_security',
403 'rest_api_security',
404 'user_security',
405 'wp_hardening',
406 'file_integrity',
407 'activity_log',
408 );
409 $heal_changed = false;
410 foreach ( $sections as $section_name ) {
411 if ( ! empty( $options['modules'][ $section_name ] )
412 && isset( $options[ $section_name ] )
413 && is_array( $options[ $section_name ] )
414 && array_key_exists( 'enabled', $options[ $section_name ] )
415 && empty( $options[ $section_name ]['enabled'] ) ) {
416 $options[ $section_name ]['enabled'] = true;
417 $heal_changed = true;
418 }
419 }
420 if ( $heal_changed ) {
421 update_option( Vigilante_Settings::OPTION_NAME, $options );
422 $this->settings->clear_cache();
423 $options = $this->settings->get_all_options();
424 }
425
426 // Firewall - runs early to block threats
427 if ( ! empty( $options['modules']['firewall'] ) ) {
428 new Vigilante_Firewall( $this->settings, $this->activity_log );
429 }
430
431 // Security Headers - rules are applied via .htaccess, no runtime hooks needed
432 // HTTPS Enforcer still needs runtime hooks
433 if ( ! empty( $options['modules']['security_headers'] ) ) {
434 new Vigilante_Https_Enforcer( $this->settings );
435 }
436
437 // REST API Security
438 if ( ! empty( $options['modules']['rest_api_security'] ) ) {
439 new Vigilante_Rest_Api_Security( $this->settings );
440 }
441
442 // User Security
443 if ( ! empty( $options['modules']['user_security'] ) ) {
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 );
456 }
457
458 // Login Security
459 $login_security = null;
460
461 if ( ! empty( $options['modules']['login_security'] ) ) {
462 $login_security = new Vigilante_Login_Security( $this->settings, $this->database, $this->activity_log );
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 ) {
482 new Vigilante_Two_Factor_Email( $this->settings, $this->database, $this->activity_log, $login_security );
483 new Vigilante_Two_Factor_TOTP( $this->settings, $this->database, $this->activity_log, $login_security );
484 }
485
486 // WordPress Hardening (includes comments, head cleaner, feeds)
487 if ( ! empty( $options['modules']['wp_hardening'] ) ) {
488 new Vigilante_Comment_Security( $this->settings );
489 new Vigilante_Head_Cleaner( $this->settings );
490 new Vigilante_Feed_Manager( $this->settings );
491 }
492
493 // File Integrity Scanner
494 if ( ! empty( $options['modules']['file_integrity'] ) ) {
495 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
496 $file_integrity->init_hooks();
497 $file_integrity->init_cleanup_hooks();
498
499 new Vigilante_Plugin_Status( $this->settings, $this->activity_log );
500 } elseif ( is_admin() ) {
501 /*
502 * The module is off, and the cleanup goes on anyway. It is not
503 * integrity monitoring: it takes out of the database the copy of
504 * wp-config.php that earlier versions stored, credentials and all.
505 * Turning the module off is not a decision to keep them.
506 *
507 * Two holes closed here, both reported by @calzbert after reading the
508 * 2.11.3 diff. A site with the module off cleaned itself by neither
509 * of its own two paths, because both hang off this class. And with
510 * the module off on the MAIN site, the network sweep was not
511 * registered either, which is what would have reached every other
512 * site: the sweep removes each site's option without asking whether
513 * the module is on over there.
514 *
515 * Only in the admin, because both hooks are admin_init and there is
516 * nothing to gain from building this on a front-end request. Note
517 * that admin-ajax.php fires admin_init too (wp-admin/admin-ajax.php
518 * :45), so this also runs on wp_ajax_nopriv_* requests from
519 * visitors with no session. That is deliberate and it is what the
520 * module has been doing since 2.11.2: the cleanup asks for no
521 * capability because it also runs under wp-cron with nobody logged
522 * in, and all it does is take the plugin's own copy out of the
523 * database. The network sweep, which does reach across sites, is
524 * the one that demands manage_network_options.
525 */
526 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
527 $file_integrity->init_cleanup_hooks();
528 }
529
530 // Activity Log is always initialized (core component)
531 // Logging is gated by the modules.activity_log toggle and per-type flags
532
533 // Audit Alerts engine - an alerting layer on top of Security Audit.
534 // Only instantiated when Security Audit is on, because it reacts to the
535 // events the activity log records (a passive subscriber, no per-module
536 // coupling). Both alert legs are opt-in, off by default.
537 if ( ! empty( $options['modules']['activity_log'] ) ) {
538 new Vigilante_Audit_Alerts( $this->settings, $this->activity_log );
539 }
540
541 // Under Attack mode - always loaded (independent of modules)
542 new Vigilante_Under_Attack( $this->settings, $this->activity_log );
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
552 // Admin interface
553 if ( is_admin() ) {
554 new Vigilante_Admin( $this->settings, $this->database, $this->activity_log );
555 }
556 }
557
558 /**
559 * Initialize WordPress hooks
560 */
561 private function init_hooks() {
562 // Plugin action links
563 add_filter( 'plugin_action_links_' . VIGILANTE_PLUGIN_BASENAME, array( $this, 'add_action_links' ) );
564
565 // Scheduled tasks
566 add_action( 'vigilante_daily_maintenance', array( $this, 'daily_maintenance' ) );
567 add_action( 'vigilante_hourly_checks', array( $this, 'hourly_checks' ) );
568
569 // AJAX handlers
570 add_action( 'wp_ajax_vigilante_dismiss_notice', array( $this, 'ajax_dismiss_notice' ) );
571
572 // Regenerate critical file baseline after Vigilante modifies wp-config.php or .htaccess
573 add_action( 'vigilante_critical_file_written', array( $this, 'on_critical_file_written' ) );
574
575 // Keep the server layer in step with the installed version.
576 add_action( 'init', array( $this, 'maybe_sync_server_files' ), 20 );
577 }
578
579 /**
580 * Rewrite the .htaccess block when the installed version has moved on
581 *
582 * Updating the plugin did not touch the file: the block was only rewritten
583 * on activation or when the Headers or Firewall tab was saved. So a fix
584 * that lives inside those rules never reached a site that merely updated,
585 * which is exactly what happened with the connect-src of 2.9.6: the browser
586 * kept receiving the old policy, and image uploads kept failing on
587 * WordPress 7.1 until someone pressed Save. This rewrites the block once
588 * per version, and picks up the rules that an activation from WP-CLI had to
589 * leave pending because it could not tell what server it was on.
590 *
591 * Only the content between the plugin markers is rewritten, the same part
592 * any save has always rewritten.
593 *
594 * @since 2.9.9
595 */
596 public function maybe_sync_server_files() {
597 $pending = (bool) get_option( 'vigilante_server_files_pending' );
598
599 if ( ! $pending && VIGILANTE_VERSION === get_option( 'vigilante_server_files_version' ) ) {
600 return;
601 }
602
603 // A failed write is not retried on every request.
604 if ( (int) get_option( 'vigilante_server_files_retry_after' ) > time() ) {
605 return;
606 }
607
608 /*
609 * A subsite has nothing to do here, ever: the file belongs to the main
610 * site. Marking it done keeps every request from re-checking.
611 *
612 * 2.10.0 asked the wrong question at this point and it cost the whole
613 * feature on networks. can_write_shared_files() ends in a capability
614 * check, and this runs on init for every request, so on a network the
615 * branch below was the one nearly every visitor took: it retired the job
616 * without having written a thing. The .htaccess was never refreshed after
617 * an update, and the one-shot snapshot behind it was consumed without
618 * being taken, so not even a network administrator visiting afterwards
619 * retried, because the version had already been marked. Reported by
620 * @calzbert, who found it reading the code.
621 */
622 if ( ! Vigilante_Settings::owns_shared_files() ) {
623 $this->mark_server_files_synced();
624 return;
625 }
626
627 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
628 $manager = Vigilante_Htaccess_Manager::get_instance();
629
630 if ( ! $manager->is_apache() ) {
631 // Still on the command line with nothing to learn from: stay pending.
632 if ( $manager->server_is_unknown() ) {
633 return;
634 }
635
636 // Not Apache: there is no block to keep in step.
637 $this->mark_server_files_synced();
638 return;
639 }
640
641 $options = get_option( Vigilante_Settings::OPTION_NAME, array() );
642 $headers = isset( $options['security_headers'] ) ? (array) $options['security_headers'] : array();
643 $failed = false;
644 $rewrote = false;
645
646 /*
647 * Last chance to keep what the file still says. The rewrites below are
648 * precisely what overwrites it, and on a site whose header settings the
649 * 2.9.8 migration reset, this file is the only remaining copy of what the
650 * owner had actually chosen. Captured here rather than inside the write
651 * path so it only ever happens on a version change: an ordinary save also
652 * leaves the file describing the previous values for an instant, and
653 * capturing there would spend the single slot on a difference the owner
654 * made deliberately.
655 */
656 $wrote_last = (string) get_option( 'vigilante_server_files_version' );
657
658 /*
659 * And only on the very first sync that arrives from a version older than
660 * this one. That is the whole window: the file still describes what the
661 * owner chose, and the rewrite below is what ends it. Gating on the
662 * version also keeps a future release, one that legitimately changes what
663 * the block contains, from reading its own improvement as damage and
664 * offering to undo it.
665 */
666 /*
667 * 2.10.1 and not 2.10.0, deliberately: it gives the networks a second
668 * chance. On a network 2.10.0 marked this done without writing anything,
669 * so the window closed with the snapshot untaken. But nothing was
670 * written, which means the .htaccess on those sites still describes the
671 * configuration its owner actually chose. Reopening the window one
672 * version wide is what lets them be recovered after all.
673 *
674 * Harmless where it already worked: a site that took a snapshot is
675 * skipped because one exists, and a site that found nothing to take has
676 * had its file rewritten to match its settings, so there is still no
677 * difference to find.
678 */
679 if ( '' === $wrote_last || version_compare( $wrote_last, '2.10.1', '<' ) ) {
680 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-recovery.php';
681 Vigilante_Htaccess_Recovery::maybe_capture( $manager->get_content(), $this->settings );
682 }
683
684 $needs_protection_block = ! empty( $options['modules']['firewall'] )
685 || ! empty( $headers['hide_server_signature'] )
686 || ! empty( $headers['remove_fingerprinting_headers'] );
687
688 /*
689 * A 'locked' result is not a failure: another request is doing this very
690 * work right now. Returning without marking anything leaves the pending
691 * state alone, so whichever request wins finishes the job and this one
692 * stays out of the way. Treating it as a failure would arm the one hour
693 * backoff for something that is already being handled.
694 */
695 $locked = false;
696 $incomplete = false;
697 $unreadable = false;
698 $settled = array( 'locked', 'block_incomplete', 'read_failed' );
699
700 if ( $needs_protection_block ) {
701 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-protection.php';
702 $result = ( new Vigilante_Htaccess_Protection( $this->settings ) )->apply_rules( true );
703 $code = is_wp_error( $result ) ? $result->get_error_code() : ( true === $result ? '' : 'unexpected_result' );
704 $locked = $locked || 'locked' === $code;
705 $incomplete = $incomplete || 'block_incomplete' === $code;
706 $unreadable = $unreadable || 'read_failed' === $code;
707 $failed = $failed || ( '' !== $code && ! in_array( $code, $settled, true ) );
708 $rewrote = true;
709 }
710
711 if ( ! $locked && ! empty( $options['modules']['security_headers'] ) ) {
712 require_once VIGILANTE_INCLUDES_DIR . 'class-security-headers.php';
713 $result = ( new Vigilante_Security_Headers( $this->settings ) )->apply_rules( true );
714 $code = is_wp_error( $result ) ? $result->get_error_code() : ( true === $result ? '' : 'unexpected_result' );
715 $locked = $locked || 'locked' === $code;
716 $incomplete = $incomplete || 'block_incomplete' === $code;
717 $unreadable = $unreadable || 'read_failed' === $code;
718 $failed = $failed || ( '' !== $code && ! in_array( $code, $settled, true ) );
719 $rewrote = true;
720 }
721
722 if ( $locked ) {
723 return;
724 }
725
726 if ( $failed ) {
727 update_option( 'vigilante_server_files_retry_after', time() + HOUR_IN_SECONDS );
728
729 // A refusal to write the server rules is exactly the kind of thing
730 // that used to happen in silence, so it is recorded and retried in
731 // an hour instead of being forgotten.
732 if ( $this->activity_log ) {
733 $this->activity_log->log(
734 'system',
735 'server_rules_write_failed',
736 __( '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' ),
737 array( 'version' => VIGILANTE_VERSION ),
738 'warning'
739 );
740 }
741
742 return;
743 }
744
745 /*
746 * A block with a BEGIN line and no END is not going to mend itself, so
747 * retrying every hour would only repeat the refusal: it is recorded once
748 * for this version, with what to do about it, and the job is marked done.
749 * Saving the Firewall or Headers tab after fixing the file writes the
750 * rules again.
751 */
752 if ( $incomplete && $this->activity_log ) {
753 $this->activity_log->log(
754 'system',
755 'server_rules_block_incomplete',
756 __( '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' ),
757 array( 'version' => VIGILANTE_VERSION ),
758 'warning'
759 );
760 }
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
779 $this->mark_server_files_synced();
780
781 if ( $rewrote && ! $incomplete && ! $unreadable && $this->activity_log ) {
782 $this->activity_log->log(
783 'system',
784 'server_rules_refreshed',
785 sprintf(
786 /* translators: %s: plugin version. */
787 __( 'The .htaccess rules were rewritten to match Vigilant %s.', 'vigilante' ),
788 VIGILANTE_VERSION
789 ),
790 array( 'version' => VIGILANTE_VERSION ),
791 'info'
792 );
793 }
794 }
795
796 /**
797 * Record that the server layer matches the installed version
798 *
799 * @since 2.9.9
800 */
801 private function mark_server_files_synced() {
802 update_option( 'vigilante_server_files_version', VIGILANTE_VERSION );
803 delete_option( 'vigilante_server_files_pending' );
804 delete_option( 'vigilante_server_files_retry_after' );
805 }
806
807 /**
808 * Update the critical file baseline after Vigilante writes to a monitored file
809 *
810 * @param string $filename File that was modified (e.g. 'wp-config.php').
811 */
812 public function on_critical_file_written( $filename ) {
813 if ( ! class_exists( 'Vigilante_File_Integrity' ) ) {
814 require_once VIGILANTE_INCLUDES_DIR . 'class-file-integrity.php';
815 }
816
817 $fi = new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
818 $fi->update_critical_file_baseline( $filename );
819 }
820
821 /**
822 * Add plugin action links
823 *
824 * @param array $links Existing links.
825 * @return array Modified links.
826 */
827 public function add_action_links( $links ) {
828 $plugin_links = array(
829 '<a href="' . esc_url( admin_url( 'admin.php?page=vigilante' ) ) . '">' . esc_html__( 'Security Settings', 'vigilante' ) . '</a>',
830 );
831 return array_merge( $plugin_links, $links );
832 }
833
834 /**
835 * Daily maintenance tasks
836 */
837 public function daily_maintenance() {
838 // Clean old activity logs
839 $this->activity_log->cleanup_old_logs();
840
841 // Clean old login attempts
842 $this->database->cleanup_old_login_attempts();
843
844 // Clean expired 2FA codes and trusted devices
845 $this->database->cleanup_expired_2fa_codes();
846 $this->database->cleanup_expired_trusted_devices();
847
848 // Remove sensitive files (readme.html, license.txt, licencia.txt)
849 // WordPress core updates recreate these files, so we clean them daily.
850 // They sit in the root every site of a network shares, so only the main
851 // site removes them, from its own settings; until 2.11.6 the daily
852 // maintenance of any site did.
853 $advanced = Vigilante_Settings::owns_shared_files() ? $this->settings->get_section( 'advanced' ) : array();
854 if ( ! empty( $advanced['remove_readme'] ) ) {
855 $readme_path = ABSPATH . 'readme.html';
856 if ( file_exists( $readme_path ) ) {
857 wp_delete_file( $readme_path );
858 }
859 }
860 if ( ! empty( $advanced['remove_license'] ) ) {
861 $license_files = array( 'license.txt', 'licencia.txt' );
862 foreach ( $license_files as $license_file ) {
863 $license_path = ABSPATH . $license_file;
864 if ( file_exists( $license_path ) ) {
865 wp_delete_file( $license_path );
866 }
867 }
868 }
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
888 // Log maintenance
889 $this->activity_log->log( 'system', 'maintenance', __( 'Daily maintenance completed', 'vigilante' ) );
890 }
891
892 /**
893 * Hourly checks
894 */
895 public function hourly_checks() {
896 // File integrity scans are handled by the File_Integrity class own cron schedule
897 // based on the configured scan_frequency (daily/weekly).
898 }
899
900 /**
901 * AJAX handler for dismissing notices
902 */
903 public function ajax_dismiss_notice() {
904 check_ajax_referer( 'vigilante_dismiss_notice', 'nonce' );
905
906 if ( ! current_user_can( 'manage_options' ) ) {
907 wp_die( -1 );
908 }
909
910 $notice_id = isset( $_POST['notice_id'] ) ? sanitize_key( $_POST['notice_id'] ) : '';
911
912 if ( $notice_id ) {
913 $dismissed = get_option( 'vigilante_dismissed_notices', array() );
914 $dismissed[ $notice_id ] = time();
915 update_option( 'vigilante_dismissed_notices', $dismissed );
916 }
917
918 wp_send_json_success();
919 }
920 }
921
922 /**
923 * Cron handler for the weekly Security Analyzer scan.
924 *
925 * Resolves the shared Vigilante_Security_Analyzer (lazily; no cost when the
926 * cron is not firing) and lets it run the scan + regression email logic.
927 */
928 function vigilante_run_analyzer_cron() {
929 if ( ! class_exists( 'Vigilante_Security_Analyzer' ) ) {
930 require_once VIGILANTE_INCLUDES_DIR . 'class-security-analyzer.php';
931 }
932 if ( ! class_exists( 'Vigilante_Settings' ) ) {
933 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
934 }
935
936 $settings = new Vigilante_Settings();
937 $activity_log = null;
938 if ( class_exists( 'Vigilante_Activity_Log' ) && class_exists( 'Vigilante_Database' ) ) {
939 $database = new Vigilante_Database();
940 $activity_log = new Vigilante_Activity_Log( $settings, $database );
941 }
942
943 $analyzer = new Vigilante_Security_Analyzer( $settings, $activity_log );
944 $analyzer->cron_weekly_scan();
945 }
946
947 /**
948 * Cron handler for the daily plugin status check.
949 *
950 * Resolves the shared Vigilante_Plugin_Status lazily so the daily cron has no
951 * cost while it is not firing.
952 */
953 function vigilante_run_plugin_status_check() {
954 if ( ! class_exists( 'Vigilante_Plugin_Status' ) ) {
955 require_once VIGILANTE_INCLUDES_DIR . 'class-plugin-status.php';
956 }
957 if ( ! class_exists( 'Vigilante_Settings' ) ) {
958 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
959 }
960
961 $settings = new Vigilante_Settings();
962 $activity_log = null;
963 if ( class_exists( 'Vigilante_Activity_Log' ) && class_exists( 'Vigilante_Database' ) ) {
964 $database = new Vigilante_Database();
965 $activity_log = new Vigilante_Activity_Log( $settings, $database );
966 }
967
968 $checker = new Vigilante_Plugin_Status( $settings, $activity_log );
969 $checker->run_scheduled_check();
970 }
971
972 /**
973 * Run a Security Analyzer full scan after Under Attack mode deactivates.
974 *
975 * Scheduled one-shot from Vigilante_Under_Attack::deactivate() so the dashboard
976 * reflects the restored configuration with the slow HTTP/header probes the
977 * mode prevented from running safely while it was active.
978 */
979 function vigilante_run_post_under_attack_scan() {
980 if ( ! class_exists( 'Vigilante_Under_Attack' ) ) {
981 require_once VIGILANTE_INCLUDES_DIR . 'class-under-attack.php';
982 }
983 if ( ! class_exists( 'Vigilante_Settings' ) ) {
984 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
985 }
986
987 $settings = new Vigilante_Settings();
988 $activity_log = null;
989 if ( class_exists( 'Vigilante_Activity_Log' ) && class_exists( 'Vigilante_Database' ) ) {
990 $database = new Vigilante_Database();
991 $activity_log = new Vigilante_Activity_Log( $settings, $database );
992 }
993
994 $under_attack = new Vigilante_Under_Attack( $settings, $activity_log );
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;
1145 }
1146
1147 /**
1148 * Plugin activation hook
1149 */
1150 function vigilante_activate() {
1151 require_once VIGILANTE_INCLUDES_DIR . 'class-database.php';
1152 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
1153 require_once VIGILANTE_INCLUDES_DIR . 'class-backup-manager.php';
1154 require_once VIGILANTE_INCLUDES_DIR . 'class-activator.php';
1155
1156 Vigilante_Activator::activate();
1157 }
1158 register_activation_hook( __FILE__, 'vigilante_activate' );
1159
1160 /**
1161 * Plugin deactivation hook
1162 *
1163 * @param bool $network_wide Whether core is deactivating the plugin for the whole network.
1164 */
1165 function vigilante_deactivate( $network_wide = false ) {
1166 require_once VIGILANTE_INCLUDES_DIR . 'class-database.php';
1167 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
1168 require_once VIGILANTE_INCLUDES_DIR . 'class-backup-manager.php';
1169 require_once VIGILANTE_INCLUDES_DIR . 'class-wpconfig-security.php';
1170 require_once VIGILANTE_INCLUDES_DIR . 'class-deactivator.php';
1171
1172 Vigilante_Deactivator::deactivate( (bool) $network_wide );
1173 }
1174 register_deactivation_hook( __FILE__, 'vigilante_deactivate' );
1175
1176 /**
1177 * Initialize plugin after WordPress loads
1178 */
1179 add_action( 'plugins_loaded', 'vigilante_load_plugin' );
1180
1181 /*
1182 * The hidden wp-admin is answered as early as the request can be judged with
1183 * certainty, before the theme and the other plugins load. The modules are built
1184 * on init priority 1, so until 2.9.9 a request that was going to be refused had
1185 * already paid for the whole boot.
1186 */
1187 add_action( 'plugins_loaded', 'vigilante_block_hidden_admin_early', 1 );
1188
1189 /**
1190 * Cheap gate for the early hidden wp-admin rejection
1191 *
1192 * Everything that can be decided without loading a single plugin class is
1193 * decided here, so the usual request pays nothing more than a couple of
1194 * comparisons and one option read that WordPress has already cached.
1195 *
1196 * @since 2.9.9
1197 */
1198 function vigilante_block_hidden_admin_early() {
1199 if ( ! is_admin() ) {
1200 return;
1201 }
1202
1203 $method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'GET';
1204
1205 // POST is how remote managers authenticate, and the later path lets it through too.
1206 if ( 'GET' !== $method ) {
1207 return;
1208 }
1209
1210 $options = get_option( 'vigilante_options', array() );
1211
1212 if ( ! is_array( $options )
1213 || empty( $options['modules']['login_security'] )
1214 || empty( $options['login_security']['custom_login_url'] ) ) {
1215 return;
1216 }
1217
1218 require_once VIGILANTE_INCLUDES_DIR . 'class-ip-utils.php';
1219 require_once VIGILANTE_INCLUDES_DIR . 'class-login-security.php';
1220
1221 Vigilante_Login_Security::maybe_block_hidden_admin_early( $options );
1222 }
1223
1224 /**
1225 * Helper function to get plugin instance
1226 *
1227 * @return Vigilante_Main
1228 */
1229 function vigilante() {
1230 return Vigilante_Main::get_instance();
1231 }