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

class-activator.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 3.0.0, at includes/class-activator.php

544 lines 20.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Activator Class
4 *
5 * Handles plugin activation tasks
6 *
7 * @package Vigilante
8 */
9
10 // Prevent direct access
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Class Vigilante_Activator
17 *
18 * Fired during plugin activation
19 */
20 class Vigilante_Activator {
21
22 /**
23 * Run activation tasks
24 */
25 public static function activate() {
26 // Start output buffering to prevent any accidental output
27 ob_start();
28
29 // Check requirements first
30 if ( ! self::check_requirements() ) {
31 ob_end_clean();
32 return;
33 }
34
35 // Create database tables
36 $database = new Vigilante_Database();
37 $database->create_tables();
38
39 // Initialize default settings
40 $settings = new Vigilante_Settings();
41 $current_options = get_option( Vigilante_Settings::OPTION_NAME );
42
43 if ( false === $current_options ) {
44 // First installation - set defaults
45 $first_run = $settings->get_default_options();
46
47 /*
48 * XML-RPC on a brand new install: block the pingback methods, which is
49 * what gets abused for amplification, and leave the rest reachable so
50 * the WordPress app, Jetpack or a remote manager keep working out of
51 * the box. Disabling it completely is the stricter choice and the one
52 * the settings screen recommends, but it is not imposed on a site that
53 * never asked for it. Brute force through XML-RPC stays covered either
54 * way, because those logins go through wp_authenticate() and the login
55 * lockout hooks into it.
56 *
57 * Only written here, on a first installation. Sites upgrading keep
58 * whatever they had: the activation hook does not run on an update, and
59 * Vigilante_Comment_Security::resolve_xmlrpc_mode() answers 'full' when
60 * nothing is stored, which is what every version since 1.0.0 did.
61 */
62 $first_run = Vigilante_Settings::apply_install_tweaks( $first_run );
63
64 update_option( Vigilante_Settings::OPTION_NAME, $first_run );
65 // Refresh settings instance to get new values
66 $settings->clear_cache();
67 $settings = new Vigilante_Settings();
68 } else {
69 // Existing installation - run idempotent migrations
70 if ( self::run_migrations( $current_options ) ) {
71 $settings->clear_cache();
72 $settings = new Vigilante_Settings();
73 }
74 }
75
76 // Apply htaccess protection (part of firewall module)
77 if ( $settings->is_module_enabled( 'firewall' ) ) {
78 self::apply_htaccess_protection( $settings );
79 }
80
81 // Apply security headers to htaccess
82 if ( $settings->is_module_enabled( 'security_headers' ) ) {
83 self::apply_security_headers( $settings );
84 }
85
86 // Apply wp-config security (part of wp_hardening module)
87 if ( $settings->is_module_enabled( 'wp_hardening' ) ) {
88 self::apply_wpconfig_security( $settings );
89 }
90
91 // Update WordPress options for HTTPS (part of security_headers module)
92 if ( $settings->is_module_enabled( 'security_headers' ) ) {
93 self::enforce_https( $settings );
94 }
95
96 // Apply comment security settings (part of wp_hardening module)
97 if ( $settings->is_module_enabled( 'wp_hardening' ) ) {
98 self::apply_comment_security( $settings );
99 }
100
101 // Remove sensitive files
102 self::remove_sensitive_files( $settings );
103
104 // Generate critical config files baseline (after all Vigilante writes above)
105 self::generate_critical_baseline( $settings );
106
107 // Schedule cron events
108 self::schedule_events();
109
110 // Capture the self-integrity anchor (A3: manifest fingerprint in DB).
111 self::anchor_self_integrity( $settings );
112
113 // Set activation transient for admin notice
114 set_transient( 'vigilante_activated', true, 30 );
115
116 // Store activation time
117 update_option( 'vigilante_activated_time', time() );
118
119 // Send activation email if enabled
120 self::send_activation_email( $settings );
121
122 // Flush rewrite rules
123 flush_rewrite_rules();
124
125 // Clean any output that may have been generated
126 ob_end_clean();
127 }
128
129 /**
130 * Anchor the self-integrity check on activation
131 *
132 * The first activation captures the fingerprint of the shipped manifest,
133 * unless WordPress.org distributes something else for that version, so
134 * the self-check has a baseline from the very first run. A
135 * reactivation keeps the anchor it already has and checks against it:
136 * capturing again adopted whatever manifest the folder held at that
137 * moment, a regenerated one included, the same reason the critical files
138 * baseline is not thrown away on reactivation.
139 *
140 * @since 3.0.0
141 *
142 * @param Vigilante_Settings $settings Settings instance.
143 * @return void
144 */
145 public static function anchor_self_integrity( $settings ) {
146 if ( ! class_exists( 'Vigilante_Self_Integrity' ) ) {
147 require_once VIGILANTE_INCLUDES_DIR . 'class-self-integrity.php';
148 }
149 // run_check() captures on the first run itself, but only when
150 // WordPress.org does not contradict the manifest; with an anchor already
151 // there it checks against it.
152 $self_integrity = new Vigilante_Self_Integrity( $settings );
153 $self_integrity->run_check( 'activation' );
154 }
155
156 /**
157 * Idempotent migrations for existing installations.
158 *
159 * @param array $current_options Current vigilante_options array.
160 * @return bool True if any migration changed the stored option.
161 */
162 private static function run_migrations( $current_options ) {
163 $changed = false;
164
165 // Migration: rest_api_security.mode legacy value 'authenticated'
166 // (UI bug shipped a <select> value that did not match the backend
167 // string 'authenticated_only', so manual saves wrote a value the
168 // module ignored). Normalise so the option is honoured again.
169 if ( isset( $current_options['rest_api_security']['mode'] )
170 && 'authenticated' === $current_options['rest_api_security']['mode'] ) {
171 $current_options['rest_api_security']['mode'] = 'authenticated_only';
172 $changed = true;
173 }
174
175 // Migration: rest_api_security.protected_endpoints used to default to
176 // ['/wp/v2/users'], which duplicated the "Block user enumeration"
177 // toggle and confused users (turning that toggle off didn't unblock
178 // /users because protected_endpoints kept it locked in selective
179 // mode). If the saved list is still the legacy single-element default,
180 // empty it out so there is one knob per behaviour. Custom lists
181 // (anything other than exactly ['/wp/v2/users']) are left untouched.
182 if ( isset( $current_options['rest_api_security']['protected_endpoints'] )
183 && is_array( $current_options['rest_api_security']['protected_endpoints'] )
184 && array( '/wp/v2/users' ) === array_values( $current_options['rest_api_security']['protected_endpoints'] ) ) {
185 $current_options['rest_api_security']['protected_endpoints'] = array();
186 $changed = true;
187 }
188
189 // Migration: section-level 'enabled' flag wrongly stored as false.
190 // Earlier 2.4.x betas had a UI save handler that treated the absence
191 // of a field in the form as "checkbox unchecked" — including the
192 // top-level 'enabled' master flag, which has no checkbox in any
193 // section form. This left modules silently disabled even though the
194 // Dashboard master toggle was on. Restore the flag where it makes
195 // sense (master toggle on + flag false).
196 $sections = array(
197 'firewall',
198 'security_headers',
199 'login_security',
200 'rest_api_security',
201 'user_security',
202 'wp_hardening',
203 'file_integrity',
204 'activity_log',
205 );
206 foreach ( $sections as $section_name ) {
207 if ( ! empty( $current_options['modules'][ $section_name ] )
208 && isset( $current_options[ $section_name ] )
209 && is_array( $current_options[ $section_name ] )
210 && array_key_exists( 'enabled', $current_options[ $section_name ] )
211 && empty( $current_options[ $section_name ]['enabled'] ) ) {
212 $current_options[ $section_name ]['enabled'] = true;
213 $changed = true;
214 }
215 }
216
217 if ( $changed ) {
218 update_option( Vigilante_Settings::OPTION_NAME, $current_options );
219 }
220
221 return $changed;
222 }
223
224 /**
225 * Check minimum requirements
226 *
227 * @return bool
228 */
229 private static function check_requirements() {
230 // PHP version check
231 if ( version_compare( PHP_VERSION, '7.4', '<' ) ) {
232 add_action( 'admin_notices', function() {
233 printf(
234 '<div class="notice notice-error"><p>%s</p></div>',
235 esc_html__( 'Vigilant requires PHP 7.4 or higher.', 'vigilante' )
236 );
237 });
238 return false;
239 }
240
241 // WordPress version check
242 global $wp_version;
243 if ( version_compare( $wp_version, '5.0', '<' ) ) {
244 add_action( 'admin_notices', function() {
245 printf(
246 '<div class="notice notice-error"><p>%s</p></div>',
247 esc_html__( 'Vigilant requires WordPress 5.0 or higher.', 'vigilante' )
248 );
249 });
250 return false;
251 }
252
253 return true;
254 }
255
256 /**
257 * Apply htaccess protection
258 *
259 * @param Vigilante_Settings $settings Settings instance.
260 */
261 private static function apply_htaccess_protection( $settings ) {
262 // Only apply if Apache server
263 if ( ! self::is_apache() ) {
264 self::mark_server_files_pending();
265 return;
266 }
267
268 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-protection.php';
269
270 $htaccess = new Vigilante_Htaccess_Protection( $settings );
271 $htaccess->apply_rules();
272 }
273
274 /**
275 * Apply security headers to htaccess
276 *
277 * @param Vigilante_Settings $settings Settings instance.
278 */
279 private static function apply_security_headers( $settings ) {
280 // Only apply if Apache server
281 if ( ! self::is_apache() ) {
282 self::mark_server_files_pending();
283 return;
284 }
285
286 require_once VIGILANTE_INCLUDES_DIR . 'class-security-headers.php';
287
288 $security_headers = new Vigilante_Security_Headers( $settings );
289 $security_headers->apply_rules();
290 }
291
292 /**
293 * Apply wp-config security
294 *
295 * @param Vigilante_Settings $settings Settings instance.
296 */
297 private static function apply_wpconfig_security( $settings ) {
298 require_once VIGILANTE_INCLUDES_DIR . 'class-wpconfig-security.php';
299
300 $wpconfig = new Vigilante_Wpconfig_Security( $settings );
301 $wpconfig->apply_security_constants();
302 }
303
304 /**
305 * Enforce HTTPS in WordPress settings
306 *
307 * @param Vigilante_Settings $settings Settings instance.
308 */
309 private static function enforce_https( $settings ) {
310 $options = $settings->get_section( 'security_headers' );
311
312 if ( empty( $options['force_https'] ) ) {
313 return;
314 }
315
316 /*
317 * Only rewrite the URLs when the request doing the activation is itself
318 * running over HTTPS, which proves the site answers over it. Without this
319 * check, activating on an HTTP-only site pointed it at an address that
320 * may not respond, locking the owner out of their own admin. is_ssl() is
321 * also false under WP-CLI, where there is no request to learn from, so a
322 * command-line activation leaves the URLs alone as well.
323 */
324 if ( ! is_ssl() ) {
325 return;
326 }
327
328 // Check if already HTTPS
329 $site_url = get_option( 'siteurl' );
330 $home_url = get_option( 'home' );
331
332 // Update to HTTPS if not already
333 if ( strpos( $site_url, 'https://' ) === false ) {
334 update_option( 'siteurl', str_replace( 'http://', 'https://', $site_url ) );
335 }
336
337 if ( strpos( $home_url, 'https://' ) === false ) {
338 update_option( 'home', str_replace( 'http://', 'https://', $home_url ) );
339 }
340 }
341
342 /**
343 * Remove sensitive files from WordPress root
344 *
345 * @param Vigilante_Settings $settings Settings instance.
346 */
347 private static function remove_sensitive_files( $settings ) {
348 // They sit in the root every site of a network shares. Until 2.11.6 the
349 // activation on any site removed them.
350 if ( ! Vigilante_Settings::can_write_shared_files() ) {
351 return;
352 }
353
354 $advanced = $settings->get_section( 'advanced' );
355
356 // Remove readme.html
357 if ( ! empty( $advanced['remove_readme'] ) ) {
358 $readme_path = ABSPATH . 'readme.html';
359 if ( file_exists( $readme_path ) ) {
360 wp_delete_file( $readme_path );
361 }
362 }
363
364 // Remove license.txt / licencia.txt (Spanish locale)
365 if ( ! empty( $advanced['remove_license'] ) ) {
366 $license_files = array( 'license.txt', 'licencia.txt' );
367 foreach ( $license_files as $license_file ) {
368 $license_path = ABSPATH . $license_file;
369 if ( file_exists( $license_path ) ) {
370 wp_delete_file( $license_path );
371 }
372 }
373 }
374 }
375
376 /**
377 * Generate initial baseline hashes for critical config files
378 *
379 * Called once during activation, after Vigilante has written its own
380 * blocks to wp-config.php and .htaccess. The baseline stores the
381 * normalized hash (excluding Vigilante blocks) so that subsequent
382 * scans can detect unauthorized external modifications.
383 *
384 * @param Vigilante_Settings $settings Settings instance.
385 */
386 private static function generate_critical_baseline( $settings ) {
387 if ( ! class_exists( 'Vigilante_File_Integrity' ) ) {
388 require_once VIGILANTE_INCLUDES_DIR . 'class-file-integrity.php';
389 }
390
391 $database = new Vigilante_Database();
392 $activity_log = null; // Not needed for baseline generation
393
394 $fi = new Vigilante_File_Integrity( $settings, $database, $activity_log );
395
396 // Same care as the migration: reactivating the plugin on a site that
397 // already has an approved baseline must not throw it away and adopt
398 // whatever the files say today.
399 if ( ! $fi->get_critical_files_baseline() ) {
400 $fi->regenerate_all_baselines();
401 }
402 }
403
404 /**
405 * Schedule cron events
406 */
407 private static function schedule_events() {
408 // Daily maintenance
409 if ( ! wp_next_scheduled( 'vigilante_daily_maintenance' ) ) {
410 wp_schedule_event( time(), 'daily', 'vigilante_daily_maintenance' );
411 }
412
413 // Hourly checks
414 if ( ! wp_next_scheduled( 'vigilante_hourly_checks' ) ) {
415 wp_schedule_event( time(), 'hourly', 'vigilante_hourly_checks' );
416 }
417
418 // Weekly security analyzer scan
419 if ( ! wp_next_scheduled( 'vigilante_analyzer_weekly_scan' ) ) {
420 wp_schedule_event( time() + DAY_IN_SECONDS, 'weekly', 'vigilante_analyzer_weekly_scan' );
421 }
422
423 // Daily plugin status check (closed-in-wp.org detection)
424 if ( ! wp_next_scheduled( 'vigilante_plugin_status_check' ) ) {
425 wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'vigilante_plugin_status_check' );
426 }
427 }
428
429 /**
430 * Send activation notification email
431 *
432 * @param Vigilante_Settings $settings Settings instance.
433 */
434 private static function send_activation_email( $settings ) {
435 $email_settings = $settings->get_section( 'email' );
436
437 if ( empty( $email_settings['send_activation_email'] ) ) {
438 return;
439 }
440
441 if ( ! class_exists( 'Vigilante_Email_Template' ) ) {
442 require_once VIGILANTE_INCLUDES_DIR . 'class-email-template.php';
443 }
444
445 $to = Vigilante_Email_Template::get_admin_recipients();
446
447 $site_name = get_bloginfo( 'name' );
448 $site_url = get_site_url();
449
450 $subject = sprintf(
451 /* translators: %s: Site name */
452 __( '[%s] Vigilant Activated', 'vigilante' ),
453 $site_name
454 );
455
456 $body = Vigilante_Email_Template::p( __( 'Vigilant has been activated on your website. All security modules are now enabled with default settings.', 'vigilante' ) );
457 $body .= Vigilante_Email_Template::data_table( array(
458 __( 'Site', 'vigilante' ) => $site_name,
459 __( 'URL', 'vigilante' ) => $site_url,
460 __( 'Date', 'vigilante' ) => wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ),
461 ) );
462 $body .= Vigilante_Email_Template::info_box( __( 'Please review the settings in your WordPress admin panel.', 'vigilante' ) );
463 $body .= Vigilante_Email_Template::button( admin_url( 'admin.php?page=vigilante' ), __( 'Go to Vigilant', 'vigilante' ) );
464
465 Vigilante_Email_Template::send( $to, $subject, __( 'Plugin activated', 'vigilante' ), $body );
466 }
467
468 /**
469 * Apply comment security settings to WordPress options
470 *
471 * @param Vigilante_Settings $settings Settings instance.
472 */
473 private static function apply_comment_security( $settings ) {
474 $options = $settings->get_section( 'wp_hardening' );
475
476 // Disable pingbacks
477 if ( ! empty( $options['disable_pingbacks'] ) ) {
478 update_option( 'default_pingback_flag', 0 );
479 update_option( 'default_ping_status', 'closed' );
480 }
481
482 // Disable trackbacks
483 if ( ! empty( $options['disable_trackbacks'] ) ) {
484 update_option( 'default_ping_status', 'closed' );
485 }
486
487 // Require comment moderation
488 if ( ! empty( $options['require_comment_moderation'] ) ) {
489 update_option( 'comment_moderation', 1 );
490 }
491 }
492
493 /**
494 * Check if server is Apache
495 *
496 * @return bool
497 */
498 private static function is_apache() {
499 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
500
501 // One detection for the whole plugin. This used to be a second copy of
502 // the same logic, so fixing one never fixed the other.
503 return Vigilante_Htaccess_Manager::get_instance()->is_apache();
504 }
505
506 /**
507 * Leave the server layer pending when the server could not be identified
508 *
509 * An activation from WP-CLI has no request to read the server software
510 * from, so before 2.9.9 the two apply_* guards below simply returned and
511 * the site was left without the .htaccess layer, with every switch showing
512 * as on. Now it is written down, so the first web request applies it, and
513 * it is logged, so it is visible that it happened.
514 *
515 * @since 2.9.9
516 */
517 private static function mark_server_files_pending() {
518 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
519
520 // On a server known not to be Apache there is nothing to write, ever.
521 if ( ! Vigilante_Htaccess_Manager::get_instance()->server_is_unknown() ) {
522 return;
523 }
524
525 update_option( 'vigilante_server_files_pending', 1 );
526
527 // The activation runs before the plugin has loaded its own files, so
528 // every link of the chain has to be pulled in: the log asks the database
529 // for the client IP, and that resolves it through the IP helper.
530 require_once VIGILANTE_INCLUDES_DIR . 'class-ip-utils.php';
531 require_once VIGILANTE_INCLUDES_DIR . 'class-database.php';
532 require_once VIGILANTE_INCLUDES_DIR . 'class-activity-log.php';
533
534 $settings = new Vigilante_Settings();
535 $activity_log = new Vigilante_Activity_Log( $settings, new Vigilante_Database() );
536 $activity_log->log(
537 'system',
538 'server_rules_pending',
539 __( 'The server type could not be identified from this request, so the .htaccess rules were left pending and will be written on the first web request.', 'vigilante' ),
540 array( 'sapi' => PHP_SAPI ),
541 'warning'
542 );
543 }
544 }