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

928 lines 37.7 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.7
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.7' );
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 }
369
370 // Login Security
371 if ( ! empty( $options['modules']['login_security'] ) ) {
372 $login_security = new Vigilante_Login_Security( $this->settings, $this->database, $this->activity_log );
373
374 // Two-Factor Authentication (only if login security module is active)
375 new Vigilante_Two_Factor_Email( $this->settings, $this->database, $this->activity_log, $login_security );
376 new Vigilante_Two_Factor_TOTP( $this->settings, $this->database, $this->activity_log, $login_security );
377 }
378
379 // WordPress Hardening (includes comments, head cleaner, feeds)
380 if ( ! empty( $options['modules']['wp_hardening'] ) ) {
381 new Vigilante_Comment_Security( $this->settings );
382 new Vigilante_Head_Cleaner( $this->settings );
383 new Vigilante_Feed_Manager( $this->settings );
384 }
385
386 // File Integrity Scanner
387 if ( ! empty( $options['modules']['file_integrity'] ) ) {
388 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
389 $file_integrity->init_hooks();
390 $file_integrity->init_cleanup_hooks();
391
392 new Vigilante_Plugin_Status( $this->settings, $this->activity_log );
393 } elseif ( is_admin() ) {
394 /*
395 * The module is off, and the cleanup goes on anyway. It is not
396 * integrity monitoring: it takes out of the database the copy of
397 * wp-config.php that earlier versions stored, credentials and all.
398 * Turning the module off is not a decision to keep them.
399 *
400 * Two holes closed here, both reported by @calzbert after reading the
401 * 2.11.3 diff. A site with the module off cleaned itself by neither
402 * of its own two paths, because both hang off this class. And with
403 * the module off on the MAIN site, the network sweep was not
404 * registered either, which is what would have reached every other
405 * site: the sweep removes each site's option without asking whether
406 * the module is on over there.
407 *
408 * Only in the admin, because both hooks are admin_init and there is
409 * nothing to gain from building this on a front-end request. Note
410 * that admin-ajax.php fires admin_init too (wp-admin/admin-ajax.php
411 * :45), so this also runs on wp_ajax_nopriv_* requests from
412 * visitors with no session. That is deliberate and it is what the
413 * module has been doing since 2.11.2: the cleanup asks for no
414 * capability because it also runs under wp-cron with nobody logged
415 * in, and all it does is take the plugin's own copy out of the
416 * database. The network sweep, which does reach across sites, is
417 * the one that demands manage_network_options.
418 */
419 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
420 $file_integrity->init_cleanup_hooks();
421 }
422
423 // Activity Log is always initialized (core component)
424 // Logging is gated by the modules.activity_log toggle and per-type flags
425
426 // Audit Alerts engine - an alerting layer on top of Security Audit.
427 // Only instantiated when Security Audit is on, because it reacts to the
428 // events the activity log records (a passive subscriber, no per-module
429 // coupling). Both alert legs are opt-in, off by default.
430 if ( ! empty( $options['modules']['activity_log'] ) ) {
431 new Vigilante_Audit_Alerts( $this->settings, $this->activity_log );
432 }
433
434 // Under Attack mode - always loaded (independent of modules)
435 new Vigilante_Under_Attack( $this->settings, $this->activity_log );
436
437 // Admin interface
438 if ( is_admin() ) {
439 new Vigilante_Admin( $this->settings, $this->database, $this->activity_log );
440 }
441 }
442
443 /**
444 * Initialize WordPress hooks
445 */
446 private function init_hooks() {
447 // Plugin action links
448 add_filter( 'plugin_action_links_' . VIGILANTE_PLUGIN_BASENAME, array( $this, 'add_action_links' ) );
449
450 // Scheduled tasks
451 add_action( 'vigilante_daily_maintenance', array( $this, 'daily_maintenance' ) );
452 add_action( 'vigilante_hourly_checks', array( $this, 'hourly_checks' ) );
453
454 // AJAX handlers
455 add_action( 'wp_ajax_vigilante_dismiss_notice', array( $this, 'ajax_dismiss_notice' ) );
456
457 // Regenerate critical file baseline after Vigilante modifies wp-config.php or .htaccess
458 add_action( 'vigilante_critical_file_written', array( $this, 'on_critical_file_written' ) );
459
460 // Keep the server layer in step with the installed version.
461 add_action( 'init', array( $this, 'maybe_sync_server_files' ), 20 );
462 }
463
464 /**
465 * Rewrite the .htaccess block when the installed version has moved on
466 *
467 * Updating the plugin did not touch the file: the block was only rewritten
468 * on activation or when the Headers or Firewall tab was saved. So a fix
469 * that lives inside those rules never reached a site that merely updated,
470 * which is exactly what happened with the connect-src of 2.9.6: the browser
471 * kept receiving the old policy, and image uploads kept failing on
472 * WordPress 7.1 until someone pressed Save. This rewrites the block once
473 * per version, and picks up the rules that an activation from WP-CLI had to
474 * leave pending because it could not tell what server it was on.
475 *
476 * Only the content between the plugin markers is rewritten, the same part
477 * any save has always rewritten.
478 *
479 * @since 2.9.9
480 */
481 public function maybe_sync_server_files() {
482 $pending = (bool) get_option( 'vigilante_server_files_pending' );
483
484 if ( ! $pending && VIGILANTE_VERSION === get_option( 'vigilante_server_files_version' ) ) {
485 return;
486 }
487
488 // A failed write is not retried on every request.
489 if ( (int) get_option( 'vigilante_server_files_retry_after' ) > time() ) {
490 return;
491 }
492
493 /*
494 * A subsite has nothing to do here, ever: the file belongs to the main
495 * site. Marking it done keeps every request from re-checking.
496 *
497 * 2.10.0 asked the wrong question at this point and it cost the whole
498 * feature on networks. can_write_shared_files() ends in a capability
499 * check, and this runs on init for every request, so on a network the
500 * branch below was the one nearly every visitor took: it retired the job
501 * without having written a thing. The .htaccess was never refreshed after
502 * an update, and the one-shot snapshot behind it was consumed without
503 * being taken, so not even a network administrator visiting afterwards
504 * retried, because the version had already been marked. Reported by
505 * @calzbert, who found it reading the code.
506 */
507 if ( ! Vigilante_Settings::owns_shared_files() ) {
508 $this->mark_server_files_synced();
509 return;
510 }
511
512 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
513 $manager = Vigilante_Htaccess_Manager::get_instance();
514
515 if ( ! $manager->is_apache() ) {
516 // Still on the command line with nothing to learn from: stay pending.
517 if ( $manager->server_is_unknown() ) {
518 return;
519 }
520
521 // Not Apache: there is no block to keep in step.
522 $this->mark_server_files_synced();
523 return;
524 }
525
526 $options = get_option( Vigilante_Settings::OPTION_NAME, array() );
527 $headers = isset( $options['security_headers'] ) ? (array) $options['security_headers'] : array();
528 $failed = false;
529 $rewrote = false;
530
531 /*
532 * Last chance to keep what the file still says. The rewrites below are
533 * precisely what overwrites it, and on a site whose header settings the
534 * 2.9.8 migration reset, this file is the only remaining copy of what the
535 * owner had actually chosen. Captured here rather than inside the write
536 * path so it only ever happens on a version change: an ordinary save also
537 * leaves the file describing the previous values for an instant, and
538 * capturing there would spend the single slot on a difference the owner
539 * made deliberately.
540 */
541 $wrote_last = (string) get_option( 'vigilante_server_files_version' );
542
543 /*
544 * And only on the very first sync that arrives from a version older than
545 * this one. That is the whole window: the file still describes what the
546 * owner chose, and the rewrite below is what ends it. Gating on the
547 * version also keeps a future release, one that legitimately changes what
548 * the block contains, from reading its own improvement as damage and
549 * offering to undo it.
550 */
551 /*
552 * 2.10.1 and not 2.10.0, deliberately: it gives the networks a second
553 * chance. On a network 2.10.0 marked this done without writing anything,
554 * so the window closed with the snapshot untaken. But nothing was
555 * written, which means the .htaccess on those sites still describes the
556 * configuration its owner actually chose. Reopening the window one
557 * version wide is what lets them be recovered after all.
558 *
559 * Harmless where it already worked: a site that took a snapshot is
560 * skipped because one exists, and a site that found nothing to take has
561 * had its file rewritten to match its settings, so there is still no
562 * difference to find.
563 */
564 if ( '' === $wrote_last || version_compare( $wrote_last, '2.10.1', '<' ) ) {
565 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-recovery.php';
566 Vigilante_Htaccess_Recovery::maybe_capture( $manager->get_content(), $this->settings );
567 }
568
569 $needs_protection_block = ! empty( $options['modules']['firewall'] )
570 || ! empty( $headers['hide_server_signature'] )
571 || ! empty( $headers['remove_fingerprinting_headers'] );
572
573 /*
574 * A 'locked' result is not a failure: another request is doing this very
575 * work right now. Returning without marking anything leaves the pending
576 * state alone, so whichever request wins finishes the job and this one
577 * stays out of the way. Treating it as a failure would arm the one hour
578 * backoff for something that is already being handled.
579 */
580 $locked = false;
581 $incomplete = false;
582
583 if ( $needs_protection_block ) {
584 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-protection.php';
585 $result = ( new Vigilante_Htaccess_Protection( $this->settings ) )->apply_rules( true );
586 $code = is_wp_error( $result ) ? $result->get_error_code() : ( true === $result ? '' : 'unexpected_result' );
587 $locked = $locked || 'locked' === $code;
588 $incomplete = $incomplete || 'block_incomplete' === $code;
589 $failed = $failed || ( '' !== $code && 'locked' !== $code && 'block_incomplete' !== $code );
590 $rewrote = true;
591 }
592
593 if ( ! $locked && ! empty( $options['modules']['security_headers'] ) ) {
594 require_once VIGILANTE_INCLUDES_DIR . 'class-security-headers.php';
595 $result = ( new Vigilante_Security_Headers( $this->settings ) )->apply_rules( true );
596 $code = is_wp_error( $result ) ? $result->get_error_code() : ( true === $result ? '' : 'unexpected_result' );
597 $locked = $locked || 'locked' === $code;
598 $incomplete = $incomplete || 'block_incomplete' === $code;
599 $failed = $failed || ( '' !== $code && 'locked' !== $code && 'block_incomplete' !== $code );
600 $rewrote = true;
601 }
602
603 if ( $locked ) {
604 return;
605 }
606
607 if ( $failed ) {
608 update_option( 'vigilante_server_files_retry_after', time() + HOUR_IN_SECONDS );
609
610 // A refusal to write the server rules is exactly the kind of thing
611 // that used to happen in silence, so it is recorded and retried in
612 // an hour instead of being forgotten.
613 if ( $this->activity_log ) {
614 $this->activity_log->log(
615 'system',
616 'server_rules_write_failed',
617 __( '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' ),
618 array( 'version' => VIGILANTE_VERSION ),
619 'warning'
620 );
621 }
622
623 return;
624 }
625
626 /*
627 * A block with a BEGIN line and no END is not going to mend itself, so
628 * retrying every hour would only repeat the refusal: it is recorded once
629 * for this version, with what to do about it, and the job is marked done.
630 * Saving the Firewall or Headers tab after fixing the file writes the
631 * rules again.
632 */
633 if ( $incomplete && $this->activity_log ) {
634 $this->activity_log->log(
635 'system',
636 'server_rules_block_incomplete',
637 __( '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' ),
638 array( 'version' => VIGILANTE_VERSION ),
639 'warning'
640 );
641 }
642
643 $this->mark_server_files_synced();
644
645 if ( $rewrote && ! $incomplete && $this->activity_log ) {
646 $this->activity_log->log(
647 'system',
648 'server_rules_refreshed',
649 sprintf(
650 /* translators: %s: plugin version. */
651 __( 'The .htaccess rules were rewritten to match Vigilant %s.', 'vigilante' ),
652 VIGILANTE_VERSION
653 ),
654 array( 'version' => VIGILANTE_VERSION ),
655 'info'
656 );
657 }
658 }
659
660 /**
661 * Record that the server layer matches the installed version
662 *
663 * @since 2.9.9
664 */
665 private function mark_server_files_synced() {
666 update_option( 'vigilante_server_files_version', VIGILANTE_VERSION );
667 delete_option( 'vigilante_server_files_pending' );
668 delete_option( 'vigilante_server_files_retry_after' );
669 }
670
671 /**
672 * Update the critical file baseline after Vigilante writes to a monitored file
673 *
674 * @param string $filename File that was modified (e.g. 'wp-config.php').
675 */
676 public function on_critical_file_written( $filename ) {
677 if ( ! class_exists( 'Vigilante_File_Integrity' ) ) {
678 require_once VIGILANTE_INCLUDES_DIR . 'class-file-integrity.php';
679 }
680
681 $fi = new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
682 $fi->update_critical_file_baseline( $filename );
683 }
684
685 /**
686 * Add plugin action links
687 *
688 * @param array $links Existing links.
689 * @return array Modified links.
690 */
691 public function add_action_links( $links ) {
692 $plugin_links = array(
693 '<a href="' . esc_url( admin_url( 'admin.php?page=vigilante' ) ) . '">' . esc_html__( 'Security Settings', 'vigilante' ) . '</a>',
694 );
695 return array_merge( $plugin_links, $links );
696 }
697
698 /**
699 * Daily maintenance tasks
700 */
701 public function daily_maintenance() {
702 // Clean old activity logs
703 $this->activity_log->cleanup_old_logs();
704
705 // Clean old login attempts
706 $this->database->cleanup_old_login_attempts();
707
708 // Clean expired 2FA codes and trusted devices
709 $this->database->cleanup_expired_2fa_codes();
710 $this->database->cleanup_expired_trusted_devices();
711
712 // Remove sensitive files (readme.html, license.txt, licencia.txt)
713 // WordPress core updates recreate these files, so we clean them daily.
714 // They sit in the root every site of a network shares, so only the main
715 // site removes them, from its own settings; until 2.11.6 the daily
716 // maintenance of any site did.
717 $advanced = Vigilante_Settings::owns_shared_files() ? $this->settings->get_section( 'advanced' ) : array();
718 if ( ! empty( $advanced['remove_readme'] ) ) {
719 $readme_path = ABSPATH . 'readme.html';
720 if ( file_exists( $readme_path ) ) {
721 wp_delete_file( $readme_path );
722 }
723 }
724 if ( ! empty( $advanced['remove_license'] ) ) {
725 $license_files = array( 'license.txt', 'licencia.txt' );
726 foreach ( $license_files as $license_file ) {
727 $license_path = ABSPATH . $license_file;
728 if ( file_exists( $license_path ) ) {
729 wp_delete_file( $license_path );
730 }
731 }
732 }
733
734 // Log maintenance
735 $this->activity_log->log( 'system', 'maintenance', __( 'Daily maintenance completed', 'vigilante' ) );
736 }
737
738 /**
739 * Hourly checks
740 */
741 public function hourly_checks() {
742 // File integrity scans are handled by the File_Integrity class own cron schedule
743 // based on the configured scan_frequency (daily/weekly).
744 }
745
746 /**
747 * AJAX handler for dismissing notices
748 */
749 public function ajax_dismiss_notice() {
750 check_ajax_referer( 'vigilante_dismiss_notice', 'nonce' );
751
752 if ( ! current_user_can( 'manage_options' ) ) {
753 wp_die( -1 );
754 }
755
756 $notice_id = isset( $_POST['notice_id'] ) ? sanitize_key( $_POST['notice_id'] ) : '';
757
758 if ( $notice_id ) {
759 $dismissed = get_option( 'vigilante_dismissed_notices', array() );
760 $dismissed[ $notice_id ] = time();
761 update_option( 'vigilante_dismissed_notices', $dismissed );
762 }
763
764 wp_send_json_success();
765 }
766 }
767
768 /**
769 * Cron handler for the weekly Security Analyzer scan.
770 *
771 * Resolves the shared Vigilante_Security_Analyzer (lazily; no cost when the
772 * cron is not firing) and lets it run the scan + regression email logic.
773 */
774 function vigilante_run_analyzer_cron() {
775 if ( ! class_exists( 'Vigilante_Security_Analyzer' ) ) {
776 require_once VIGILANTE_INCLUDES_DIR . 'class-security-analyzer.php';
777 }
778 if ( ! class_exists( 'Vigilante_Settings' ) ) {
779 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
780 }
781
782 $settings = new Vigilante_Settings();
783 $activity_log = null;
784 if ( class_exists( 'Vigilante_Activity_Log' ) && class_exists( 'Vigilante_Database' ) ) {
785 $database = new Vigilante_Database();
786 $activity_log = new Vigilante_Activity_Log( $settings, $database );
787 }
788
789 $analyzer = new Vigilante_Security_Analyzer( $settings, $activity_log );
790 $analyzer->cron_weekly_scan();
791 }
792
793 /**
794 * Cron handler for the daily plugin status check.
795 *
796 * Resolves the shared Vigilante_Plugin_Status lazily so the daily cron has no
797 * cost while it is not firing.
798 */
799 function vigilante_run_plugin_status_check() {
800 if ( ! class_exists( 'Vigilante_Plugin_Status' ) ) {
801 require_once VIGILANTE_INCLUDES_DIR . 'class-plugin-status.php';
802 }
803 if ( ! class_exists( 'Vigilante_Settings' ) ) {
804 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
805 }
806
807 $settings = new Vigilante_Settings();
808 $activity_log = null;
809 if ( class_exists( 'Vigilante_Activity_Log' ) && class_exists( 'Vigilante_Database' ) ) {
810 $database = new Vigilante_Database();
811 $activity_log = new Vigilante_Activity_Log( $settings, $database );
812 }
813
814 $checker = new Vigilante_Plugin_Status( $settings, $activity_log );
815 $checker->run_scheduled_check();
816 }
817
818 /**
819 * Run a Security Analyzer full scan after Under Attack mode deactivates.
820 *
821 * Scheduled one-shot from Vigilante_Under_Attack::deactivate() so the dashboard
822 * reflects the restored configuration with the slow HTTP/header probes the
823 * mode prevented from running safely while it was active.
824 */
825 function vigilante_run_post_under_attack_scan() {
826 if ( ! class_exists( 'Vigilante_Under_Attack' ) ) {
827 require_once VIGILANTE_INCLUDES_DIR . 'class-under-attack.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 $under_attack = new Vigilante_Under_Attack( $settings, $activity_log );
841 $under_attack->run_analyzer_scan( 'all' );
842 }
843
844 /**
845 * Plugin activation hook
846 */
847 function vigilante_activate() {
848 require_once VIGILANTE_INCLUDES_DIR . 'class-database.php';
849 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
850 require_once VIGILANTE_INCLUDES_DIR . 'class-backup-manager.php';
851 require_once VIGILANTE_INCLUDES_DIR . 'class-activator.php';
852
853 Vigilante_Activator::activate();
854 }
855 register_activation_hook( __FILE__, 'vigilante_activate' );
856
857 /**
858 * Plugin deactivation hook
859 *
860 * @param bool $network_wide Whether core is deactivating the plugin for the whole network.
861 */
862 function vigilante_deactivate( $network_wide = false ) {
863 require_once VIGILANTE_INCLUDES_DIR . 'class-database.php';
864 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
865 require_once VIGILANTE_INCLUDES_DIR . 'class-backup-manager.php';
866 require_once VIGILANTE_INCLUDES_DIR . 'class-wpconfig-security.php';
867 require_once VIGILANTE_INCLUDES_DIR . 'class-deactivator.php';
868
869 Vigilante_Deactivator::deactivate( (bool) $network_wide );
870 }
871 register_deactivation_hook( __FILE__, 'vigilante_deactivate' );
872
873 /**
874 * Initialize plugin after WordPress loads
875 */
876 add_action( 'plugins_loaded', 'vigilante_load_plugin' );
877
878 /*
879 * The hidden wp-admin is answered as early as the request can be judged with
880 * certainty, before the theme and the other plugins load. The modules are built
881 * on init priority 1, so until 2.9.9 a request that was going to be refused had
882 * already paid for the whole boot.
883 */
884 add_action( 'plugins_loaded', 'vigilante_block_hidden_admin_early', 1 );
885
886 /**
887 * Cheap gate for the early hidden wp-admin rejection
888 *
889 * Everything that can be decided without loading a single plugin class is
890 * decided here, so the usual request pays nothing more than a couple of
891 * comparisons and one option read that WordPress has already cached.
892 *
893 * @since 2.9.9
894 */
895 function vigilante_block_hidden_admin_early() {
896 if ( ! is_admin() ) {
897 return;
898 }
899
900 $method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'GET';
901
902 // POST is how remote managers authenticate, and the later path lets it through too.
903 if ( 'GET' !== $method ) {
904 return;
905 }
906
907 $options = get_option( 'vigilante_options', array() );
908
909 if ( ! is_array( $options )
910 || empty( $options['modules']['login_security'] )
911 || empty( $options['login_security']['custom_login_url'] ) ) {
912 return;
913 }
914
915 require_once VIGILANTE_INCLUDES_DIR . 'class-ip-utils.php';
916 require_once VIGILANTE_INCLUDES_DIR . 'class-login-security.php';
917
918 Vigilante_Login_Security::maybe_block_hidden_admin_early( $options );
919 }
920
921 /**
922 * Helper function to get plugin instance
923 *
924 * @return Vigilante_Main
925 */
926 function vigilante() {
927 return Vigilante_Main::get_instance();
928 }