PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.10
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.10
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 2.9.4 2.9.3 All 86 releases
vigilante / vigilante.php

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

979 lines 40.6 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: 2.11.10
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', '2.11.10' );
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-under-attack.php';
141 require_once VIGILANTE_INCLUDES_DIR . 'class-database-backup.php';
142 require_once VIGILANTE_INCLUDES_DIR . 'class-database-prefix.php';
143 require_once VIGILANTE_INCLUDES_DIR . 'class-security-analyzer.php';
144
145 // Load admin classes
146 if ( is_admin() ) {
147 require_once VIGILANTE_ADMIN_DIR . 'class-admin-analyzer-ajax.php';
148 require_once VIGILANTE_ADMIN_DIR . 'class-admin-recovery-ajax.php';
149 require_once VIGILANTE_ADMIN_DIR . 'class-admin-audit-alerts-ajax.php';
150 require_once VIGILANTE_ADMIN_DIR . 'class-admin.php';
151 }
152
153 // Weekly Security Analyzer cron (registered even outside admin so it fires on cron hit).
154 add_action( 'vigilante_analyzer_weekly_scan', 'vigilante_run_analyzer_cron' );
155
156 // Daily plugin status check (closed-in-wp.org detection).
157 add_action( 'vigilante_plugin_status_check', 'vigilante_run_plugin_status_check' );
158
159 // Post-Under Attack scan (one-shot, scheduled by Vigilante_Under_Attack::deactivate).
160 add_action( 'vigilante_under_attack_post_scan', 'vigilante_run_post_under_attack_scan' );
161
162 // Initialize core components only - modules will be initialized at init
163 add_action( 'init', 'vigilante_init_plugin', 1 );
164 }
165
166 /**
167 * Initialize plugin at init hook (translations are ready)
168 */
169 function vigilante_init_plugin() {
170 Vigilante_Main::get_instance();
171 }
172
173 /**
174 * Main plugin class - Singleton pattern
175 */
176 final class Vigilante_Main {
177
178 /**
179 * Single instance of the class
180 *
181 * @var Vigilante_Main|null
182 */
183 private static $instance = null;
184
185 /**
186 * Settings instance
187 *
188 * @var Vigilante_Settings
189 */
190 public $settings;
191
192 /**
193 * Database instance
194 *
195 * @var Vigilante_Database
196 */
197 public $database;
198
199 /**
200 * Activity log instance
201 *
202 * @var Vigilante_Activity_Log
203 */
204 public $activity_log;
205
206 /**
207 * Get single instance of the class
208 *
209 * @return Vigilante_Main
210 */
211 public static function get_instance() {
212 if ( null === self::$instance ) {
213 self::$instance = new self();
214 }
215 return self::$instance;
216 }
217
218 /**
219 * Constructor - private to enforce singleton
220 */
221 private function __construct() {
222 $this->init_core();
223 $this->init_modules();
224 $this->init_hooks();
225 }
226
227 /**
228 * Prevent cloning
229 */
230 private function __clone() {}
231
232 /**
233 * Prevent unserializing
234 *
235 * @throws Exception Always throws exception.
236 */
237 public function __wakeup() {
238 throw new Exception( 'Cannot unserialize singleton' );
239 }
240
241 /**
242 * Initialize core components
243 */
244 private function init_core() {
245 $this->database = new Vigilante_Database();
246 $this->settings = new Vigilante_Settings();
247 $this->activity_log = new Vigilante_Activity_Log( $this->settings, $this->database );
248
249 // Auto-create/update tables when DB version is outdated (handles file-only updates)
250 if ( $this->database->needs_update() ) {
251 $this->database->create_tables();
252 }
253
254 // One-time cleanup: versions before 2.7.0 wrote config backups (including
255 // wp-config.php) as files under wp-content/vigilante-backups/. Those now
256 // live in the database, so remove anything left on disk.
257 if ( ! get_option( 'vigilante_legacy_backups_cleaned' ) ) {
258 Vigilante_Backup_Manager::cleanup_legacy_files();
259 update_option( 'vigilante_legacy_backups_cleaned', 1, false );
260 }
261
262 // Once (2.11.6): the copies of wp-config.php, .htaccess and robots.txt
263 // that earlier versions kept in the options table, on this site and, on
264 // a network, on every site of it.
265 Vigilante_Backup_Manager::maybe_purge_stored_copies();
266
267 // One-time migration (2.9.0): add '.css' to File Integrity's excluded
268 // extensions on existing installs. Stylesheets are rewritten so often by
269 // themes and optimizer plugins that they were the main post-update false
270 // positive. New installs get it from the defaults; this brings existing
271 // sites in line without touching any other setting. Additive, idempotent.
272 if ( ! get_option( 'vigilante_css_exclusion_migrated' ) ) {
273 $fi = $this->settings->get_section( 'file_integrity' );
274 if ( is_array( $fi ) ) {
275 $ext = ( isset( $fi['excluded_extensions'] ) && is_array( $fi['excluded_extensions'] ) )
276 ? $fi['excluded_extensions']
277 : array();
278 if ( ! in_array( '.css', $ext, true ) ) {
279 $ext[] = '.css';
280 $fi['excluded_extensions'] = $ext;
281 $this->settings->update_section( 'file_integrity', $fi );
282 }
283 }
284 update_option( 'vigilante_css_exclusion_migrated', 1, false );
285 }
286
287 // One-time on upgrade to 2.9.0: drop any cached WordPress.org checksum
288 // manifests. The new comparison is array-aware and self-corrects a cached
289 // array-md5 value, but a manifest cached by an older version while wp.org
290 // was still propagating a new release could otherwise keep producing
291 // false "modified" results until it expires (up to 24h). Flushing on
292 // upgrade guarantees a clean slate on the very release that fixes them;
293 // the next scan refetches fresh manifests. One-time, bulk, no caching.
294 if ( ! get_option( 'vigilante_checksum_cache_flushed_290' ) ) {
295 global $wpdb;
296 $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.
297 "DELETE FROM {$wpdb->options}
298 WHERE option_name LIKE '\\_transient\\_vigilante\\_plugin\\_checksums\\_%'
299 OR option_name LIKE '\\_transient\\_timeout\\_vigilante\\_plugin\\_checksums\\_%'
300 OR option_name LIKE '\\_transient\\_vigilante\\_theme\\_checksums\\_%'
301 OR option_name LIKE '\\_transient\\_timeout\\_vigilante\\_theme\\_checksums\\_%'
302 OR option_name LIKE '\\_transient\\_vigilante\\_core\\_checksums\\_%'
303 OR option_name LIKE '\\_transient\\_timeout\\_vigilante\\_core\\_checksums\\_%'"
304 );
305 update_option( 'vigilante_checksum_cache_flushed_290', 1, false );
306 }
307 }
308
309 /**
310 * Initialize security modules based on settings
311 */
312 private function init_modules() {
313 $options = $this->settings->get_all_options();
314
315 // Self-heal: a UI bug in earlier 2.4.x betas could leave a section's
316 // top-level 'enabled' flag set to false because the section forms do
317 // not render a checkbox for that field — saving any tab caused the
318 // generic save handler to treat the missing field as "unchecked" and
319 // store it as false. If the master module toggle on the Dashboard is
320 // on but the section flag is off, restore it here so the module's
321 // hooks can attach. Idempotent: noop on healthy installs.
322 $sections = array(
323 'firewall',
324 'security_headers',
325 'login_security',
326 'rest_api_security',
327 'user_security',
328 'wp_hardening',
329 'file_integrity',
330 'activity_log',
331 );
332 $heal_changed = false;
333 foreach ( $sections as $section_name ) {
334 if ( ! empty( $options['modules'][ $section_name ] )
335 && isset( $options[ $section_name ] )
336 && is_array( $options[ $section_name ] )
337 && array_key_exists( 'enabled', $options[ $section_name ] )
338 && empty( $options[ $section_name ]['enabled'] ) ) {
339 $options[ $section_name ]['enabled'] = true;
340 $heal_changed = true;
341 }
342 }
343 if ( $heal_changed ) {
344 update_option( Vigilante_Settings::OPTION_NAME, $options );
345 $this->settings->clear_cache();
346 $options = $this->settings->get_all_options();
347 }
348
349 // Firewall - runs early to block threats
350 if ( ! empty( $options['modules']['firewall'] ) ) {
351 new Vigilante_Firewall( $this->settings, $this->activity_log );
352 }
353
354 // Security Headers - rules are applied via .htaccess, no runtime hooks needed
355 // HTTPS Enforcer still needs runtime hooks
356 if ( ! empty( $options['modules']['security_headers'] ) ) {
357 new Vigilante_Https_Enforcer( $this->settings );
358 }
359
360 // REST API Security
361 if ( ! empty( $options['modules']['rest_api_security'] ) ) {
362 new Vigilante_Rest_Api_Security( $this->settings );
363 }
364
365 // User Security
366 if ( ! empty( $options['modules']['user_security'] ) ) {
367 new Vigilante_User_Security( $this->settings, $this->activity_log );
368 } else {
369 /*
370 * Turning the module off must not quietly unlock the accounts it
371 * already locked. A forced password reset and a registration waiting
372 * for approval are marks written on somebody's account, with their
373 * sessions already destroyed and the activity log saying they cannot
374 * get in; until 2.11.10 both stopped being enforced the moment this
375 * toggle went off and every one of those accounts logged in again
376 * with its old password. Only the enforcing half is registered here.
377 */
378 new Vigilante_User_Security( $this->settings, $this->activity_log, true );
379 }
380
381 // Login Security
382 $login_security = null;
383
384 if ( ! empty( $options['modules']['login_security'] ) ) {
385 $login_security = new Vigilante_Login_Security( $this->settings, $this->database, $this->activity_log );
386 }
387
388 /*
389 * Two factor on a network is decided for the whole network, so it has to
390 * be ENFORCED on the whole network too. Gating these two on this site's
391 * module toggle left the last leg of the bypass open: the administrator
392 * of any subsite can turn Login Security off on their own site, which
393 * takes them out of the picture but not out of the network, and the
394 * session cookie WordPress issues there is valid on every host of it. So
395 * a login sent to that subsite registered no second factor check at all
396 * and the cookie it handed back opened the main site. Reproduced over
397 * HTTP by the second cross review of 2.11.10.
398 *
399 * Same reasoning, and the same shape, as the enforcement-only User
400 * Security above: on a network the enforcing half is registered whatever
401 * this site says. On a single site there is no other site to protect and
402 * the toggle means what it says.
403 */
404 if ( is_multisite() || null !== $login_security ) {
405 new Vigilante_Two_Factor_Email( $this->settings, $this->database, $this->activity_log, $login_security );
406 new Vigilante_Two_Factor_TOTP( $this->settings, $this->database, $this->activity_log, $login_security );
407 }
408
409 // WordPress Hardening (includes comments, head cleaner, feeds)
410 if ( ! empty( $options['modules']['wp_hardening'] ) ) {
411 new Vigilante_Comment_Security( $this->settings );
412 new Vigilante_Head_Cleaner( $this->settings );
413 new Vigilante_Feed_Manager( $this->settings );
414 }
415
416 // File Integrity Scanner
417 if ( ! empty( $options['modules']['file_integrity'] ) ) {
418 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
419 $file_integrity->init_hooks();
420 $file_integrity->init_cleanup_hooks();
421
422 new Vigilante_Plugin_Status( $this->settings, $this->activity_log );
423 } elseif ( is_admin() ) {
424 /*
425 * The module is off, and the cleanup goes on anyway. It is not
426 * integrity monitoring: it takes out of the database the copy of
427 * wp-config.php that earlier versions stored, credentials and all.
428 * Turning the module off is not a decision to keep them.
429 *
430 * Two holes closed here, both reported by @calzbert after reading the
431 * 2.11.3 diff. A site with the module off cleaned itself by neither
432 * of its own two paths, because both hang off this class. And with
433 * the module off on the MAIN site, the network sweep was not
434 * registered either, which is what would have reached every other
435 * site: the sweep removes each site's option without asking whether
436 * the module is on over there.
437 *
438 * Only in the admin, because both hooks are admin_init and there is
439 * nothing to gain from building this on a front-end request. Note
440 * that admin-ajax.php fires admin_init too (wp-admin/admin-ajax.php
441 * :45), so this also runs on wp_ajax_nopriv_* requests from
442 * visitors with no session. That is deliberate and it is what the
443 * module has been doing since 2.11.2: the cleanup asks for no
444 * capability because it also runs under wp-cron with nobody logged
445 * in, and all it does is take the plugin's own copy out of the
446 * database. The network sweep, which does reach across sites, is
447 * the one that demands manage_network_options.
448 */
449 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
450 $file_integrity->init_cleanup_hooks();
451 }
452
453 // Activity Log is always initialized (core component)
454 // Logging is gated by the modules.activity_log toggle and per-type flags
455
456 // Audit Alerts engine - an alerting layer on top of Security Audit.
457 // Only instantiated when Security Audit is on, because it reacts to the
458 // events the activity log records (a passive subscriber, no per-module
459 // coupling). Both alert legs are opt-in, off by default.
460 if ( ! empty( $options['modules']['activity_log'] ) ) {
461 new Vigilante_Audit_Alerts( $this->settings, $this->activity_log );
462 }
463
464 // Under Attack mode - always loaded (independent of modules)
465 new Vigilante_Under_Attack( $this->settings, $this->activity_log );
466
467 // Admin interface
468 if ( is_admin() ) {
469 new Vigilante_Admin( $this->settings, $this->database, $this->activity_log );
470 }
471 }
472
473 /**
474 * Initialize WordPress hooks
475 */
476 private function init_hooks() {
477 // Plugin action links
478 add_filter( 'plugin_action_links_' . VIGILANTE_PLUGIN_BASENAME, array( $this, 'add_action_links' ) );
479
480 // Scheduled tasks
481 add_action( 'vigilante_daily_maintenance', array( $this, 'daily_maintenance' ) );
482 add_action( 'vigilante_hourly_checks', array( $this, 'hourly_checks' ) );
483
484 // AJAX handlers
485 add_action( 'wp_ajax_vigilante_dismiss_notice', array( $this, 'ajax_dismiss_notice' ) );
486
487 // Regenerate critical file baseline after Vigilante modifies wp-config.php or .htaccess
488 add_action( 'vigilante_critical_file_written', array( $this, 'on_critical_file_written' ) );
489
490 // Keep the server layer in step with the installed version.
491 add_action( 'init', array( $this, 'maybe_sync_server_files' ), 20 );
492 }
493
494 /**
495 * Rewrite the .htaccess block when the installed version has moved on
496 *
497 * Updating the plugin did not touch the file: the block was only rewritten
498 * on activation or when the Headers or Firewall tab was saved. So a fix
499 * that lives inside those rules never reached a site that merely updated,
500 * which is exactly what happened with the connect-src of 2.9.6: the browser
501 * kept receiving the old policy, and image uploads kept failing on
502 * WordPress 7.1 until someone pressed Save. This rewrites the block once
503 * per version, and picks up the rules that an activation from WP-CLI had to
504 * leave pending because it could not tell what server it was on.
505 *
506 * Only the content between the plugin markers is rewritten, the same part
507 * any save has always rewritten.
508 *
509 * @since 2.9.9
510 */
511 public function maybe_sync_server_files() {
512 $pending = (bool) get_option( 'vigilante_server_files_pending' );
513
514 if ( ! $pending && VIGILANTE_VERSION === get_option( 'vigilante_server_files_version' ) ) {
515 return;
516 }
517
518 // A failed write is not retried on every request.
519 if ( (int) get_option( 'vigilante_server_files_retry_after' ) > time() ) {
520 return;
521 }
522
523 /*
524 * A subsite has nothing to do here, ever: the file belongs to the main
525 * site. Marking it done keeps every request from re-checking.
526 *
527 * 2.10.0 asked the wrong question at this point and it cost the whole
528 * feature on networks. can_write_shared_files() ends in a capability
529 * check, and this runs on init for every request, so on a network the
530 * branch below was the one nearly every visitor took: it retired the job
531 * without having written a thing. The .htaccess was never refreshed after
532 * an update, and the one-shot snapshot behind it was consumed without
533 * being taken, so not even a network administrator visiting afterwards
534 * retried, because the version had already been marked. Reported by
535 * @calzbert, who found it reading the code.
536 */
537 if ( ! Vigilante_Settings::owns_shared_files() ) {
538 $this->mark_server_files_synced();
539 return;
540 }
541
542 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
543 $manager = Vigilante_Htaccess_Manager::get_instance();
544
545 if ( ! $manager->is_apache() ) {
546 // Still on the command line with nothing to learn from: stay pending.
547 if ( $manager->server_is_unknown() ) {
548 return;
549 }
550
551 // Not Apache: there is no block to keep in step.
552 $this->mark_server_files_synced();
553 return;
554 }
555
556 $options = get_option( Vigilante_Settings::OPTION_NAME, array() );
557 $headers = isset( $options['security_headers'] ) ? (array) $options['security_headers'] : array();
558 $failed = false;
559 $rewrote = false;
560
561 /*
562 * Last chance to keep what the file still says. The rewrites below are
563 * precisely what overwrites it, and on a site whose header settings the
564 * 2.9.8 migration reset, this file is the only remaining copy of what the
565 * owner had actually chosen. Captured here rather than inside the write
566 * path so it only ever happens on a version change: an ordinary save also
567 * leaves the file describing the previous values for an instant, and
568 * capturing there would spend the single slot on a difference the owner
569 * made deliberately.
570 */
571 $wrote_last = (string) get_option( 'vigilante_server_files_version' );
572
573 /*
574 * And only on the very first sync that arrives from a version older than
575 * this one. That is the whole window: the file still describes what the
576 * owner chose, and the rewrite below is what ends it. Gating on the
577 * version also keeps a future release, one that legitimately changes what
578 * the block contains, from reading its own improvement as damage and
579 * offering to undo it.
580 */
581 /*
582 * 2.10.1 and not 2.10.0, deliberately: it gives the networks a second
583 * chance. On a network 2.10.0 marked this done without writing anything,
584 * so the window closed with the snapshot untaken. But nothing was
585 * written, which means the .htaccess on those sites still describes the
586 * configuration its owner actually chose. Reopening the window one
587 * version wide is what lets them be recovered after all.
588 *
589 * Harmless where it already worked: a site that took a snapshot is
590 * skipped because one exists, and a site that found nothing to take has
591 * had its file rewritten to match its settings, so there is still no
592 * difference to find.
593 */
594 if ( '' === $wrote_last || version_compare( $wrote_last, '2.10.1', '<' ) ) {
595 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-recovery.php';
596 Vigilante_Htaccess_Recovery::maybe_capture( $manager->get_content(), $this->settings );
597 }
598
599 $needs_protection_block = ! empty( $options['modules']['firewall'] )
600 || ! empty( $headers['hide_server_signature'] )
601 || ! empty( $headers['remove_fingerprinting_headers'] );
602
603 /*
604 * A 'locked' result is not a failure: another request is doing this very
605 * work right now. Returning without marking anything leaves the pending
606 * state alone, so whichever request wins finishes the job and this one
607 * stays out of the way. Treating it as a failure would arm the one hour
608 * backoff for something that is already being handled.
609 */
610 $locked = false;
611 $incomplete = false;
612 $unreadable = false;
613 $settled = array( 'locked', 'block_incomplete', 'read_failed' );
614
615 if ( $needs_protection_block ) {
616 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-protection.php';
617 $result = ( new Vigilante_Htaccess_Protection( $this->settings ) )->apply_rules( true );
618 $code = is_wp_error( $result ) ? $result->get_error_code() : ( true === $result ? '' : 'unexpected_result' );
619 $locked = $locked || 'locked' === $code;
620 $incomplete = $incomplete || 'block_incomplete' === $code;
621 $unreadable = $unreadable || 'read_failed' === $code;
622 $failed = $failed || ( '' !== $code && ! in_array( $code, $settled, true ) );
623 $rewrote = true;
624 }
625
626 if ( ! $locked && ! empty( $options['modules']['security_headers'] ) ) {
627 require_once VIGILANTE_INCLUDES_DIR . 'class-security-headers.php';
628 $result = ( new Vigilante_Security_Headers( $this->settings ) )->apply_rules( true );
629 $code = is_wp_error( $result ) ? $result->get_error_code() : ( true === $result ? '' : 'unexpected_result' );
630 $locked = $locked || 'locked' === $code;
631 $incomplete = $incomplete || 'block_incomplete' === $code;
632 $unreadable = $unreadable || 'read_failed' === $code;
633 $failed = $failed || ( '' !== $code && ! in_array( $code, $settled, true ) );
634 $rewrote = true;
635 }
636
637 if ( $locked ) {
638 return;
639 }
640
641 if ( $failed ) {
642 update_option( 'vigilante_server_files_retry_after', time() + HOUR_IN_SECONDS );
643
644 // A refusal to write the server rules is exactly the kind of thing
645 // that used to happen in silence, so it is recorded and retried in
646 // an hour instead of being forgotten.
647 if ( $this->activity_log ) {
648 $this->activity_log->log(
649 'system',
650 'server_rules_write_failed',
651 __( 'The .htaccess rules could not be rewritten after the update. Vigilant will try again in an hour; if the file is read only, fix its permissions or save the Firewall or Headers tab once.', 'vigilante' ),
652 array( 'version' => VIGILANTE_VERSION ),
653 'warning'
654 );
655 }
656
657 return;
658 }
659
660 /*
661 * A block with a BEGIN line and no END is not going to mend itself, so
662 * retrying every hour would only repeat the refusal: it is recorded once
663 * for this version, with what to do about it, and the job is marked done.
664 * Saving the Firewall or Headers tab after fixing the file writes the
665 * rules again.
666 */
667 if ( $incomplete && $this->activity_log ) {
668 $this->activity_log->log(
669 'system',
670 'server_rules_block_incomplete',
671 __( 'The .htaccess rules were not rewritten after the update because a Vigilant block in that file has a BEGIN line without its END, and rewriting it would have cut everything below it. Remove the broken block by hand, then save the Firewall or Headers tab.', 'vigilante' ),
672 array( 'version' => VIGILANTE_VERSION ),
673 'warning'
674 );
675 }
676
677 /*
678 * Same for a .htaccess that PHP can write but not read: since 2.11.8 it is
679 * left as it is rather than replaced by the Vigilant rules alone, and
680 * that does not mend itself either. The first version of that fix left
681 * it to the hourly retry, with a message about read only files; found by
682 * the cross review of 2.11.8.
683 */
684 if ( $unreadable && $this->activity_log ) {
685 $this->activity_log->log(
686 'system',
687 'server_rules_read_failed',
688 __( 'The .htaccess rules were not rewritten after the update because PHP can write that file but cannot read it, and writing it without reading it would have removed every other rule in it. Let PHP read the file, then save the Firewall or Headers tab.', 'vigilante' ),
689 array( 'version' => VIGILANTE_VERSION ),
690 'warning'
691 );
692 }
693
694 $this->mark_server_files_synced();
695
696 if ( $rewrote && ! $incomplete && ! $unreadable && $this->activity_log ) {
697 $this->activity_log->log(
698 'system',
699 'server_rules_refreshed',
700 sprintf(
701 /* translators: %s: plugin version. */
702 __( 'The .htaccess rules were rewritten to match Vigilant %s.', 'vigilante' ),
703 VIGILANTE_VERSION
704 ),
705 array( 'version' => VIGILANTE_VERSION ),
706 'info'
707 );
708 }
709 }
710
711 /**
712 * Record that the server layer matches the installed version
713 *
714 * @since 2.9.9
715 */
716 private function mark_server_files_synced() {
717 update_option( 'vigilante_server_files_version', VIGILANTE_VERSION );
718 delete_option( 'vigilante_server_files_pending' );
719 delete_option( 'vigilante_server_files_retry_after' );
720 }
721
722 /**
723 * Update the critical file baseline after Vigilante writes to a monitored file
724 *
725 * @param string $filename File that was modified (e.g. 'wp-config.php').
726 */
727 public function on_critical_file_written( $filename ) {
728 if ( ! class_exists( 'Vigilante_File_Integrity' ) ) {
729 require_once VIGILANTE_INCLUDES_DIR . 'class-file-integrity.php';
730 }
731
732 $fi = new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
733 $fi->update_critical_file_baseline( $filename );
734 }
735
736 /**
737 * Add plugin action links
738 *
739 * @param array $links Existing links.
740 * @return array Modified links.
741 */
742 public function add_action_links( $links ) {
743 $plugin_links = array(
744 '<a href="' . esc_url( admin_url( 'admin.php?page=vigilante' ) ) . '">' . esc_html__( 'Security Settings', 'vigilante' ) . '</a>',
745 );
746 return array_merge( $plugin_links, $links );
747 }
748
749 /**
750 * Daily maintenance tasks
751 */
752 public function daily_maintenance() {
753 // Clean old activity logs
754 $this->activity_log->cleanup_old_logs();
755
756 // Clean old login attempts
757 $this->database->cleanup_old_login_attempts();
758
759 // Clean expired 2FA codes and trusted devices
760 $this->database->cleanup_expired_2fa_codes();
761 $this->database->cleanup_expired_trusted_devices();
762
763 // Remove sensitive files (readme.html, license.txt, licencia.txt)
764 // WordPress core updates recreate these files, so we clean them daily.
765 // They sit in the root every site of a network shares, so only the main
766 // site removes them, from its own settings; until 2.11.6 the daily
767 // maintenance of any site did.
768 $advanced = Vigilante_Settings::owns_shared_files() ? $this->settings->get_section( 'advanced' ) : array();
769 if ( ! empty( $advanced['remove_readme'] ) ) {
770 $readme_path = ABSPATH . 'readme.html';
771 if ( file_exists( $readme_path ) ) {
772 wp_delete_file( $readme_path );
773 }
774 }
775 if ( ! empty( $advanced['remove_license'] ) ) {
776 $license_files = array( 'license.txt', 'licencia.txt' );
777 foreach ( $license_files as $license_file ) {
778 $license_path = ABSPATH . $license_file;
779 if ( file_exists( $license_path ) ) {
780 wp_delete_file( $license_path );
781 }
782 }
783 }
784
785 // Log maintenance
786 $this->activity_log->log( 'system', 'maintenance', __( 'Daily maintenance completed', 'vigilante' ) );
787 }
788
789 /**
790 * Hourly checks
791 */
792 public function hourly_checks() {
793 // File integrity scans are handled by the File_Integrity class own cron schedule
794 // based on the configured scan_frequency (daily/weekly).
795 }
796
797 /**
798 * AJAX handler for dismissing notices
799 */
800 public function ajax_dismiss_notice() {
801 check_ajax_referer( 'vigilante_dismiss_notice', 'nonce' );
802
803 if ( ! current_user_can( 'manage_options' ) ) {
804 wp_die( -1 );
805 }
806
807 $notice_id = isset( $_POST['notice_id'] ) ? sanitize_key( $_POST['notice_id'] ) : '';
808
809 if ( $notice_id ) {
810 $dismissed = get_option( 'vigilante_dismissed_notices', array() );
811 $dismissed[ $notice_id ] = time();
812 update_option( 'vigilante_dismissed_notices', $dismissed );
813 }
814
815 wp_send_json_success();
816 }
817 }
818
819 /**
820 * Cron handler for the weekly Security Analyzer scan.
821 *
822 * Resolves the shared Vigilante_Security_Analyzer (lazily; no cost when the
823 * cron is not firing) and lets it run the scan + regression email logic.
824 */
825 function vigilante_run_analyzer_cron() {
826 if ( ! class_exists( 'Vigilante_Security_Analyzer' ) ) {
827 require_once VIGILANTE_INCLUDES_DIR . 'class-security-analyzer.php';
828 }
829 if ( ! class_exists( 'Vigilante_Settings' ) ) {
830 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
831 }
832
833 $settings = new Vigilante_Settings();
834 $activity_log = null;
835 if ( class_exists( 'Vigilante_Activity_Log' ) && class_exists( 'Vigilante_Database' ) ) {
836 $database = new Vigilante_Database();
837 $activity_log = new Vigilante_Activity_Log( $settings, $database );
838 }
839
840 $analyzer = new Vigilante_Security_Analyzer( $settings, $activity_log );
841 $analyzer->cron_weekly_scan();
842 }
843
844 /**
845 * Cron handler for the daily plugin status check.
846 *
847 * Resolves the shared Vigilante_Plugin_Status lazily so the daily cron has no
848 * cost while it is not firing.
849 */
850 function vigilante_run_plugin_status_check() {
851 if ( ! class_exists( 'Vigilante_Plugin_Status' ) ) {
852 require_once VIGILANTE_INCLUDES_DIR . 'class-plugin-status.php';
853 }
854 if ( ! class_exists( 'Vigilante_Settings' ) ) {
855 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
856 }
857
858 $settings = new Vigilante_Settings();
859 $activity_log = null;
860 if ( class_exists( 'Vigilante_Activity_Log' ) && class_exists( 'Vigilante_Database' ) ) {
861 $database = new Vigilante_Database();
862 $activity_log = new Vigilante_Activity_Log( $settings, $database );
863 }
864
865 $checker = new Vigilante_Plugin_Status( $settings, $activity_log );
866 $checker->run_scheduled_check();
867 }
868
869 /**
870 * Run a Security Analyzer full scan after Under Attack mode deactivates.
871 *
872 * Scheduled one-shot from Vigilante_Under_Attack::deactivate() so the dashboard
873 * reflects the restored configuration with the slow HTTP/header probes the
874 * mode prevented from running safely while it was active.
875 */
876 function vigilante_run_post_under_attack_scan() {
877 if ( ! class_exists( 'Vigilante_Under_Attack' ) ) {
878 require_once VIGILANTE_INCLUDES_DIR . 'class-under-attack.php';
879 }
880 if ( ! class_exists( 'Vigilante_Settings' ) ) {
881 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
882 }
883
884 $settings = new Vigilante_Settings();
885 $activity_log = null;
886 if ( class_exists( 'Vigilante_Activity_Log' ) && class_exists( 'Vigilante_Database' ) ) {
887 $database = new Vigilante_Database();
888 $activity_log = new Vigilante_Activity_Log( $settings, $database );
889 }
890
891 $under_attack = new Vigilante_Under_Attack( $settings, $activity_log );
892 $under_attack->run_analyzer_scan( 'all' );
893 }
894
895 /**
896 * Plugin activation hook
897 */
898 function vigilante_activate() {
899 require_once VIGILANTE_INCLUDES_DIR . 'class-database.php';
900 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
901 require_once VIGILANTE_INCLUDES_DIR . 'class-backup-manager.php';
902 require_once VIGILANTE_INCLUDES_DIR . 'class-activator.php';
903
904 Vigilante_Activator::activate();
905 }
906 register_activation_hook( __FILE__, 'vigilante_activate' );
907
908 /**
909 * Plugin deactivation hook
910 *
911 * @param bool $network_wide Whether core is deactivating the plugin for the whole network.
912 */
913 function vigilante_deactivate( $network_wide = false ) {
914 require_once VIGILANTE_INCLUDES_DIR . 'class-database.php';
915 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
916 require_once VIGILANTE_INCLUDES_DIR . 'class-backup-manager.php';
917 require_once VIGILANTE_INCLUDES_DIR . 'class-wpconfig-security.php';
918 require_once VIGILANTE_INCLUDES_DIR . 'class-deactivator.php';
919
920 Vigilante_Deactivator::deactivate( (bool) $network_wide );
921 }
922 register_deactivation_hook( __FILE__, 'vigilante_deactivate' );
923
924 /**
925 * Initialize plugin after WordPress loads
926 */
927 add_action( 'plugins_loaded', 'vigilante_load_plugin' );
928
929 /*
930 * The hidden wp-admin is answered as early as the request can be judged with
931 * certainty, before the theme and the other plugins load. The modules are built
932 * on init priority 1, so until 2.9.9 a request that was going to be refused had
933 * already paid for the whole boot.
934 */
935 add_action( 'plugins_loaded', 'vigilante_block_hidden_admin_early', 1 );
936
937 /**
938 * Cheap gate for the early hidden wp-admin rejection
939 *
940 * Everything that can be decided without loading a single plugin class is
941 * decided here, so the usual request pays nothing more than a couple of
942 * comparisons and one option read that WordPress has already cached.
943 *
944 * @since 2.9.9
945 */
946 function vigilante_block_hidden_admin_early() {
947 if ( ! is_admin() ) {
948 return;
949 }
950
951 $method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'GET';
952
953 // POST is how remote managers authenticate, and the later path lets it through too.
954 if ( 'GET' !== $method ) {
955 return;
956 }
957
958 $options = get_option( 'vigilante_options', array() );
959
960 if ( ! is_array( $options )
961 || empty( $options['modules']['login_security'] )
962 || empty( $options['login_security']['custom_login_url'] ) ) {
963 return;
964 }
965
966 require_once VIGILANTE_INCLUDES_DIR . 'class-ip-utils.php';
967 require_once VIGILANTE_INCLUDES_DIR . 'class-login-security.php';
968
969 Vigilante_Login_Security::maybe_block_hidden_admin_early( $options );
970 }
971
972 /**
973 * Helper function to get plugin instance
974 *
975 * @return Vigilante_Main
976 */
977 function vigilante() {
978 return Vigilante_Main::get_instance();
979 }