PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.5
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.5
3.0.5 3.0.4 3.0.3 3.0.2 3.0.1 3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 All 93 releases
vigilante / admin / class-admin.php

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

10,006 lines 627.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Admin Class
4 *
5 * Handles admin interface and settings page
6 *
7 * @package Vigilante
8 */
9
10 // Prevent direct access
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 // Load AJAX trait
16 require_once VIGILANTE_ADMIN_DIR . 'class-admin-ajax.php';
17
18 // Load promotional banner class
19 require_once VIGILANTE_INCLUDES_DIR . 'class-ayudawp-promo-banner.php';
20
21 /**
22 * Class Vigilante_Admin
23 *
24 * Manages the admin settings interface
25 */
26 class Vigilante_Admin {
27
28 use Vigilante_Admin_Ajax;
29 use Vigilante_Admin_Analyzer_Ajax;
30 use Vigilante_Admin_Audit_Alerts_Ajax;
31 use Vigilante_Admin_Recovery_Ajax;
32
33 /**
34 * Settings instance
35 *
36 * @var Vigilante_Settings
37 */
38 private $settings;
39
40 /**
41 * Database instance
42 *
43 * @var Vigilante_Database
44 */
45 private $database;
46
47 /**
48 * Activity log instance
49 *
50 * @var Vigilante_Activity_Log
51 */
52 private $activity_log;
53
54 /**
55 * Current tab
56 *
57 * @var string
58 */
59 private $current_tab = 'dashboard';
60
61 /**
62 * Available tabs
63 *
64 * @var array
65 */
66 private $tabs = array();
67
68 /**
69 * Constructor
70 *
71 * @param Vigilante_Settings $settings Settings instance.
72 * @param Vigilante_Database $database Database instance.
73 * @param Vigilante_Activity_Log $activity_log Activity log instance.
74 */
75 public function __construct( $settings, $database, $activity_log ) {
76 $this->settings = $settings;
77 $this->database = $database;
78 $this->activity_log = $activity_log;
79
80 $this->setup_tabs();
81 $this->init_hooks();
82 }
83
84 /**
85 * Setup available tabs
86 */
87 private function setup_tabs() {
88 $this->tabs = array(
89 'dashboard' => __( 'Dashboard', 'vigilante' ),
90 'firewall' => __( 'Firewall', 'vigilante' ),
91 'headers' => __( 'Security Headers', 'vigilante' ),
92 'login' => __( 'Login Security', 'vigilante' ),
93 'rest-api' => __( 'REST API', 'vigilante' ),
94 'users' => __( 'User Security', 'vigilante' ),
95 'wp-hardening' => __( 'WP Hardening', 'vigilante' ),
96 'file-integrity' => __( 'File Integrity', 'vigilante' ),
97 'activity-log' => __( 'Security Audit', 'vigilante' ),
98 'tools' => __( 'Settings & Tools', 'vigilante' ),
99 );
100 }
101
102 /**
103 * Initialize hooks
104 */
105 private function init_hooks() {
106 add_action( 'admin_menu', array( $this, 'add_menu' ) );
107 add_action( 'admin_init', array( $this, 'redirect_submenu_shortcuts' ) );
108 add_action( 'admin_init', array( $this, 'register_settings' ) );
109 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
110 add_action( 'admin_notices', array( $this, 'show_admin_notices' ) );
111 // Self-protection speaks in the network admin too: on a network its
112 // files are shared, and the super administrator is who can repair them.
113 add_action( 'network_admin_notices', array( $this, 'maybe_show_self_protection_notice' ) );
114
115 // Highlight correct submenu based on active tab
116 add_filter( 'submenu_file', array( $this, 'highlight_submenu_tab' ) );
117
118 // Set browser tab title to show plugin name and active tab
119 add_filter( 'admin_title', array( $this, 'set_admin_page_title' ), 10, 2 );
120
121 // AJAX handlers
122 add_action( 'wp_ajax_vigilante_save_settings', array( $this, 'ajax_save_settings' ) );
123 add_action( 'wp_ajax_vigilante_apply_preset', array( $this, 'ajax_apply_preset' ) );
124 add_action( 'wp_ajax_vigilante_reset_section', array( $this, 'ajax_reset_section' ) );
125 add_action( 'wp_ajax_vigilante_clear_lockouts', array( $this, 'ajax_clear_lockouts' ) );
126 add_action( 'wp_ajax_vigilante_clear_logs', array( $this, 'ajax_clear_logs' ) );
127 add_action( 'wp_ajax_vigilante_run_scan', array( $this, 'ajax_run_scan' ) );
128 add_action( 'wp_ajax_vigilante_clear_scan', array( $this, 'ajax_clear_scan' ) );
129 add_action( 'wp_ajax_vigilante_ignore_file', array( $this, 'ajax_ignore_file' ) );
130 add_action( 'wp_ajax_vigilante_unignore_file', array( $this, 'ajax_unignore_file' ) );
131 add_action( 'wp_ajax_vigilante_bulk_ignore_files', array( $this, 'ajax_bulk_ignore_files' ) );
132 add_action( 'wp_ajax_vigilante_bulk_unignore_files', array( $this, 'ajax_bulk_unignore_files' ) );
133 add_action( 'wp_ajax_vigilante_clear_ignored', array( $this, 'ajax_clear_ignored' ) );
134 add_action( 'wp_ajax_vigilante_ignore_closed_plugin', array( $this, 'ajax_ignore_closed_plugin' ) );
135 add_action( 'wp_ajax_vigilante_unignore_closed_plugin', array( $this, 'ajax_unignore_closed_plugin' ) );
136 add_action( 'wp_ajax_vigilante_clear_ignored_closed_plugins', array( $this, 'ajax_clear_ignored_closed_plugins' ) );
137 add_action( 'wp_ajax_vigilante_approve_critical_file', array( $this, 'ajax_approve_critical_file' ) );
138 add_action( 'wp_ajax_vigilante_export_settings', array( $this, 'ajax_export_settings' ) );
139 add_action( 'wp_ajax_vigilante_import_settings', array( $this, 'ajax_import_settings' ) );
140 add_action( 'wp_ajax_vigilante_get_logs', array( $this, 'ajax_get_logs' ) );
141 add_action( 'wp_ajax_vigilante_test_headers', array( $this, 'ajax_test_headers' ) );
142 add_action( 'wp_ajax_vigilante_download_files_backup', array( $this, 'ajax_download_files_backup' ) );
143
144 // 2FA AJAX handlers
145 add_action( 'wp_ajax_vigilante_search_users_2fa', array( $this, 'ajax_search_users_2fa' ) );
146 add_action( 'wp_ajax_vigilante_send_2fa_notification', array( $this, 'ajax_send_2fa_notification' ) );
147 add_action( 'wp_ajax_vigilante_search_totp_users', array( $this, 'ajax_search_totp_users' ) );
148 add_action( 'wp_ajax_vigilante_reset_totp_users', array( $this, 'ajax_reset_totp_users' ) );
149 add_action( 'wp_ajax_vigilante_totp_get_setup', array( $this, 'ajax_totp_get_setup' ) );
150 add_action( 'wp_ajax_vigilante_notify_login_url', array( $this, 'ajax_notify_login_url' ) );
151
152 // Password Reset AJAX handlers
153 add_action( 'wp_ajax_vigilante_search_users_password_reset', array( $this, 'ajax_search_users_password_reset' ) );
154 add_action( 'wp_ajax_vigilante_force_password_reset', array( $this, 'ajax_force_password_reset' ) );
155 add_action( 'wp_ajax_vigilante_force_password_reset_all', array( $this, 'ajax_force_password_reset_all' ) );
156 add_action( 'wp_ajax_vigilante_force_password_reset_by_role', array( $this, 'ajax_force_password_reset_by_role' ) );
157
158 // User approval AJAX handlers
159 add_action( 'wp_ajax_vigilante_approve_user', array( $this, 'ajax_approve_user' ) );
160 add_action( 'wp_ajax_vigilante_reject_user', array( $this, 'ajax_reject_user' ) );
161
162 // Session management AJAX handlers
163 add_action( 'wp_ajax_vigilante_get_user_sessions', array( $this, 'ajax_get_user_sessions' ) );
164 add_action( 'wp_ajax_vigilante_revoke_session', array( $this, 'ajax_revoke_session' ) );
165 add_action( 'wp_ajax_vigilante_revoke_all_sessions', array( $this, 'ajax_revoke_all_sessions' ) );
166
167 // Under Attack mode AJAX handlers
168 add_action( 'wp_ajax_vigilante_activate_under_attack', array( $this, 'ajax_activate_under_attack' ) );
169 add_action( 'wp_ajax_vigilante_deactivate_under_attack', array( $this, 'ajax_deactivate_under_attack' ) );
170 add_action( 'wp_ajax_vigilante_under_attack_status', array( $this, 'ajax_under_attack_status' ) );
171
172 // Database backup AJAX handlers
173 add_action( 'wp_ajax_vigilante_get_db_tables', array( $this, 'ajax_get_db_tables' ) );
174 add_action( 'wp_ajax_vigilante_download_db_backup', array( $this, 'ajax_download_db_backup' ) );
175
176 // Database prefix AJAX handlers
177 add_action( 'wp_ajax_vigilante_generate_prefix', array( $this, 'ajax_generate_prefix' ) );
178 add_action( 'wp_ajax_vigilante_change_prefix', array( $this, 'ajax_change_prefix' ) );
179
180 // Firewall list management from activity log popup
181 add_action( 'wp_ajax_vigilante_add_to_firewall_list', array( $this, 'ajax_add_to_firewall_list' ) );
182 add_action( 'wp_ajax_vigilante_unblock_firewall_ip', array( $this, 'ajax_unblock_firewall_ip' ) );
183
184 // Security Analyzer AJAX handlers (v2.1.0)
185 add_action( 'wp_ajax_vigilante_analyzer_run', array( $this, 'ajax_analyzer_run' ) );
186 add_action( 'wp_ajax_vigilante_analyzer_history', array( $this, 'ajax_analyzer_history' ) );
187 add_action( 'wp_ajax_vigilante_analyzer_dismiss_notice', array( $this, 'ajax_analyzer_dismiss_notice' ) );
188 add_action( 'wp_ajax_vigilante_analyzer_save_settings', array( $this, 'ajax_analyzer_save_settings' ) );
189
190 // Security Headers settings recovery (2.10.0)
191 add_action( 'wp_ajax_vigilante_headers_recovery_restore', array( $this, 'ajax_headers_recovery_restore' ) );
192 add_action( 'wp_ajax_vigilante_headers_recovery_undo', array( $this, 'ajax_headers_recovery_undo' ) );
193 add_action( 'wp_ajax_vigilante_headers_recovery_dismiss', array( $this, 'ajax_headers_recovery_dismiss' ) );
194
195 // Shared "Send test email" handler — Notification settings, File Integrity, Audit Alerts (v2.8.0)
196 add_action( 'wp_ajax_vigilante_send_test_email', array( $this, 'ajax_send_test_email' ) );
197
198 // Run migrations on admin load
199 add_action( 'admin_init', array( $this, 'run_migrations' ) );
200 }
201
202 /**
203 * Run database migrations based on stored version
204 */
205 public function run_migrations() {
206 /*
207 * admin-ajax.php fires admin_init before it decides who is asking
208 * (wp-admin/admin-ajax.php:45), so until 2.11.10 an anonymous POST to
209 * admin-ajax.php with any action ran every pending migration. That is
210 * not a read: the migrations rewrite wp-config.php through
211 * apply_security_constants(), rewrite the root .htaccess, move user meta
212 * of the whole network and can rebuild the file integrity baseline,
213 * taking whatever is on disk as approved. Reproduced on 12 sep 2026 with
214 * curl and no cookies, and found by the file-by-file review of 2.11.10.
215 *
216 * Migrations are maintenance for whoever administers the site, so they
217 * wait for an administrator to load a screen. Nothing is lost by
218 * waiting: every migration is idempotent and version gated.
219 */
220 if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {
221 return;
222 }
223
224 $db_version = get_option( 'vigilante_db_version', '0' );
225
226 // 1.2.3: Fix IP lists corrupted by sanitize_text_field stripping newlines
227 if ( version_compare( $db_version, '1.2.3', '<' ) ) {
228 $this->migrate_fix_ip_lists();
229 update_option( 'vigilante_db_version', '1.2.3' );
230 }
231
232 // 1.3.0: Add request_method column to activity log table
233 if ( version_compare( $db_version, '1.3.0', '<' ) ) {
234 $this->database->run_migrations();
235 }
236
237 // 1.9.0: Re-apply wp-config constants (performance constants removed from managed list)
238 if ( version_compare( $db_version, '1.9.0', '<' ) ) {
239 if ( $this->settings->is_module_enabled( 'wp_hardening' ) ) {
240 require_once VIGILANTE_INCLUDES_DIR . 'class-wpconfig-security.php';
241 $wpconfig = new Vigilante_Wpconfig_Security( $this->settings );
242 $wpconfig->apply_security_constants();
243 }
244 update_option( 'vigilante_db_version', '1.9.0' );
245 }
246
247 // 1.10.0: Clean up orphaned email fields (centralized notification recipients)
248 if ( version_compare( $db_version, '1.10.0', '<' ) ) {
249 $this->migrate_cleanup_email_fields();
250 update_option( 'vigilante_db_version', '1.10.0' );
251 }
252
253 // 1.11.0: Remove stale 'enabled' key from activity_log settings
254 // + Convert additional_recipients from string to array
255 if ( version_compare( $db_version, '1.11.0', '<' ) ) {
256 $options = get_option( Vigilante_Settings::OPTION_NAME, array() );
257 $changed = false;
258
259 if ( isset( $options['activity_log']['enabled'] ) ) {
260 unset( $options['activity_log']['enabled'] );
261 $changed = true;
262 }
263
264 // Convert corrupted string to array for additional_recipients
265 if ( isset( $options['email']['additional_recipients'] ) && is_string( $options['email']['additional_recipients'] ) ) {
266 $raw = trim( $options['email']['additional_recipients'] );
267 if ( ! empty( $raw ) ) {
268 $emails = array_filter( array_map( 'trim', preg_split( '/[\r\n,; ]+/', $raw ) ) );
269 $options['email']['additional_recipients'] = array_values( array_filter( $emails, 'is_email' ) );
270 } else {
271 $options['email']['additional_recipients'] = array();
272 }
273 $changed = true;
274 }
275
276 if ( $changed ) {
277 update_option( Vigilante_Settings::OPTION_NAME, $options );
278 }
279 update_option( 'vigilante_db_version', '1.11.0' );
280 }
281
282 // 1.12.1: Regenerate htaccess (WooCommerce IPN exclusion in bot blocking rule)
283 if ( version_compare( $db_version, '1.12.1', '<' ) ) {
284 if ( ! empty( $this->settings->get_section( 'firewall' )['block_bad_bots'] ) ) {
285 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-protection.php';
286 $htaccess = new Vigilante_Htaccess_Protection( $this->settings );
287 $htaccess->apply_rules();
288 }
289 update_option( 'vigilante_db_version', '1.12.1' );
290 }
291
292 // 1.14.0: Generate critical config files baseline (wp-config.php, .htaccess)
293 if ( version_compare( $db_version, '1.14.0', '<' ) ) {
294 if ( ! class_exists( 'Vigilante_File_Integrity' ) ) {
295 require_once VIGILANTE_INCLUDES_DIR . 'class-file-integrity.php';
296 }
297 $fi = new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
298
299 /*
300 * Only when there is nothing on record. This migration exists to
301 * create the baseline that did not exist, never to discard the one
302 * the owner approved: rebuilding it from the files takes whatever
303 * is on disk right now as approved, so a wp-config.php modified and
304 * awaiting review would be blessed in silence.
305 *
306 * And this is not theory. vigilante_db_version is written on two
307 * different scales into the same option: this file counts in plugin
308 * versions (2.11.0) and Vigilante_Database counts in schema
309 * versions, currently 1.4.0 (class-database.php:322 and :380). For
310 * version_compare, 1.4.0 is LOWER than 1.14.0, so any site whose
311 * option was last written by the schema runs this migration again.
312 * Measured on the Multisite install on 10 sep 2026: one of the three
313 * sites was sitting on 1.4.0.
314 */
315 if ( ! $fi->get_critical_files_baseline() ) {
316 $fi->regenerate_all_baselines();
317 }
318
319 update_option( 'vigilante_db_version', '1.14.0' );
320 }
321
322 // 2.0.0: Move hide_server_signature and remove_fingerprinting_headers
323 // from firewall section to security_headers section
324 if ( version_compare( $db_version, '2.0.0', '<' ) ) {
325 $options = get_option( Vigilante_Settings::OPTION_NAME, array() );
326 $changed = false;
327
328 foreach ( array( 'hide_server_signature', 'remove_fingerprinting_headers' ) as $key ) {
329 if ( isset( $options['firewall'][ $key ] ) ) {
330 if ( ! isset( $options['security_headers'][ $key ] ) ) {
331 $options['security_headers'][ $key ] = $options['firewall'][ $key ];
332 }
333 unset( $options['firewall'][ $key ] );
334 $changed = true;
335 }
336 }
337
338 if ( $changed ) {
339 update_option( Vigilante_Settings::OPTION_NAME, $options );
340 }
341 update_option( 'vigilante_db_version', '2.0.0' );
342 }
343
344 // 2.6.1: Two things happen here.
345 //
346 // 1. Re-apply wp-config constants so the block is rewritten with
347 // "if ( ! defined() )" guards around every define(). Without guards,
348 // non-standard setups that pre-define WordPress constants outside
349 // wp-config.php (custom bootstraps that load constants from .env or
350 // similar) hit a fatal "Constant already defined" when wp-config.php
351 // is parsed and reaches our block.
352 //
353 // 2. Drop the cached Security Check report. The cached "max" per
354 // category was frozen at scan time; with the internal category
355 // bumping from 22 to 28 points (closed_plugins added in 2.6.0),
356 // the cached report would keep displaying 22/22 until the next
357 // full scan. Clearing it forces a fresh scan with the new caps.
358 if ( version_compare( $db_version, '2.6.1', '<' ) ) {
359 if ( $this->settings->is_module_enabled( 'wp_hardening' ) ) {
360 require_once VIGILANTE_INCLUDES_DIR . 'class-wpconfig-security.php';
361 $wpconfig = new Vigilante_Wpconfig_Security( $this->settings );
362 $wpconfig->apply_security_constants();
363 }
364 delete_option( 'vigilante_analyzer_last_scan' );
365
366 // Schedule an immediate background scan so the dashboard widget
367 // doesn't display "Last scan: never" right after the upgrade.
368 // Reuses the same hook the post-Under-Attack flow uses.
369 if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
370 wp_schedule_single_event( time() + 5, 'vigilante_under_attack_post_scan' );
371 }
372
373 update_option( 'vigilante_db_version', '2.6.1' );
374 }
375
376 // 2.9.3: Regenerate the .htaccess protection block. The bad-bots
377 // User-Agent list dropped substring-prone tokens that 403'd
378 // legitimate clients (e.g. "rma" matched inside "Performance" and
379 // blocked WP Rocket's page fetch), and the blocking rules now honour
380 // the firewall IP / User-Agent whitelists as negated exceptions.
381 // Existing sites only rewrite the block when Server Protection is
382 // saved, so the upgrade has to refresh it once itself (same pattern
383 // as the 1.12.1 WooCommerce IPN migration).
384 if ( version_compare( $db_version, '2.9.3', '<' ) ) {
385 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-protection.php';
386 $htaccess = new Vigilante_Htaccess_Protection( $this->settings );
387
388 if ( $htaccess->are_rules_active() ) {
389 $htaccess->apply_rules();
390 }
391
392 update_option( 'vigilante_db_version', '2.9.3' );
393 }
394
395 /*
396 * 2.9.8: the mixed content handling changes shape. "Upgrade Insecure
397 * Requests" becomes a setting of its own, and Fix Mixed Content ships
398 * off, where before it shipped on and carried the directive with it.
399 * Both have to be written down for sites that are updating, so their
400 * pages keep loading exactly what they loaded yesterday.
401 *
402 * Read the RAW stored options, not get_section(): that one merges the
403 * defaults, so a site that never stored the key would be read with the
404 * new default and silently lose the behaviour it had. Absent means the
405 * site was running on the old default, which was on.
406 */
407 if ( version_compare( $db_version, '2.9.8', '<' ) ) {
408 $raw = get_option( Vigilante_Settings::OPTION_NAME, array() );
409 $stored = ( is_array( $raw ) && isset( $raw['security_headers'] ) && is_array( $raw['security_headers'] ) ) ? $raw['security_headers'] : array();
410 $had_fix = array_key_exists( 'fix_mixed_content', $stored ) ? ! empty( $stored['fix_mixed_content'] ) : true;
411
412 /*
413 * Merge, never replace. update_section() overwrites the whole
414 * section, so passing just these two keys wiped every other header
415 * setting the site had stored (HSTS, CSP, cross-origin policies,
416 * the HTTPS switches, Server Identity) and left the screen showing
417 * factory defaults while the .htaccess kept serving the old values.
418 */
419 $this->settings->update_section(
420 'security_headers',
421 array_merge(
422 $stored,
423 array(
424 'fix_mixed_content' => $had_fix,
425 'upgrade_insecure_requests' => $had_fix,
426 )
427 )
428 );
429
430 update_option( 'vigilante_db_version', '2.9.8' );
431 }
432
433 /*
434 * 2.9.9: drop the settings that no code has read for versions.
435 *
436 * They were carried in the defaults and therefore written into every
437 * saved configuration, they show up in an exported configuration, and
438 * anyone reading them assumes a feature exists behind them. Removing
439 * them from the defaults is not enough: the stored copies survive, so
440 * they are swept here too. Nothing reads them, so nothing changes.
441 */
442 if ( version_compare( $db_version, '2.9.9', '<' ) ) {
443 $raw = get_option( Vigilante_Settings::OPTION_NAME, array() );
444 $dead = array(
445 'firewall' => array( 'country_blocking', 'protected_file_extensions' ),
446 'file_integrity' => array( 'suspicious_patterns' ),
447 'backup' => array( 'auto_backup', 'backup_before_update' ),
448 'advanced' => array( 'block_author_archives', 'disable_embeds', 'uninstall_cleanup', 'debug_mode' ),
449 );
450
451 $changed = false;
452 foreach ( $dead as $section => $keys ) {
453 if ( ! isset( $raw[ $section ] ) || ! is_array( $raw[ $section ] ) ) {
454 continue;
455 }
456 foreach ( $keys as $key ) {
457 if ( array_key_exists( $key, $raw[ $section ] ) ) {
458 unset( $raw[ $section ][ $key ] );
459 $changed = true;
460 }
461 }
462 }
463
464 if ( $changed ) {
465 update_option( Vigilante_Settings::OPTION_NAME, $raw );
466 }
467
468 update_option( 'vigilante_db_version', '2.9.9' );
469 }
470
471 /*
472 * 2.11.0: security release (audit of 28 Aug 2026). Runs here and not
473 * from Vigilante_Database::needs_update(): this option is shared with
474 * that class, and on any updated site it already holds a plugin version
475 * (2.9.9 or later), so a bump of DB_VERSION would never fire.
476 * create_tables() widens the email code column through dbDelta (varchar
477 * 6 to 64, the code is stored hashed since 2.11.0) and purge_for_2_11_0()
478 * does what dbDelta cannot: it empties the trusted devices, which were
479 * identified by User-Agent until now (S1), and the pending email codes,
480 * stored in clear until now (S11). Every remembered device asks for the
481 * second factor once more after this update, and the changelog says so.
482 */
483 if ( version_compare( $db_version, '2.11.0', '<' ) ) {
484 $this->database->create_tables();
485 $this->database->purge_for_2_11_0();
486
487 update_option( 'vigilante_db_version', '2.11.0' );
488 }
489
490 /*
491 * 2.11.9: clear the raw .htaccess copies that older versions left in
492 * options, on the first admin load after the update. Uninstall already
493 * removes them, but that only fires when the plugin is deleted, so a
494 * site that keeps the plugin carried them until now. Three stores, each
495 * a copy of a file that can hold secrets (a SetEnv token, an
496 * Authorization header): the same exposure the wp.org review flagged as
497 * 4.4, on the paths its fix did not reach.
498 *
499 * - vigilante_htaccess_history: up to five raw copies, by design, until
500 * 2.11.8. The writer is gone, nothing reads it, so it is deleted.
501 * - vigilante_htaccess_backup: the single rollback buffer, normally
502 * cleared in the finally of each write; a copy only lingers if a write
503 * crashed mid-operation. Nothing outside one write reads it, so a
504 * leftover is deleted.
505 * - vigilante_htaccess_pre_migration: still read by the header recovery,
506 * but older versions stored the whole file where only our own block is
507 * ever used. Truncated to that block, so the feature keeps working and
508 * nothing outside our markers stays in the option.
509 */
510 if ( version_compare( $db_version, '2.11.9', '<' ) ) {
511 delete_option( 'vigilante_htaccess_history' );
512 delete_option( 'vigilante_htaccess_backup' );
513
514 $snapshot = get_option( 'vigilante_htaccess_pre_migration' );
515 if ( is_array( $snapshot ) && isset( $snapshot['content'] ) && '' !== (string) $snapshot['content'] ) {
516 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-recovery.php';
517 $block = Vigilante_Htaccess_Recovery::get_raw_block();
518
519 if ( '' === $block ) {
520 delete_option( 'vigilante_htaccess_pre_migration' );
521 } elseif ( $block !== $snapshot['content'] ) {
522 $snapshot['content'] = $block;
523 update_option( 'vigilante_htaccess_pre_migration', $snapshot, false );
524 }
525 }
526
527 update_option( 'vigilante_db_version', '2.11.9' );
528 }
529
530 /*
531 * 2.11.10: the pending-approval flag becomes one per site on a network.
532 * Until 2.11.9 it was a single global user meta, so the queue was shared
533 * across the whole network. Moving the key is not enough: the accounts
534 * already waiting carry the old key, and reading only the new one would
535 * let them log in. So they are moved here, each to the site it belongs
536 * to, and the old key is removed only once the new one is written.
537 */
538 if ( version_compare( $db_version, '2.11.10', '<' ) ) {
539 $this->migrate_pending_approval_per_site();
540
541 update_option( 'vigilante_db_version', '2.11.10' );
542 }
543
544 /*
545 * 3.0.0: self-protection package. This block must stay the LAST one of
546 * run_migrations(): every block compares against the same $db_version
547 * captured at the top, so the last update_option() that runs is the
548 * one that sticks, and on a jump from any older version it has to be
549 * this one.
550 *
551 * 1. Capture the self-integrity anchor (the manifest fingerprint) and
552 * run an inline self-check. It covers every update the old code never
553 * saw through the upgrader hook: from any 2.x, and manual or FTP
554 * uploads. A plain new is enough, the constructor registers no hooks.
555 * 2. Drop the cached Security Check report: the Internal category grows
556 * from 30 to 33 points (the self_integrity check), and the cached
557 * report would keep the old denominator until the next full scan.
558 * Same reason and same background refresh as the 2.6.1 block above.
559 * 3. Remove Vigilant's own files from the ignore list before that check.
560 * Until 2.11.11 the generic scan listed them like any plugin, and any
561 * administrator could ignore one (after a false alarm while
562 * WordPress.org published new checksums, for example). Kept, those
563 * entries would silence the self-check of those files for good,
564 * PHP included.
565 */
566 if ( version_compare( $db_version, '3.0.0', '<' ) ) {
567 if ( ! class_exists( 'Vigilante_Self_Integrity' ) ) {
568 require_once VIGILANTE_INCLUDES_DIR . 'class-self-integrity.php';
569 }
570 $ignored_files = get_option( 'vigilante_ignored_files', array() );
571 if ( is_array( $ignored_files ) && $ignored_files ) {
572 $kept_files = array_values(
573 array_filter(
574 $ignored_files,
575 function ( $ignored_file ) {
576 return ! Vigilante_Self_Integrity::is_own_file_path( (string) $ignored_file );
577 }
578 )
579 );
580 if ( count( $kept_files ) !== count( $ignored_files ) ) {
581 update_option( 'vigilante_ignored_files', $kept_files );
582 if ( $this->activity_log ) {
583 $this->activity_log->log(
584 'system',
585 'self_ignored_files_removed',
586 __( 'Vigilant files were removed from the File Integrity ignore list: self-protection checks them now.', 'vigilante' ),
587 array( 'removed' => count( $ignored_files ) - count( $kept_files ) ),
588 'info'
589 );
590 }
591 }
592 }
593 $self_integrity = new Vigilante_Self_Integrity( $this->settings, $this->activity_log );
594 $self_integrity->run_check( 'migration' );
595
596 delete_option( 'vigilante_analyzer_last_scan' );
597 if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
598 wp_schedule_single_event( time() + 5, 'vigilante_under_attack_post_scan' );
599 }
600
601 update_option( 'vigilante_db_version', '3.0.0' );
602 }
603 }
604
605 /**
606 * Move the pending-approval flag of a network to a key per site
607 *
608 * Runs once for the whole network, not once per site: the data it moves is
609 * global, so the guard is a network option and any site may be the one that
610 * does it. On a single site the key does not change and there is nothing to
611 * do.
612 *
613 * Each waiting account goes to its primary site, or to the only site it
614 * belongs to; one that belongs to none goes to the main site rather than
615 * nowhere, because losing the flag would silently approve it.
616 *
617 * @since 2.11.10
618 */
619 private function migrate_pending_approval_per_site() {
620 global $wpdb;
621
622 if ( ! is_multisite() ) {
623 return;
624 }
625
626 if ( get_site_option( 'vigilante_pending_per_site_done' ) ) {
627 return;
628 }
629
630 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- One-off migration of the plugin's own user meta; the meta API has no "list every user with this key".
631 $user_ids = $wpdb->get_col(
632 $wpdb->prepare( "SELECT DISTINCT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s", 'vigilante_pending_approval' )
633 );
634
635 foreach ( (array) $user_ids as $user_id ) {
636 $user_id = (int) $user_id;
637 if ( ! $user_id ) {
638 continue;
639 }
640
641 $pending = get_user_meta( $user_id, 'vigilante_pending_approval', true );
642 $since = get_user_meta( $user_id, 'vigilante_pending_since', true );
643
644 /*
645 * Every site the account belongs to, not its primary one. The global
646 * flag does not say where the registration happened, and the first
647 * version of this guessed the primary blog: an account that
648 * registered on B while its primary was A came out pending on A and
649 * free to log in on B, which is the very site it had never been
650 * approved on. Found by the cross review of 2.11.10.
651 *
652 * Marking every site it belongs to fails closed instead: the account
653 * stays blocked wherever it can log in, and shows up in the queue of
654 * each of those sites so somebody can actually act on it. An account
655 * that belongs to no site goes to the main one rather than nowhere,
656 * because losing the flag would silently approve it.
657 */
658 /*
659 * With $all true, because the default leaves out archived, spam and
660 * deleted sites (wp-includes/user.php:1113-1117): a site archived on
661 * the day this runs would lose the flag, and the account would walk
662 * in unapproved the moment it was brought back. Found by the second
663 * cross review of 2.11.10.
664 */
665 $blog_ids = array();
666
667 foreach ( get_blogs_of_user( $user_id, true ) as $blog ) {
668 if ( ! empty( $blog->userblog_id ) ) {
669 $blog_ids[] = (int) $blog->userblog_id;
670 }
671 }
672
673 if ( empty( $blog_ids ) ) {
674 $blog_ids[] = (int) get_main_site_id();
675 }
676
677 foreach ( array_unique( $blog_ids ) as $blog_id ) {
678 $prefix = $wpdb->get_blog_prefix( $blog_id );
679
680 update_user_meta( $user_id, $prefix . 'vigilante_pending_approval', $pending );
681 if ( '' !== $since && false !== $since ) {
682 update_user_meta( $user_id, $prefix . 'vigilante_pending_since', $since );
683 }
684 }
685
686 delete_user_meta( $user_id, 'vigilante_pending_approval' );
687 delete_user_meta( $user_id, 'vigilante_pending_since' );
688 }
689
690 update_site_option( 'vigilante_pending_per_site_done', 1 );
691 }
692
693 /**
694 * Migration: Remove orphaned email fields from saved options
695 *
696 * v1.10.0 centralized notification recipients into email section.
697 * Old per-module notify_email fields and dead email section fields
698 * are removed to avoid confusion.
699 */
700 private function migrate_cleanup_email_fields() {
701 $options = get_option( Vigilante_Settings::OPTION_NAME, array() );
702 $modified = false;
703
704 // Remove orphaned fields from email section
705 $dead_email_keys = array( 'enabled', 'from_name', 'from_email', 'admin_email', 'send_activation_email', 'custom_email' );
706 if ( isset( $options['email'] ) && is_array( $options['email'] ) ) {
707 foreach ( $dead_email_keys as $key ) {
708 if ( array_key_exists( $key, $options['email'] ) ) {
709 unset( $options['email'][ $key ] );
710 $modified = true;
711 }
712 }
713 // Ensure new fields exist with defaults
714 if ( ! array_key_exists( 'send_to_admin_email', $options['email'] ) ) {
715 $options['email']['send_to_admin_email'] = true;
716 $modified = true;
717 }
718 if ( ! array_key_exists( 'additional_recipients', $options['email'] ) ) {
719 $options['email']['additional_recipients'] = '';
720 $modified = true;
721 }
722 }
723
724 // Remove notify_email from login_security
725 if ( isset( $options['login_security']['notify_email'] ) ) {
726 unset( $options['login_security']['notify_email'] );
727 $modified = true;
728 }
729
730 // Remove notify_email from file_integrity
731 if ( isset( $options['file_integrity']['notify_email'] ) ) {
732 unset( $options['file_integrity']['notify_email'] );
733 $modified = true;
734 }
735
736 if ( $modified ) {
737 update_option( Vigilante_Settings::OPTION_NAME, $options );
738 // Clear settings cache so the plugin uses clean data immediately
739 $this->settings->clear_cache();
740 }
741 }
742
743 /**
744 * Migration: Fix IP whitelist/blacklist entries merged into single line
745 *
746 * Prior to 1.2.3, sanitize_text_field() stripped newlines from textarea data,
747 * causing multiple IPs to be stored as a single space-separated string.
748 */
749 private function migrate_fix_ip_lists() {
750 $options = get_option( Vigilante_Settings::OPTION_NAME, array() );
751 $fixed = false;
752
753 foreach ( array( 'ip_whitelist', 'ip_blacklist' ) as $key ) {
754 if ( ! empty( $options['firewall'][ $key ] ) && is_array( $options['firewall'][ $key ] ) ) {
755 $new_list = array();
756 foreach ( $options['firewall'][ $key ] as $entry ) {
757 // Split entries that were joined by spaces
758 $parts = preg_split( '/\s+/', trim( $entry ) );
759 foreach ( $parts as $part ) {
760 $part = trim( $part );
761 if ( '' !== $part ) {
762 $new_list[] = $part;
763 }
764 }
765 }
766 if ( count( $new_list ) !== count( $options['firewall'][ $key ] ) ) {
767 $options['firewall'][ $key ] = array_unique( $new_list );
768 $fixed = true;
769 }
770 }
771 }
772
773 if ( $fixed ) {
774 update_option( Vigilante_Settings::OPTION_NAME, $options );
775 // Clear settings cache so changes take effect immediately
776 $this->settings->clear_cache();
777 }
778 }
779
780 /**
781 * Add admin menu page in last position
782 */
783 public function add_menu() {
784 $menu_title = __( 'Vigilant', 'vigilante' );
785
786 // Count pending approvals (separate concern, always red if present)
787 $pending_count = $this->get_pending_approvals_count();
788
789 // Get security issues with severity
790 $security_status = $this->get_security_status_for_badge();
791
792 /*
793 * Self-protection: a change to Vigilant own files is the one finding
794 * that has to be visible from any screen of WordPress, so it adds to
795 * the counter and paints it red. A verified state, a state with fewer
796 * references than usual and the check turned off add nothing: a counter
797 * that is always on is a counter nobody reads.
798 */
799 $self_tone = Vigilante_Self_Integrity::tone(
800 Vigilante_Self_Integrity::display_state(),
801 Vigilante_Self_Integrity::is_on()
802 );
803 $self_badge = in_array( $self_tone, array( 'critical', 'off', 'warning' ), true ) ? 1 : 0;
804
805 // Total count for badge
806 $total_badge = $pending_count + $security_status['count'] + $self_badge;
807
808 if ( $total_badge > 0 ) {
809 // Determine badge color:
810 // - Red (awaiting-mod): pending approvals OR critical modules disabled
811 // - Orange (update-plugins): only non-critical modules disabled
812 if ( $pending_count > 0 || $security_status['has_critical'] || in_array( $self_tone, array( 'critical', 'off' ), true ) ) {
813 $badge_class = 'awaiting-mod';
814 } else {
815 $badge_class = 'update-plugins vigilante-badge-warning';
816 }
817
818 $menu_title .= sprintf(
819 ' <span class="%s count-%d"><span class="pending-count">%d</span></span>',
820 esc_attr( $badge_class ),
821 $total_badge,
822 $total_badge
823 );
824 }
825
826 add_menu_page(
827 __( 'Vigilant', 'vigilante' ),
828 $menu_title,
829 'manage_options',
830 'vigilante',
831 array( $this, 'render_settings_page' ),
832 'dashicons-shield',
833 999
834 );
835
836 // Rename auto-generated first submenu to "Dashboard"
837 add_submenu_page(
838 'vigilante',
839 __( 'Dashboard', 'vigilante' ),
840 __( 'Dashboard', 'vigilante' ),
841 'manage_options',
842 'vigilante',
843 array( $this, 'render_settings_page' )
844 );
845
846 // Security Audit shortcut
847 add_submenu_page(
848 'vigilante',
849 __( 'Security Audit', 'vigilante' ),
850 __( 'Security Audit', 'vigilante' ),
851 'manage_options',
852 'vigilante-activity-log',
853 array( $this, 'redirect_to_tab' )
854 );
855
856 // File Integrity shortcut, with its own counter when the self-check has
857 // something to say: that is the screen that explains it.
858 $fi_menu_title = __( 'File Integrity', 'vigilante' );
859 if ( $self_badge > 0 ) {
860 $fi_menu_title .= sprintf(
861 ' <span class="%s count-1"><span class="pending-count">1</span></span>',
862 in_array( $self_tone, array( 'critical', 'off' ), true ) ? 'awaiting-mod' : 'update-plugins vigilante-badge-warning'
863 );
864 }
865 add_submenu_page(
866 'vigilante',
867 __( 'File Integrity', 'vigilante' ),
868 $fi_menu_title,
869 'manage_options',
870 'vigilante-file-integrity',
871 array( $this, 'redirect_to_tab' )
872 );
873 }
874
875 /**
876 * Redirect submenu shortcuts early, before headers are sent
877 */
878 public function redirect_submenu_shortcuts() {
879 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Just reading page slug for redirect
880 $page = isset( $_GET['page'] ) ? sanitize_key( $_GET['page'] ) : '';
881
882 $tab_map = array(
883 'vigilante-activity-log' => 'activity-log',
884 'vigilante-file-integrity' => 'file-integrity',
885 );
886
887 if ( isset( $tab_map[ $page ] ) ) {
888 wp_safe_redirect( admin_url( 'admin.php?page=vigilante&tab=' . $tab_map[ $page ] ) );
889 exit;
890 }
891 }
892
893 /**
894 * Fallback redirect for submenu shortcuts (JS-based)
895 */
896 public function redirect_to_tab() {
897 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Just reading page slug for redirect
898 $page = isset( $_GET['page'] ) ? sanitize_key( $_GET['page'] ) : '';
899
900 $tab_map = array(
901 'vigilante-activity-log' => 'activity-log',
902 'vigilante-file-integrity' => 'file-integrity',
903 );
904
905 if ( isset( $tab_map[ $page ] ) ) {
906 $url = admin_url( 'admin.php?page=vigilante&tab=' . $tab_map[ $page ] );
907 echo '<script>window.location.replace(' . wp_json_encode( esc_url( $url ) ) . ');</script>';
908 }
909 }
910
911 /**
912 * Highlight the correct submenu item based on active tab
913 *
914 * @param string $submenu_file Current submenu file.
915 * @return string
916 */
917 public function highlight_submenu_tab( $submenu_file ) {
918 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading tab for menu highlight only
919 $page = isset( $_GET['page'] ) ? sanitize_key( $_GET['page'] ) : '';
920 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
921 $tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : '';
922
923 if ( 'vigilante' !== $page || empty( $tab ) ) {
924 return $submenu_file;
925 }
926
927 $tab_to_submenu = array(
928 'activity-log' => 'vigilante-activity-log',
929 'file-integrity' => 'vigilante-file-integrity',
930 );
931
932 if ( isset( $tab_to_submenu[ $tab ] ) ) {
933 return $tab_to_submenu[ $tab ];
934 }
935
936 return $submenu_file;
937 }
938
939 /**
940 * Set browser tab title to show plugin name and active tab
941 *
942 * Changes "Dashboard ‹ Site Name — WordPress" to
943 * "Vigilant > Dashboard ‹ Site Name — WordPress"
944 *
945 * @param string $admin_title Full admin title.
946 * @param string $title Page title from add_menu_page/add_submenu_page.
947 * @return string Modified title.
948 */
949 public function set_admin_page_title( $admin_title, $title ) {
950 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading page slug for title only
951 $page = isset( $_GET['page'] ) ? sanitize_key( $_GET['page'] ) : '';
952
953 // Only modify on Vigilante pages
954 if ( 0 !== strpos( $page, 'vigilante' ) ) {
955 return $admin_title;
956 }
957
958 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
959 $tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'dashboard';
960
961 if ( isset( $this->tabs[ $tab ] ) ) {
962 $tab_label = $this->tabs[ $tab ];
963 } else {
964 $tab_label = __( 'Dashboard', 'vigilante' );
965 }
966
967 $plugin_title = __( 'Vigilant', 'vigilante' ) . ' &rsaquo; ' . $tab_label;
968
969 // Replace the original page title portion
970 return str_replace( $title, $plugin_title, $admin_title );
971 }
972
973 /**
974 * Get count of users pending approval
975 *
976 * @return int Count of pending users.
977 */
978 private function get_pending_approvals_count() {
979 // Prevent early execution before WordPress is ready
980 if ( ! did_action( 'plugins_loaded' ) ) {
981 return 0;
982 }
983
984 /*
985 * Counted whether the feature is on or off. An account already waiting
986 * stays blocked when it is switched off (see init_enforcement_hooks()),
987 * so reporting zero there hid people who cannot log in and whom nobody
988 * could see to approve. Found by the cross review of 2.11.10.
989 */
990
991 // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Limited results in admin context.
992 $args = array(
993 'meta_key' => Vigilante_User_Security::site_user_meta_key( 'vigilante_pending_approval' ),
994 'meta_value' => '1',
995 'fields' => 'ID',
996 );
997 // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
998
999 // Same query as Vigilante_User_Security::get_pending_users(), and for the
1000 // same reason: the meta key already scopes this to the site, and adding
1001 // core's membership filter on top hid the accounts that have no role yet.
1002 if ( is_multisite() ) {
1003 $args['blog_id'] = 0;
1004 }
1005
1006 $pending_users = get_users( $args );
1007
1008 return count( $pending_users );
1009 }
1010
1011 /**
1012 * Whether XML-RPC is blocked, whole or its pingback methods
1013 *
1014 * Both levels have to say so: the three-way setting under WP Hardening, read
1015 * through the module's own resolver, and the WP Hardening module that
1016 * enforces it. Until 3.0.2 the Dashboard read login_security[disable_xmlrpc],
1017 * the checkbox that moved to WP Hardening in 2.9.7 and is never written any
1018 * more, so the score never granted its three points and the recommendation
1019 * to disable XML-RPC stayed on for good.
1020 *
1021 * @since 3.0.2
1022 *
1023 * @param array $options Plugin options.
1024 * @return bool
1025 */
1026 private function is_xmlrpc_protected( $options ) {
1027 return ! empty( $options['modules']['wp_hardening'] )
1028 && 'none' !== Vigilante_Comment_Security::resolve_xmlrpc_mode( $this->settings );
1029 }
1030
1031 /**
1032 * Calculate comprehensive security score (0-100)
1033 *
1034 * The maximum is the same for every site, 129 points. A module that is
1035 * switched off loses its weight and the points of its details, which stay
1036 * in the total. Until 3.0.4 the details of a module only counted, on both
1037 * sides of the fraction, while the module was on: a site with almost
1038 * everything off kept a middling score, because what it had switched off
1039 * left the total as well, and switching Security Audit off with no alert
1040 * configured raised the score.
1041 *
1042 * @param array $options Plugin options.
1043 * @return int Security score.
1044 */
1045 private function calculate_security_score( $options ) {
1046 $score = 0;
1047 $max_score = 0;
1048
1049 // Module scores (60 points total)
1050 $module_weights = array(
1051 'firewall' => 10,
1052 'security_headers' => 8,
1053 'login_security' => 10,
1054 'rest_api_security' => 6,
1055 'user_security' => 8,
1056 'wp_hardening' => 8,
1057 'file_integrity' => 5,
1058 'activity_log' => 5,
1059 );
1060
1061 foreach ( $module_weights as $module => $weight ) {
1062 $max_score += $weight;
1063 if ( ! empty( $options['modules'][ $module ] ) ) {
1064 $score += $weight;
1065 }
1066 }
1067
1068 // Firewall details (10 points)
1069 $max_score += 10;
1070 if ( ! empty( $options['modules']['firewall'] ) ) {
1071 $firewall = $options['firewall'] ?? array();
1072
1073 $firewall_checks = array(
1074 'block_sql_injection',
1075 'block_xss_attacks',
1076 'block_bad_query_strings',
1077 'block_file_inclusion',
1078 'block_directory_traversal',
1079 );
1080
1081 $firewall_enabled = 0;
1082 foreach ( $firewall_checks as $check ) {
1083 if ( ! empty( $firewall[ $check ] ) ) {
1084 $firewall_enabled++;
1085 }
1086 }
1087 $score += min( 10, $firewall_enabled * 2 );
1088 }
1089
1090 // Login security details (10 points)
1091 $max_score += 10;
1092 if ( ! empty( $options['modules']['login_security'] ) ) {
1093 $login = $options['login_security'] ?? array();
1094
1095 // Max attempts configured
1096 if ( isset( $login['max_attempts'] ) && $login['max_attempts'] <= 5 ) {
1097 $score += 3;
1098 }
1099 // XML-RPC blocked, whole or its pingback methods
1100 if ( $this->is_xmlrpc_protected( $options ) ) {
1101 $score += 3;
1102 }
1103 // 2FA enabled
1104 if ( ! empty( $login['two_factor']['enabled'] ) ) {
1105 $score += 4;
1106 }
1107 }
1108
1109 // Security headers details (10 points)
1110 $max_score += 10;
1111 if ( ! empty( $options['modules']['security_headers'] ) ) {
1112 $headers = $options['security_headers'] ?? array();
1113
1114 if ( ! empty( $headers['x_frame_options'] ) ) {
1115 $score += 2;
1116 }
1117 if ( ! empty( $headers['x_content_type_options'] ) ) {
1118 $score += 2;
1119 }
1120 if ( ! empty( $headers['hsts']['enabled'] ) ) {
1121 $score += 3;
1122 }
1123 if ( ! empty( $headers['csp']['enabled'] ) ) {
1124 $score += 3;
1125 }
1126 }
1127
1128 // User security details (10 points)
1129 $max_score += 10;
1130 if ( ! empty( $options['modules']['user_security'] ) ) {
1131 $user = $options['user_security'] ?? array();
1132
1133 if ( ! empty( $user['block_insecure_usernames'] ) ) {
1134 $score += 3;
1135 }
1136 if ( ! empty( $user['force_strong_passwords'] ) ) {
1137 $score += 3;
1138 }
1139 if ( ! empty( $user['password_expiration']['enabled'] ) ) {
1140 $score += 2;
1141 }
1142 if ( ! empty( $user['email_verification']['enabled'] ) ) {
1143 $score += 2;
1144 }
1145 }
1146
1147 // Audit alerts details (6 points) - earned only when Security Audit is
1148 // on, because the alerting layer rides on top of the activity log.
1149 // Leaving both alert legs off keeps these points unearned.
1150 $max_score += 6;
1151 if ( ! empty( $options['modules']['activity_log'] ) ) {
1152 $alerts = isset( $options['audit_alerts'] ) ? (array) $options['audit_alerts'] : array();
1153 if ( Vigilante_Audit_Alerts::immediate_is_active( $alerts ) ) {
1154 $score += 3;
1155 }
1156 if ( Vigilante_Audit_Alerts::threshold_is_active( $alerts ) ) {
1157 $score += 3;
1158 }
1159 }
1160
1161 /*
1162 * Self-protection (15 points). Having it on is configuration, which is
1163 * what this card measures; the state of the last check caps the card,
1164 * because a plugin whose own files were changed is not well configured
1165 * in any useful sense, whatever the rest of the settings say. The
1166 * environment block below already mixes measured state into this score
1167 * (WP_DEBUG, insecure usernames), so the card keeps its meaning.
1168 */
1169 $max_score += 15;
1170 $self_enabled = Vigilante_Self_Integrity::is_on();
1171 $self_tone = Vigilante_Self_Integrity::tone( Vigilante_Self_Integrity::display_state(), $self_enabled );
1172 if ( $self_enabled ) {
1173 $score += 10;
1174 // The five points are for a check that ran and came out clean: a
1175 // site that has never checked itself has not earned them. Tasks
1176 // that Vigilant had to schedule again, while that is only a
1177 // warning, do not take them away: the files are as clean as they
1178 // were (Vigilante_Self_Integrity::score_tone()).
1179 if ( in_array( Vigilante_Self_Integrity::score_tone( Vigilante_Self_Integrity::display_state(), $self_enabled ), array( 'ok', 'info' ), true ) ) {
1180 $score += 5;
1181 }
1182 }
1183
1184 // Environment checks (8 points) - penalize insecure server configuration
1185 $max_score += 8;
1186 $env_score = 8;
1187
1188 // WP_DEBUG active in production is a security risk (exposes paths, errors)
1189 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
1190 $env_score -= 5;
1191 }
1192
1193 // Accounts with insecure usernames (targeted by brute force attacks)
1194 $insecure_admins = $this->get_insecure_admin_usernames();
1195 if ( ! empty( $insecure_admins ) ) {
1196 $env_score -= 3;
1197 }
1198
1199 $score += max( 0, $env_score );
1200
1201 $percent = $max_score > 0 ? (int) round( ( $score / $max_score ) * 100 ) : 0;
1202 if ( in_array( $self_tone, array( 'critical', 'off' ), true ) ) {
1203 // Same cap and same reason as the Security Check score.
1204 $percent = min( $percent, Vigilante_Security_Analyzer::SCORE_CAP_ON_TAMPER );
1205 }
1206 return $percent;
1207 }
1208
1209 /**
1210 * Recommendation of the Dashboard for a critical self-protection state
1211 *
1212 * @param string $kind What the alarm is about, from Vigilante_Self_Integrity::alarm_kind().
1213 * @return string
1214 */
1215 private function self_critical_recommendation( $kind ) {
1216 if ( 'tasks' === $kind ) {
1217 return __( 'Something keeps removing Vigilant scheduled tasks: File Integrity says what to look for.', 'vigilante' );
1218 }
1219 if ( 'hooks' === $kind ) {
1220 return __( 'Something removed the hooks of Vigilant self-protection: File Integrity says what to look for.', 'vigilante' );
1221 }
1222 return __( 'Vigilant own files have been changed: repair Vigilant from File Integrity before anything else.', 'vigilante' );
1223 }
1224
1225 /**
1226 * Get security recommendations based on current settings
1227 *
1228 * @param array $options Plugin options.
1229 * @return array Array of recommendations.
1230 */
1231 private function get_security_recommendations( $options ) {
1232 $recommendations = array();
1233
1234 // Self-protection first: if Vigilant itself cannot be trusted, nothing
1235 // else on this card means much.
1236 $vg_self_enabled = Vigilante_Self_Integrity::is_on();
1237 $vg_self_tone = Vigilante_Self_Integrity::tone( Vigilante_Self_Integrity::display_state(), $vg_self_enabled );
1238 // Switched off has no setting to turn back on: it takes code on the
1239 // site, and the recommendation says where to look for it.
1240 if ( 'off' === $vg_self_tone ) {
1241 $recommendations[] = array(
1242 'icon' => 'warning',
1243 'priority' => 'critical',
1244 'tab' => 'file-integrity',
1245 'message' => __( 'Something on this site switched Vigilant self-protection off: File Integrity says which file does it.', 'vigilante' ),
1246 );
1247 } elseif ( 'critical' === $vg_self_tone ) {
1248 $recommendations[] = array(
1249 'icon' => 'warning',
1250 'priority' => 'critical',
1251 'tab' => 'file-integrity',
1252 'message' => $this->self_critical_recommendation( Vigilante_Self_Integrity::alarm_kind( Vigilante_Self_Integrity::display_state(), $vg_self_enabled ) ),
1253 );
1254 } elseif ( 'warning' === $vg_self_tone ) {
1255 $recommendations[] = array(
1256 'icon' => 'shield',
1257 'priority' => 'high',
1258 'tab' => 'file-integrity',
1259 'message' => __( 'Vigilant self-protection needs your attention: File Integrity says what it found and what to do.', 'vigilante' ),
1260 );
1261 } elseif ( 'none' === $vg_self_tone ) {
1262 // The score holds back the points of a check that has not run yet,
1263 // so the card has to say why instead of just showing a lower number.
1264 $recommendations[] = array(
1265 'icon' => 'shield',
1266 'priority' => 'high',
1267 'tab' => 'file-integrity',
1268 'message' => __( 'Vigilant has not checked its own files yet: run a scan from File Integrity.', 'vigilante' ),
1269 );
1270 }
1271
1272 // Critical: Firewall disabled
1273 if ( empty( $options['modules']['firewall'] ) ) {
1274 $recommendations[] = array(
1275 'icon' => 'warning',
1276 'priority' => 'critical',
1277 'message' => __( 'Enable Firewall to protect against common attacks.', 'vigilante' ),
1278 );
1279 }
1280
1281 // Critical: Login security disabled
1282 if ( empty( $options['modules']['login_security'] ) ) {
1283 $recommendations[] = array(
1284 'icon' => 'warning',
1285 'priority' => 'critical',
1286 'message' => __( 'Enable Login Security to prevent brute force attacks.', 'vigilante' ),
1287 );
1288 }
1289
1290 // High: Security headers disabled
1291 if ( empty( $options['modules']['security_headers'] ) ) {
1292 $recommendations[] = array(
1293 'icon' => 'admin-generic',
1294 'priority' => 'high',
1295 'message' => __( 'Enable Security Headers to protect against clickjacking and XSS.', 'vigilante' ),
1296 );
1297 }
1298
1299 // High: User security disabled
1300 if ( empty( $options['modules']['user_security'] ) ) {
1301 $recommendations[] = array(
1302 'icon' => 'admin-users',
1303 'priority' => 'high',
1304 'message' => __( 'Enable User Security to enforce password policies and username protection.', 'vigilante' ),
1305 );
1306 }
1307
1308 // High: 2FA not enabled (only if login security is active)
1309 $login = $options['login_security'] ?? array();
1310 if ( ! empty( $options['modules']['login_security'] ) && empty( $login['two_factor']['enabled'] ) ) {
1311 $recommendations[] = array(
1312 'icon' => 'shield',
1313 'priority' => 'high',
1314 'message' => __( 'Enable Two-Factor Authentication for enhanced login security.', 'vigilante' ),
1315 'tab' => 'login',
1316 );
1317 }
1318
1319 // Medium: REST API security disabled
1320 if ( empty( $options['modules']['rest_api_security'] ) ) {
1321 $recommendations[] = array(
1322 'icon' => 'rest-api',
1323 'priority' => 'medium',
1324 'message' => __( 'Enable REST API Security to control API access and prevent enumeration.', 'vigilante' ),
1325 );
1326 }
1327
1328 // Medium: WP Hardening disabled
1329 if ( empty( $options['modules']['wp_hardening'] ) ) {
1330 $recommendations[] = array(
1331 'icon' => 'lock',
1332 'priority' => 'medium',
1333 'message' => __( 'Enable WP Hardening to remove version info and protect core files.', 'vigilante' ),
1334 );
1335 }
1336
1337 // Medium: File integrity disabled
1338 if ( empty( $options['modules']['file_integrity'] ) ) {
1339 $recommendations[] = array(
1340 'icon' => 'media-text',
1341 'priority' => 'medium',
1342 'message' => __( 'Enable File Integrity to detect unauthorized file changes.', 'vigilante' ),
1343 );
1344 }
1345
1346 // Medium: Security Audit disabled
1347 if ( empty( $options['modules']['activity_log'] ) ) {
1348 $recommendations[] = array(
1349 'icon' => 'list-view',
1350 'priority' => 'medium',
1351 'message' => __( 'Enable Security Audit to track security events.', 'vigilante' ),
1352 );
1353 }
1354
1355 // Low: XML-RPC enabled (only if login security is active)
1356 if ( ! empty( $options['modules']['login_security'] ) && ! $this->is_xmlrpc_protected( $options ) ) {
1357 $recommendations[] = array(
1358 'icon' => 'info',
1359 'priority' => 'low',
1360 'message' => __( 'Disable XML-RPC if not needed (reduces attack surface).', 'vigilante' ),
1361 'tab' => 'wp-hardening',
1362 );
1363 }
1364
1365 // Low: Strong passwords not enforced (only if user security is active)
1366 $user = $options['user_security'] ?? array();
1367 if ( ! empty( $options['modules']['user_security'] ) && empty( $user['force_strong_passwords'] ) ) {
1368 $recommendations[] = array(
1369 'icon' => 'admin-users',
1370 'priority' => 'low',
1371 'message' => __( 'Enforce strong passwords for all users.', 'vigilante' ),
1372 'tab' => 'users',
1373 );
1374 }
1375
1376 // High: WP_DEBUG active in production (regardless of Vigilante settings)
1377 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
1378 $recommendations[] = array(
1379 'icon' => 'warning',
1380 'priority' => 'high',
1381 'message' => __( 'WP_DEBUG is active. Debug mode exposes sensitive information and should be disabled in production.', 'vigilante' ),
1382 'tab' => 'wp-hardening',
1383 );
1384 }
1385
1386 // Low: Users with display name matching login username (only if user security active)
1387 if ( ! empty( $options['modules']['user_security'] ) ) {
1388 $exposed_users = $this->get_users_with_exposed_login();
1389 if ( ! empty( $exposed_users ) ) {
1390 $recommendations[] = array(
1391 'icon' => 'admin-users',
1392 'priority' => 'low',
1393 'message' => sprintf(
1394 /* translators: %s: Comma-separated list of usernames */
1395 __( 'These users have their login username as display name (publicly visible): %s', 'vigilante' ),
1396 implode( ', ', $exposed_users )
1397 ),
1398 'tab' => 'users',
1399 );
1400 }
1401 }
1402
1403 // High: Accounts with insecure usernames (regardless of module status)
1404 $insecure_admins = $this->get_insecure_admin_usernames();
1405 if ( ! empty( $insecure_admins ) ) {
1406 $recommendations[] = array(
1407 'icon' => 'warning',
1408 'priority' => 'high',
1409 'message' => sprintf(
1410 /* translators: %s: Comma-separated list of usernames */
1411 __( 'Insecure usernames detected: %s. These are commonly targeted in brute force attacks. Create new accounts with unique usernames and remove these.', 'vigilante' ),
1412 implode( ', ', $insecure_admins )
1413 ),
1414 );
1415 }
1416
1417 // Critical: Closed or removed plugins detected by the daily check.
1418 // Reads from the cached state map populated by Vigilante_Plugin_Status, so
1419 // there is no extra HTTP call here. Ignored slugs are filtered out so the
1420 // recommendation respects the user's per-slug Ignore decisions.
1421 if ( ! empty( $options['modules']['file_integrity'] ) && ! empty( $options['file_integrity']['check_closed_plugins'] ) ) {
1422 if ( ! class_exists( 'Vigilante_Plugin_Status' ) ) {
1423 require_once VIGILANTE_INCLUDES_DIR . 'class-plugin-status.php';
1424 }
1425 $closed_checker = new Vigilante_Plugin_Status( $this->settings, $this->activity_log );
1426 $closed_active = $closed_checker->get_closed_plugins();
1427 if ( ! empty( $closed_active ) ) {
1428 $names = array();
1429 foreach ( $closed_active as $slug => $entry ) {
1430 $names[] = isset( $entry['name'] ) ? $entry['name'] : $slug;
1431 }
1432 $recommendations[] = array(
1433 'icon' => 'warning',
1434 'priority' => 'critical',
1435 'message' => sprintf(
1436 /* translators: 1: count, 2: comma-separated plugin names */
1437 _n(
1438 '%1$d closed plugin detected on this site: %2$s. Closures in WordPress.org usually indicate malware, security issues or supply chain compromises. Uninstall and replace as soon as possible.',
1439 '%1$d closed plugins detected on this site: %2$s. Closures in WordPress.org usually indicate malware, security issues or supply chain compromises. Uninstall and replace as soon as possible.',
1440 count( $closed_active ),
1441 'vigilante'
1442 ),
1443 count( $closed_active ),
1444 implode( ', ', $names )
1445 ),
1446 'tab' => 'file-integrity',
1447 );
1448 }
1449 }
1450
1451 // Medium: Security Audit is on but no audit alert is configured. The
1452 // alerting layer only makes sense while the activity log is running.
1453 if ( ! empty( $options['modules']['activity_log'] ) ) {
1454 $alerts = isset( $options['audit_alerts'] ) ? (array) $options['audit_alerts'] : array();
1455 if ( ! Vigilante_Audit_Alerts::has_active_alerts( $alerts ) ) {
1456 $recommendations[] = array(
1457 'icon' => 'email-alt',
1458 'priority' => 'medium',
1459 'message' => __( 'Set up Audit Alerts to get an email when something important happens (a new admin, a closed plugin, or an attack in progress).', 'vigilante' ),
1460 'tab' => 'activity-log',
1461 );
1462 }
1463 }
1464
1465 // Sort by priority
1466 $priority_order = array( 'critical' => 0, 'high' => 1, 'medium' => 2, 'low' => 3 );
1467 usort( $recommendations, function( $a, $b ) use ( $priority_order ) {
1468 return ( $priority_order[ $a['priority'] ] ?? 99 ) - ( $priority_order[ $b['priority'] ] ?? 99 );
1469 } );
1470
1471 return $recommendations;
1472 }
1473
1474 /**
1475 * Get users whose display name matches their login username
1476 *
1477 * Limited to administrators and editors for performance and relevance.
1478 * Cached with transient to avoid repeated queries on every dashboard load.
1479 *
1480 * @return array Array of usernames with exposed login.
1481 */
1482 private function get_users_with_exposed_login() {
1483 $cache_key = 'vigilante_exposed_display_names';
1484 $cached = get_transient( $cache_key );
1485
1486 if ( false !== $cached ) {
1487 return $cached;
1488 }
1489
1490 $exposed = array();
1491 $users = get_users( array(
1492 'role__in' => array( 'administrator', 'editor' ),
1493 'fields' => array( 'ID', 'user_login', 'display_name' ),
1494 ) );
1495
1496 foreach ( $users as $user ) {
1497 if ( strcasecmp( $user->display_name, $user->user_login ) === 0 ) {
1498 $exposed[] = $user->user_login;
1499 }
1500 }
1501
1502 // Cache for 12 hours
1503 set_transient( $cache_key, $exposed, 12 * HOUR_IN_SECONDS );
1504
1505 return $exposed;
1506 }
1507
1508 /**
1509 * Get accounts with insecure usernames
1510 *
1511 * Checks for common default usernames that are targeted by brute force attacks.
1512 * Detects any user regardless of role (consistent with username creation blocking).
1513 * Uses WordPress object cache via get_user_by() so no transient needed.
1514 *
1515 * @return array Array of insecure usernames found.
1516 */
1517 private function get_insecure_admin_usernames() {
1518 $priority_usernames = array( 'admin', 'administrator', 'root', 'test', 'user', 'guest', 'info', 'sysadmin', 'webmaster' );
1519 $found = array();
1520
1521 foreach ( $priority_usernames as $username ) {
1522 $user = get_user_by( 'login', $username );
1523 if ( $user ) {
1524 $found[] = $username;
1525 }
1526 }
1527
1528 return $found;
1529 }
1530
1531 /**
1532 * Get security status for menu badge
1533 *
1534 * Returns count of disabled modules and whether there are critical issues.
1535 * Critical = Firewall or Login Security disabled.
1536 *
1537 * @return array Array with 'count' and 'has_critical'.
1538 */
1539 public function get_security_status_for_badge() {
1540 $options = $this->settings->get_all_options();
1541 $modules = $options['modules'] ?? array();
1542
1543 // Count disabled modules
1544 $disabled_count = 0;
1545 $has_critical = false;
1546
1547 // Critical modules - if disabled, badge is red
1548 $critical_modules = array( 'firewall', 'login_security' );
1549
1550 foreach ( $modules as $module => $enabled ) {
1551 // Handle both boolean and string values ('1', '0', true, false)
1552 $is_enabled = filter_var( $enabled, FILTER_VALIDATE_BOOLEAN );
1553
1554 if ( ! $is_enabled ) {
1555 $disabled_count++;
1556
1557 // Check if this is a critical module
1558 if ( in_array( $module, $critical_modules, true ) ) {
1559 $has_critical = true;
1560 }
1561 }
1562 }
1563
1564 return array(
1565 'count' => $disabled_count,
1566 'has_critical' => $has_critical,
1567 );
1568 }
1569
1570 /**
1571 * Get count of security issues for menu badge (deprecated, use get_security_status_for_badge)
1572 *
1573 * @return int Count of critical/high issues.
1574 */
1575 public function get_security_issues_count() {
1576 $status = $this->get_security_status_for_badge();
1577 return $status['count'];
1578 }
1579
1580 /**
1581 * Register settings
1582 */
1583 public function register_settings() {
1584 register_setting(
1585 'vigilante_options',
1586 Vigilante_Settings::OPTION_NAME,
1587 array( $this->settings, 'validate_options' )
1588 );
1589 }
1590
1591 /**
1592 * Index behind the settings search box.
1593 *
1594 * Each entry points at one settings row. The search matches on the label,
1595 * on its English original and on 'keywords', which are extra terms someone
1596 * might type instead of the label itself.
1597 *
1598 * Those keywords are wrapped in _x() with the context "settings search
1599 * keywords" so every locale can supply its own: the source strings are in
1600 * English, and a Spanish user typing "contrasena" or a German one typing
1601 * "Kennwort" only reaches the password settings if that locale translated
1602 * them. Translators can add, drop or replace terms freely, one per space;
1603 * they are never displayed, only matched against what the user types.
1604 *
1605 * The list is maintained by hand, so a new settings row needs an entry here
1606 * or it cannot be found. It had drifted to 68 of 131 rows before 2.9.7.
1607 *
1608 * @return array
1609 */
1610 private function get_search_index() {
1611 return array(
1612 // Firewall - Main
1613 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Firewall Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-main', 'label' => __( 'Block bad bots', 'vigilante' ), 'label_en' => 'Block bad bots', 'keywords' => _x( 'block bad bots blocking blocked deny malicious harmful bot crawler crawlers spider scraper robots', 'settings search keywords', 'vigilante' ) ),
1614 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Firewall Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-main', 'label' => __( 'Block malicious requests', 'vigilante' ), 'label_en' => 'Block malicious requests', 'keywords' => _x( 'block malicious requests blocking blocked deny attack attacks exploit injection sqli xss rfi lfi request traffic', 'settings search keywords', 'vigilante' ) ),
1615 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Firewall Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-main', 'label' => __( 'Rate limiting', 'vigilante' ), 'label_en' => 'Rate limiting', 'keywords' => _x( 'rate limiting throttle flood burst limit limits', 'settings search keywords', 'vigilante' ) ),
1616 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Firewall Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-main', 'label' => __( 'Brute force protection', 'vigilante' ), 'label_en' => 'Brute force protection', 'keywords' => _x( 'brute force protection bruteforce login', 'settings search keywords', 'vigilante' ) ),
1617 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Firewall Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-main', 'label' => __( 'IP Whitelist', 'vigilante' ), 'label_en' => 'IP Whitelist', 'keywords' => _x( 'ip whitelist ips address addresses cidr ipv4 ipv6 allowlist allowed trusted', 'settings search keywords', 'vigilante' ) ),
1618 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Firewall Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-main', 'label' => __( 'IP Blacklist', 'vigilante' ), 'label_en' => 'IP Blacklist', 'keywords' => _x( 'ip blacklist ips address addresses cidr ipv4 ipv6 blocklist denylist banned', 'settings search keywords', 'vigilante' ) ),
1619 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Firewall Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-main', 'label' => __( 'User-Agent Whitelist', 'vigilante' ), 'label_en' => 'User-Agent Whitelist', 'keywords' => _x( 'user-agent whitelist ua useragent browser allowlist allowed trusted user', 'settings search keywords', 'vigilante' ) ),
1620 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Firewall Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-main', 'label' => __( 'User-Agent Blacklist', 'vigilante' ), 'label_en' => 'User-Agent Blacklist', 'keywords' => _x( 'user-agent blacklist ua useragent browser blocklist denylist banned user', 'settings search keywords', 'vigilante' ) ),
1621 // Firewall - Server Protection
1622 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Server Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-server', 'label' => __( 'Directory Browsing', 'vigilante' ), 'label_en' => 'Directory Browsing', 'keywords' => _x( 'directory browsing folder folders listing indexing index', 'settings search keywords', 'vigilante' ) ),
1623 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Server Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-server', 'label' => __( 'Protect wp-config.php', 'vigilante' ), 'label_en' => 'Protect wp-config.php', 'keywords' => _x( 'protect wp-config php protection secure lock', 'settings search keywords', 'vigilante' ) ),
1624 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Server Protection', 'vigilante' ), 'anchor' => 'field-protect-wp-cron', 'label' => __( 'Protect wp-cron.php', 'vigilante' ), 'label_en' => 'Protect wp-cron.php', 'keywords' => _x( 'protect wp-cron php protection secure lock cron scheduled tasks block spam', 'settings search keywords', 'vigilante' ) ),
1625 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Server Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-server', 'label' => __( 'Protect wp-includes', 'vigilante' ), 'label_en' => 'Protect wp-includes', 'keywords' => _x( 'protect wp-includes protection secure lock', 'settings search keywords', 'vigilante' ) ),
1626 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Server Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-server', 'label' => __( 'PHP in Uploads', 'vigilante' ), 'label_en' => 'PHP in Uploads', 'keywords' => _x( 'php in uploads media upload', 'settings search keywords', 'vigilante' ) ),
1627 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Server Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-server', 'label' => __( 'Sensitive Files', 'vigilante' ), 'label_en' => 'Sensitive Files', 'keywords' => _x( 'sensitive files private secret file log', 'settings search keywords', 'vigilante' ) ),
1628 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Server Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-server', 'label' => __( 'Limit HTTP Methods', 'vigilante' ), 'label_en' => 'Limit HTTP Methods', 'keywords' => _x( 'limit http methods', 'settings search keywords', 'vigilante' ) ),
1629 // Security Headers
1630 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Security Headers', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'X-Frame-Options', 'vigilante' ), 'label_en' => 'X-Frame-Options', 'keywords' => _x( 'x-frame-options headers', 'settings search keywords', 'vigilante' ) ),
1631 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Security Headers', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'X-Content-Type-Options', 'vigilante' ), 'label_en' => 'X-Content-Type-Options', 'keywords' => _x( 'x-content-type-options content headers', 'settings search keywords', 'vigilante' ) ),
1632 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Security Headers', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Referrer-Policy', 'vigilante' ), 'label_en' => 'Referrer-Policy', 'keywords' => _x( 'referrer-policy headers', 'settings search keywords', 'vigilante' ) ),
1633 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'HSTS', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'HSTS', 'vigilante' ), 'label_en' => 'HSTS', 'keywords' => _x( 'hsts strict transport security ssl tls https headers', 'settings search keywords', 'vigilante' ) ),
1634 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Content Security Policy', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Content Security Policy', 'vigilante' ), 'label_en' => 'Content Security Policy', 'keywords' => _x( 'content security policy csp xss headers', 'settings search keywords', 'vigilante' ) ),
1635 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Server Identity', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Server Signature', 'vigilante' ), 'label_en' => 'Server Signature', 'keywords' => _x( 'server signature fingerprint banner', 'settings search keywords', 'vigilante' ) ),
1636 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Server Identity', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Remove Fingerprinting Headers', 'vigilante' ), 'label_en' => 'Remove Fingerprinting Headers', 'keywords' => _x( 'remove fingerprinting headers fingerprint banner header http', 'settings search keywords', 'vigilante' ) ),
1637 // Security Headers - Cross-Origin Policies
1638 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Cross-Origin Policies', 'vigilante' ), 'anchor' => 'vigilante-section-headers-cross-origin', 'label' => __( 'Cross-Origin-Opener-Policy (COOP)', 'vigilante' ), 'label_en' => 'Cross-Origin-Opener-Policy (COOP)', 'keywords' => _x( 'coop cross-origin opener policy popup popups window opener tag assistant google isolation browsing context headers', 'settings search keywords', 'vigilante' ) ),
1639 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Cross-Origin Policies', 'vigilante' ), 'anchor' => 'vigilante-section-headers-cross-origin', 'label' => __( 'Cross-Origin-Embedder-Policy (COEP)', 'vigilante' ), 'label_en' => 'Cross-Origin-Embedder-Policy (COEP)', 'keywords' => _x( 'coep cross-origin embedder policy require-corp credentialless embed embeds iframe fonts headers', 'settings search keywords', 'vigilante' ) ),
1640 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Cross-Origin Policies', 'vigilante' ), 'anchor' => 'vigilante-section-headers-cross-origin', 'label' => __( 'Cross-Origin-Resource-Policy (CORP)', 'vigilante' ), 'label_en' => 'Cross-Origin-Resource-Policy (CORP)', 'keywords' => _x( 'corp cross-origin resource policy hotlink hotlinking cdn images assets headers', 'settings search keywords', 'vigilante' ) ),
1641 // Login Security
1642 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Custom login URL', 'vigilante' ), 'label_en' => 'Custom login URL', 'keywords' => _x( 'custom login url signin log-in access slug', 'settings search keywords', 'vigilante' ) ),
1643 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Two-Factor Authentication', 'vigilante' ), 'label_en' => 'Two-Factor Authentication', 'keywords' => _x( 'two-factor authentication 2fa mfa otp totp authenticator', 'settings search keywords', 'vigilante' ) ),
1644 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( '2FA', 'vigilante' ), 'label_en' => '2FA', 'keywords' => _x( '2fa two-factor mfa otp totp authenticator', 'settings search keywords', 'vigilante' ) ),
1645 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Failed login attempts', 'vigilante' ), 'label_en' => 'Failed login attempts', 'keywords' => _x( 'failed login attempts signin log-in access tries retries', 'settings search keywords', 'vigilante' ) ),
1646 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Lockout', 'vigilante' ), 'label_en' => 'Lockout', 'keywords' => _x( 'lockout lock ban block login', 'settings search keywords', 'vigilante' ) ),
1647 // REST API
1648 array( 'tab' => 'rest-api', 'tab_label' => __( 'REST API', 'vigilante' ), 'section' => __( 'REST API Security', 'vigilante' ), 'anchor' => 'vigilante-section-rest-api-main', 'label' => __( 'Access Mode', 'vigilante' ), 'label_en' => 'Access Mode', 'keywords' => _x( 'access mode rest api', 'settings search keywords', 'vigilante' ) ),
1649 array( 'tab' => 'rest-api', 'tab_label' => __( 'REST API', 'vigilante' ), 'section' => __( 'REST API Security', 'vigilante' ), 'anchor' => 'vigilante-section-rest-api-main', 'label' => __( 'Block User Enumeration', 'vigilante' ), 'label_en' => 'Block User Enumeration', 'keywords' => _x( 'block user enumeration blocking blocked deny users account author slug', 'settings search keywords', 'vigilante' ) ),
1650 array( 'tab' => 'rest-api', 'tab_label' => __( 'REST API', 'vigilante' ), 'section' => __( 'REST API Security', 'vigilante' ), 'anchor' => 'vigilante-section-rest-api-main', 'label' => __( 'Disable JSONP', 'vigilante' ), 'label_en' => 'Disable JSONP', 'keywords' => _x( 'disable jsonp', 'settings search keywords', 'vigilante' ) ),
1651 // User Security
1652 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Username & password protection', 'vigilante' ), 'anchor' => 'vigilante-section-users-password', 'label' => __( 'Username protection', 'vigilante' ), 'label_en' => 'Username protection', 'keywords' => _x( 'username protection admin', 'settings search keywords', 'vigilante' ) ),
1653 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Username & password protection', 'vigilante' ), 'anchor' => 'vigilante-section-users-password', 'label' => __( 'Password strength', 'vigilante' ), 'label_en' => 'Password strength', 'keywords' => _x( 'password strength passwords credentials', 'settings search keywords', 'vigilante' ) ),
1654 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Admin monitoring', 'vigilante' ), 'anchor' => 'vigilante-section-users-admin-monitoring', 'label' => __( 'Admin monitoring', 'vigilante' ), 'label_en' => 'Admin monitoring', 'keywords' => _x( 'admin monitoring administrator administrators', 'settings search keywords', 'vigilante' ) ),
1655 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Registration approval', 'vigilante' ), 'anchor' => 'vigilante-section-users-registration', 'label' => __( 'Registration approval', 'vigilante' ), 'label_en' => 'Registration approval', 'keywords' => _x( 'registration approval signup register approve moderate', 'settings search keywords', 'vigilante' ) ),
1656 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Session limits', 'vigilante' ), 'anchor' => 'vigilante-section-users-sessions', 'label' => __( 'Session limits', 'vigilante' ), 'label_en' => 'Session limits', 'keywords' => _x( 'session limits sessions concurrent', 'settings search keywords', 'vigilante' ) ),
1657 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Password expiration', 'vigilante' ), 'anchor' => 'vigilante-section-users-password-exp', 'label' => __( 'Password expiration', 'vigilante' ), 'label_en' => 'Password expiration', 'keywords' => _x( 'password expiration passwords credentials expiry expire caducity', 'settings search keywords', 'vigilante' ) ),
1658 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Email verification', 'vigilante' ), 'anchor' => 'vigilante-section-users-email-verify', 'label' => __( 'Email verification', 'vigilante' ), 'label_en' => 'Email verification', 'keywords' => _x( 'email verification mail notification notify verify confirm', 'settings search keywords', 'vigilante' ) ),
1659 // WP Hardening
1660 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'Database Hardening', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-database', 'label' => __( 'Database Hardening', 'vigilante' ), 'label_en' => 'Database Hardening', 'keywords' => _x( 'database hardening db mysql tables', 'settings search keywords', 'vigilante' ) ),
1661 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'Database Hardening', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-database', 'label' => __( 'Database prefix', 'vigilante' ), 'label_en' => 'Database prefix', 'keywords' => _x( 'database prefix db mysql tables table', 'settings search keywords', 'vigilante' ) ),
1662 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'wp-config.php Security', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-wpconfig', 'label' => __( 'Disable file editing', 'vigilante' ), 'label_en' => 'Disable file editing', 'keywords' => _x( 'disable file editing files editor edit', 'settings search keywords', 'vigilante' ) ),
1663 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'wp-config.php Security', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-wpconfig', 'label' => __( 'Disable plugin/theme installation', 'vigilante' ), 'label_en' => 'Disable plugin/theme installation', 'keywords' => _x( 'disable plugin theme installation install', 'settings search keywords', 'vigilante' ) ),
1664 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'wp-config.php Security', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-wpconfig', 'label' => __( 'Force SSL admin', 'vigilante' ), 'label_en' => 'Force SSL admin', 'keywords' => _x( 'force ssl admin bruteforce administrator administrators tls https', 'settings search keywords', 'vigilante' ) ),
1665 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'wp-config.php Security', 'vigilante' ), 'anchor' => 'field-disable-wp-cron', 'label' => __( 'Disable WP Cron', 'vigilante' ), 'label_en' => 'Disable WP Cron', 'keywords' => _x( 'disable wp cron scheduled tasks wp-cron', 'settings search keywords', 'vigilante' ) ),
1666 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'Comment Security', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-comments', 'label' => __( 'Comment Security', 'vigilante' ), 'label_en' => 'Comment Security', 'keywords' => _x( 'comment security comments spam honeypot url', 'settings search keywords', 'vigilante' ) ),
1667 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'Header Cleanup', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-headers', 'label' => __( 'Header Cleanup', 'vigilante' ), 'label_en' => 'Header Cleanup', 'keywords' => _x( 'header cleanup headers http meta generator rsd wlwmanifest', 'settings search keywords', 'vigilante' ) ),
1668 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'Header Cleanup', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-headers', 'label' => __( 'Remove WordPress version', 'vigilante' ), 'label_en' => 'Remove WordPress version', 'keywords' => _x( 'remove wordpress version generator meta', 'settings search keywords', 'vigilante' ) ),
1669 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'Header Cleanup', 'vigilante' ), 'anchor' => 'field-remove-wp-version-assets', 'label' => __( 'Remove version from assets', 'vigilante' ), 'label_en' => 'Remove version from assets', 'keywords' => _x( 'remove version from assets', 'settings search keywords', 'vigilante' ) ),
1670 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'Header Cleanup', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-xmlrpc', 'label' => __( 'Disable XML-RPC', 'vigilante' ), 'label_en' => 'Disable XML-RPC', 'keywords' => _x( 'disable xml-rpc xmlrpc rpc remote jetpack app pingback trackback', 'settings search keywords', 'vigilante' ) ),
1671 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'RSS Feed Settings', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-rss', 'label' => __( 'RSS Feed Settings', 'vigilante' ), 'label_en' => 'RSS Feed Settings', 'keywords' => _x( 'rss feed settings feeds atom', 'settings search keywords', 'vigilante' ) ),
1672 // File Integrity
1673 array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'File Integrity Monitoring', 'vigilante' ), 'anchor' => 'vigilante-section-fi-monitoring', 'label' => __( 'File Integrity Monitoring', 'vigilante' ), 'label_en' => 'File Integrity Monitoring', 'keywords' => _x( 'file integrity monitoring files checksum checksums tamper', 'settings search keywords', 'vigilante' ) ),
1674 array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'File Integrity Monitoring', 'vigilante' ), 'anchor' => 'vigilante-section-fi-monitoring', 'label' => __( 'Scan schedule', 'vigilante' ), 'label_en' => 'Scan schedule', 'keywords' => _x( 'scan schedule scans scanning check cron', 'settings search keywords', 'vigilante' ) ),
1675 array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'File Integrity Monitoring', 'vigilante' ), 'anchor' => 'vigilante-section-fi-monitoring', 'label' => __( 'Instant alert', 'vigilante' ), 'label_en' => 'Instant alert', 'keywords' => _x( 'instant alert alerts notification warning email', 'settings search keywords', 'vigilante' ) ),
1676 array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'Vigilant self-protection', 'vigilante' ), 'anchor' => 'vigilante-section-fi-self', 'label' => __( 'Vigilant self-protection', 'vigilante' ), 'label_en' => 'Vigilant self-protection', 'keywords' => _x( 'self protection selfprotection self-check autoproteccion manifest sha256 checksums tampering tampered repair reinstall own files guardian integrity of the plugin', 'settings search keywords', 'vigilante' ) ),
1677 array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'Ignored Files', 'vigilante' ), 'anchor' => 'vigilante-section-fi-ignored', 'label' => __( 'Ignored Files', 'vigilante' ), 'label_en' => 'Ignored Files', 'keywords' => _x( 'ignored files file exclude', 'settings search keywords', 'vigilante' ) ),
1678 // Security Audit
1679 array( 'tab' => 'activity-log', 'tab_label' => __( 'Security Audit', 'vigilante' ), 'section' => __( 'Security Audit Settings', 'vigilante' ), 'anchor' => 'vigilante-section-audit-settings', 'label' => __( 'Retention', 'vigilante' ), 'label_en' => 'Retention', 'keywords' => _x( 'retention keep days storage log', 'settings search keywords', 'vigilante' ) ),
1680 array( 'tab' => 'activity-log', 'tab_label' => __( 'Security Audit', 'vigilante' ), 'section' => __( 'Security Audit Settings', 'vigilante' ), 'anchor' => 'vigilante-section-audit-settings', 'label' => __( 'Events to Log', 'vigilante' ), 'label_en' => 'Events to Log', 'keywords' => _x( 'events to log', 'settings search keywords', 'vigilante' ) ),
1681 array( 'tab' => 'activity-log', 'tab_label' => __( 'Security Audit', 'vigilante' ), 'section' => __( 'Security Audit Settings', 'vigilante' ), 'anchor' => 'vigilante-section-audit-settings', 'label' => __( 'Option Tracking', 'vigilante' ), 'label_en' => 'Option Tracking', 'keywords' => _x( 'option tracking', 'settings search keywords', 'vigilante' ) ),
1682 array( 'tab' => 'activity-log', 'tab_label' => __( 'Security Audit', 'vigilante' ), 'section' => __( 'Security Audit Settings', 'vigilante' ), 'anchor' => 'vigilante-section-audit-settings', 'label' => __( 'Exclusions', 'vigilante' ), 'label_en' => 'Exclusions', 'keywords' => _x( 'exclusions roles ip', 'settings search keywords', 'vigilante' ) ),
1683 array( 'tab' => 'activity-log', 'tab_label' => __( 'Security Audit', 'vigilante' ), 'section' => __( 'Audit Alerts', 'vigilante' ), 'anchor' => 'vigilante-section-audit-alerts', 'label' => __( 'Audit Alerts', 'vigilante' ), 'label_en' => 'Audit Alerts', 'keywords' => _x( 'audit alerts email mail warning critical', 'settings search keywords', 'vigilante' ) ),
1684 array( 'tab' => 'activity-log', 'tab_label' => __( 'Security Audit', 'vigilante' ), 'section' => __( 'Audit Alerts', 'vigilante' ), 'anchor' => 'field-audit-alerts-immediate', 'label' => __( 'Immediate alerts', 'vigilante' ), 'label_en' => 'Immediate alerts', 'keywords' => _x( 'immediate alerts email mail critical warning', 'settings search keywords', 'vigilante' ) ),
1685 array( 'tab' => 'activity-log', 'tab_label' => __( 'Security Audit', 'vigilante' ), 'section' => __( 'Audit Alerts', 'vigilante' ), 'anchor' => 'field-audit-alerts-threshold', 'label' => __( 'Threshold alerts', 'vigilante' ), 'label_en' => 'Threshold alerts', 'keywords' => _x( 'threshold alerts email mail login', 'settings search keywords', 'vigilante' ) ),
1686 array( 'tab' => 'activity-log', 'tab_label' => __( 'Security Audit', 'vigilante' ), 'section' => __( 'Recent Activity', 'vigilante' ), 'anchor' => 'vigilante-section-audit-recent', 'label' => __( 'Recent Activity', 'vigilante' ), 'label_en' => 'Recent Activity', 'keywords' => _x( 'recent activity log', 'settings search keywords', 'vigilante' ) ),
1687 // Settings & Tools
1688 array( 'tab' => 'tools', 'tab_label' => __( 'Settings & Tools', 'vigilante' ), 'section' => __( 'Notification settings', 'vigilante' ), 'anchor' => 'vigilante-section-tools-notifications', 'label' => __( 'Notification settings', 'vigilante' ), 'label_en' => 'Notification settings', 'keywords' => _x( 'notification settings email', 'settings search keywords', 'vigilante' ) ),
1689 array( 'tab' => 'tools', 'tab_label' => __( 'Settings & Tools', 'vigilante' ), 'section' => __( 'Notification settings', 'vigilante' ), 'anchor' => 'vigilante-section-tools-notifications', 'label' => __( 'Additional Recipients', 'vigilante' ), 'label_en' => 'Additional Recipients', 'keywords' => _x( 'additional recipients email recipient', 'settings search keywords', 'vigilante' ) ),
1690 array( 'tab' => 'tools', 'tab_label' => __( 'Settings & Tools', 'vigilante' ), 'section' => __( 'Tools', 'vigilante' ), 'anchor' => 'vigilante-section-tools-main', 'label' => __( 'Export Settings', 'vigilante' ), 'label_en' => 'Export Settings', 'keywords' => _x( 'export settings json', 'settings search keywords', 'vigilante' ) ),
1691 array( 'tab' => 'tools', 'tab_label' => __( 'Settings & Tools', 'vigilante' ), 'section' => __( 'Tools', 'vigilante' ), 'anchor' => 'vigilante-section-tools-main', 'label' => __( 'Import Settings', 'vigilante' ), 'label_en' => 'Import Settings', 'keywords' => _x( 'import settings json', 'settings search keywords', 'vigilante' ) ),
1692 array( 'tab' => 'tools', 'tab_label' => __( 'Settings & Tools', 'vigilante' ), 'section' => __( 'Tools', 'vigilante' ), 'anchor' => 'vigilante-section-tools-main', 'label' => __( 'Reset to Defaults', 'vigilante' ), 'label_en' => 'Reset to Defaults', 'keywords' => _x( 'reset to defaults', 'settings search keywords', 'vigilante' ) ),
1693 array( 'tab' => 'tools', 'tab_label' => __( 'Settings & Tools', 'vigilante' ), 'section' => __( 'Tools', 'vigilante' ), 'anchor' => 'vigilante-section-tools-main', 'label' => __( 'Create Backup', 'vigilante' ), 'label_en' => 'Create Backup', 'keywords' => _x( 'create backup', 'settings search keywords', 'vigilante' ) ),
1694 array( 'tab' => 'tools', 'tab_label' => __( 'Settings & Tools', 'vigilante' ), 'section' => __( 'Tools', 'vigilante' ), 'anchor' => 'vigilante-section-tools-main', 'label' => __( 'Database Backup', 'vigilante' ), 'label_en' => 'Database Backup', 'keywords' => _x( 'database backup db mysql tables', 'settings search keywords', 'vigilante' ) ),
1695 // Entradas anadidas en la 2.9.7 tras comprobar que el indice cubria 68 de
1696 // las 131 filas de ajustes: buscar XML-RPC, por ejemplo, no devolvia nada.
1697 // El indice se mantiene a mano, asi que al anadir una fila de ajustes hay
1698 // que anadirla tambien aqui.
1699 array( 'tab' => 'tools', 'tab_label' => __( 'Settings & Tools', 'vigilante' ), 'section' => __( 'Notification settings', 'vigilante' ), 'anchor' => 'vigilante-section-tools-notifications', 'label' => __( 'WordPress Admin Email', 'vigilante' ), 'label_en' => 'WordPress Admin Email', 'keywords' => _x( 'wordpress admin email administrator administrators mail notification notify', 'settings search keywords', 'vigilante' ) ),
1700 array( 'tab' => 'tools', 'tab_label' => __( 'Settings & Tools', 'vigilante' ), 'section' => __( 'Notification settings', 'vigilante' ), 'anchor' => 'vigilante-section-tools-notifications', 'label' => __( 'Plugin Deactivation', 'vigilante' ), 'label_en' => 'Plugin Deactivation', 'keywords' => _x( 'plugin deactivation', 'settings search keywords', 'vigilante' ) ),
1701 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Firewall Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-main', 'label' => __( 'Block Bad Query Strings', 'vigilante' ), 'label_en' => 'Block Bad Query Strings', 'keywords' => _x( 'block bad query strings blocking blocked deny malicious harmful', 'settings search keywords', 'vigilante' ) ),
1702 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Firewall Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-main', 'label' => __( 'SQL Injection Protection', 'vigilante' ), 'label_en' => 'SQL Injection Protection', 'keywords' => _x( 'sql injection protection', 'settings search keywords', 'vigilante' ) ),
1703 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Firewall Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-main', 'label' => __( 'XSS Protection', 'vigilante' ), 'label_en' => 'XSS Protection', 'keywords' => _x( 'xss protection', 'settings search keywords', 'vigilante' ) ),
1704 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Firewall Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-main', 'label' => __( 'File Inclusion Protection', 'vigilante' ), 'label_en' => 'File Inclusion Protection', 'keywords' => _x( 'file inclusion protection files', 'settings search keywords', 'vigilante' ) ),
1705 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Firewall Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-main', 'label' => __( 'Directory Traversal Protection', 'vigilante' ), 'label_en' => 'Directory Traversal Protection', 'keywords' => _x( 'directory traversal protection folder folders', 'settings search keywords', 'vigilante' ) ),
1706 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Firewall Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-main', 'label' => __( 'Enable Rate Limiting', 'vigilante' ), 'label_en' => 'Enable Rate Limiting', 'keywords' => _x( 'enable rate limiting throttle flood burst limit limits', 'settings search keywords', 'vigilante' ) ),
1707 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Firewall Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-main', 'label' => __( 'Requests per Minute', 'vigilante' ), 'label_en' => 'Requests per Minute', 'keywords' => _x( 'requests per minute request traffic', 'settings search keywords', 'vigilante' ) ),
1708 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Firewall Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-main', 'label' => __( 'Block Duration (seconds)', 'vigilante' ), 'label_en' => 'Block Duration (seconds)', 'keywords' => _x( 'block duration seconds blocking blocked deny', 'settings search keywords', 'vigilante' ) ),
1709 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Firewall Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-main', 'label' => __( 'Progressive Blocking', 'vigilante' ), 'label_en' => 'Progressive Blocking', 'keywords' => _x( 'progressive blocking', 'settings search keywords', 'vigilante' ) ),
1710 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Firewall Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-main', 'label' => __( 'Maximum Block Duration', 'vigilante' ), 'label_en' => 'Maximum Block Duration', 'keywords' => _x( 'maximum block duration blocking blocked deny', 'settings search keywords', 'vigilante' ) ),
1711 array( 'tab' => 'firewall', 'tab_label' => __( 'Firewall', 'vigilante' ), 'section' => __( 'Firewall Protection', 'vigilante' ), 'anchor' => 'vigilante-section-firewall-main', 'label' => __( 'Visitor IP detection', 'vigilante' ), 'label_en' => 'Visitor IP detection', 'keywords' => _x( 'visitor ip detection ips address addresses cidr ipv4 ipv6', 'settings search keywords', 'vigilante' ) ),
1712 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Max Login Attempts', 'vigilante' ), 'label_en' => 'Max Login Attempts', 'keywords' => _x( 'max login attempts signin log-in access tries retries', 'settings search keywords', 'vigilante' ) ),
1713 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Lockout Duration', 'vigilante' ), 'label_en' => 'Lockout Duration', 'keywords' => _x( 'lockout duration lock ban block', 'settings search keywords', 'vigilante' ) ),
1714 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Progressive Lockout', 'vigilante' ), 'label_en' => 'Progressive Lockout', 'keywords' => _x( 'progressive lockout lock ban block', 'settings search keywords', 'vigilante' ) ),
1715 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Hide Login Errors', 'vigilante' ), 'label_en' => 'Hide Login Errors', 'keywords' => _x( 'hide login errors signin log-in access error debug log', 'settings search keywords', 'vigilante' ) ),
1716 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Disable Application Passwords', 'vigilante' ), 'label_en' => 'Disable Application Passwords', 'keywords' => _x( 'disable application passwords password credentials', 'settings search keywords', 'vigilante' ) ),
1717 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Login URL Slug', 'vigilante' ), 'label_en' => 'Login URL Slug', 'keywords' => _x( 'login url slug signin log-in access path', 'settings search keywords', 'vigilante' ) ),
1718 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Notify users', 'vigilante' ), 'label_en' => 'Notify users', 'keywords' => _x( 'notify users notification alert email user accounts', 'settings search keywords', 'vigilante' ) ),
1719 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Notify on Lockout', 'vigilante' ), 'label_en' => 'Notify on Lockout', 'keywords' => _x( 'notify on lockout notification alert email lock ban block', 'settings search keywords', 'vigilante' ) ),
1720 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Notify on Admin Login', 'vigilante' ), 'label_en' => 'Notify on Admin Login', 'keywords' => _x( 'notify on admin login notification alert email administrator administrators signin log-in access', 'settings search keywords', 'vigilante' ) ),
1721 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection Status', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Current settings', 'vigilante' ), 'label_en' => 'Current settings', 'keywords' => _x( 'current settings', 'settings search keywords', 'vigilante' ) ),
1722 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection Status', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Blocked IPs', 'vigilante' ), 'label_en' => 'Blocked IPs', 'keywords' => _x( 'blocked ips', 'settings search keywords', 'vigilante' ) ),
1723 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection Status', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Enable 2FA', 'vigilante' ), 'label_en' => 'Enable 2FA', 'keywords' => _x( 'enable 2fa two-factor mfa otp totp authenticator', 'settings search keywords', 'vigilante' ) ),
1724 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection Status', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Verification method', 'vigilante' ), 'label_en' => 'Verification method', 'keywords' => _x( 'verification method verify confirm', 'settings search keywords', 'vigilante' ) ),
1725 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection Status', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Enforce for roles', 'vigilante' ), 'label_en' => 'Enforce for roles', 'keywords' => _x( 'enforce for roles role capabilities', 'settings search keywords', 'vigilante' ) ),
1726 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection Status', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Exclude specific users', 'vigilante' ), 'label_en' => 'Exclude specific users', 'keywords' => _x( 'exclude specific users user accounts', 'settings search keywords', 'vigilante' ) ),
1727 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection Status', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Remember device', 'vigilante' ), 'label_en' => 'Remember device', 'keywords' => _x( 'remember device', 'settings search keywords', 'vigilante' ) ),
1728 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection Status', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Grace period', 'vigilante' ), 'label_en' => 'Grace period', 'keywords' => _x( 'grace period', 'settings search keywords', 'vigilante' ) ),
1729 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection Status', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Email sender name', 'vigilante' ), 'label_en' => 'Email sender name', 'keywords' => _x( 'email sender name mail notification notify names', 'settings search keywords', 'vigilante' ) ),
1730 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection Status', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Reset user TOTP', 'vigilante' ), 'label_en' => 'Reset user TOTP', 'keywords' => _x( 'reset user totp users account 2fa authenticator app', 'settings search keywords', 'vigilante' ) ),
1731 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection Status', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Notify on enable', 'vigilante' ), 'label_en' => 'Notify on enable', 'keywords' => _x( 'notify on enable notification alert email', 'settings search keywords', 'vigilante' ) ),
1732 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Security Headers', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Enable CSP', 'vigilante' ), 'label_en' => 'Enable CSP', 'keywords' => _x( 'enable csp content security policy', 'settings search keywords', 'vigilante' ) ),
1733 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Security Headers', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Report Only Mode', 'vigilante' ), 'label_en' => 'Report Only Mode', 'keywords' => _x( 'report only mode', 'settings search keywords', 'vigilante' ) ),
1734 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Security Headers', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Redirect HTTP to HTTPS', 'vigilante' ), 'label_en' => 'Redirect HTTP to HTTPS', 'keywords' => _x( 'redirect http to https redirection forward ssl tls secure', 'settings search keywords', 'vigilante' ) ),
1735 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Security Headers', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Fix Mixed Content', 'vigilante' ), 'label_en' => 'Fix Mixed Content', 'keywords' => _x( 'fix mixed content insecure http', 'settings search keywords', 'vigilante' ) ),
1736 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Security Headers', 'vigilante' ), 'anchor' => 'field-upgrade-insecure-requests', 'label' => __( 'Upgrade Insecure Requests', 'vigilante' ), 'label_en' => 'Upgrade Insecure Requests', 'keywords' => _x( 'upgrade insecure requests mixed content csp https external resources', 'settings search keywords', 'vigilante' ) ),
1737 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Security Headers', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Rewrite Site Address on Activation', 'vigilante' ), 'label_en' => 'Rewrite Site Address on Activation', 'keywords' => _x( 'rewrite site address on activation', 'settings search keywords', 'vigilante' ) ),
1738 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Security Headers', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Enable HSTS', 'vigilante' ), 'label_en' => 'Enable HSTS', 'keywords' => _x( 'enable hsts strict transport security', 'settings search keywords', 'vigilante' ) ),
1739 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Security Headers', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Max Age', 'vigilante' ), 'label_en' => 'Max Age', 'keywords' => _x( 'max age', 'settings search keywords', 'vigilante' ) ),
1740 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Security Headers', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Include Subdomains', 'vigilante' ), 'label_en' => 'Include Subdomains', 'keywords' => _x( 'include subdomains', 'settings search keywords', 'vigilante' ) ),
1741 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Username & password protection', 'vigilante' ), 'anchor' => 'vigilante-section-users-password', 'label' => __( 'Block Insecure Usernames', 'vigilante' ), 'label_en' => 'Block Insecure Usernames', 'keywords' => _x( 'block insecure usernames blocking blocked deny weak unsafe', 'settings search keywords', 'vigilante' ) ),
1742 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Username & password protection', 'vigilante' ), 'anchor' => 'vigilante-section-users-password', 'label' => __( 'Enforce Strong Passwords', 'vigilante' ), 'label_en' => 'Enforce Strong Passwords', 'keywords' => _x( 'enforce strong passwords complexity password credentials', 'settings search keywords', 'vigilante' ) ),
1743 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Username & password protection', 'vigilante' ), 'anchor' => 'vigilante-section-users-password', 'label' => __( 'Minimum Password Length', 'vigilante' ), 'label_en' => 'Minimum Password Length', 'keywords' => _x( 'minimum password length passwords credentials characters', 'settings search keywords', 'vigilante' ) ),
1744 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Username & password protection', 'vigilante' ), 'anchor' => 'vigilante-section-users-password', 'label' => __( 'Password Requirements', 'vigilante' ), 'label_en' => 'Password Requirements', 'keywords' => _x( 'password requirements passwords credentials', 'settings search keywords', 'vigilante' ) ),
1745 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Username & password protection', 'vigilante' ), 'anchor' => 'vigilante-section-users-password', 'label' => __( 'Apply Password Rules To', 'vigilante' ), 'label_en' => 'Apply Password Rules To', 'keywords' => _x( 'apply password rules to passwords credentials', 'settings search keywords', 'vigilante' ) ),
1746 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Username & password protection', 'vigilante' ), 'anchor' => 'vigilante-section-users-password', 'label' => __( 'Block Author Scanning', 'vigilante' ), 'label_en' => 'Block Author Scanning', 'keywords' => _x( 'block author scanning blocking blocked deny authors enumeration probing', 'settings search keywords', 'vigilante' ) ),
1747 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Username & password protection', 'vigilante' ), 'anchor' => 'vigilante-section-users-password', 'label' => __( 'Display Name Protection', 'vigilante' ), 'label_en' => 'Display Name Protection', 'keywords' => _x( 'display name protection public visible names', 'settings search keywords', 'vigilante' ) ),
1748 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Admin monitoring', 'vigilante' ), 'anchor' => 'vigilante-section-users-admin-monitoring', 'label' => __( 'New Administrator Alert', 'vigilante' ), 'label_en' => 'New Administrator Alert', 'keywords' => _x( 'new administrator alert alerts notification warning', 'settings search keywords', 'vigilante' ) ),
1749 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Admin monitoring', 'vigilante' ), 'anchor' => 'vigilante-section-users-admin-monitoring', 'label' => __( 'Admin Email Change Alert', 'vigilante' ), 'label_en' => 'Admin Email Change Alert', 'keywords' => _x( 'admin email change alert administrator administrators mail notification notify alerts warning', 'settings search keywords', 'vigilante' ) ),
1750 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Admin monitoring', 'vigilante' ), 'anchor' => 'vigilante-section-users-admin-monitoring', 'label' => __( 'Permission Elevation Alert', 'vigilante' ), 'label_en' => 'Permission Elevation Alert', 'keywords' => _x( 'permission elevation alert alerts notification warning', 'settings search keywords', 'vigilante' ) ),
1751 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Admin monitoring', 'vigilante' ), 'anchor' => 'vigilante-section-users-admin-monitoring', 'label' => __( 'Admin Password Change Alert', 'vigilante' ), 'label_en' => 'Admin Password Change Alert', 'keywords' => _x( 'admin password change alert administrator administrators passwords credentials alerts notification warning', 'settings search keywords', 'vigilante' ) ),
1752 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Registration approval', 'vigilante' ), 'anchor' => 'vigilante-section-users-registration', 'label' => __( 'Enable Registration Approval', 'vigilante' ), 'label_en' => 'Enable Registration Approval', 'keywords' => _x( 'enable registration approval signup register approve moderate', 'settings search keywords', 'vigilante' ) ),
1753 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Registration approval', 'vigilante' ), 'anchor' => 'vigilante-section-users-registration', 'label' => __( 'Notify Admin', 'vigilante' ), 'label_en' => 'Notify Admin', 'keywords' => _x( 'notify admin notification alert email administrator administrators', 'settings search keywords', 'vigilante' ) ),
1754 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Registration approval', 'vigilante' ), 'anchor' => 'vigilante-section-users-registration', 'label' => __( 'Auto-reject After', 'vigilante' ), 'label_en' => 'Auto-reject After', 'keywords' => _x( 'auto-reject after', 'settings search keywords', 'vigilante' ) ),
1755 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Session limits', 'vigilante' ), 'anchor' => 'vigilante-section-users-sessions', 'label' => __( 'Enable Session Limits', 'vigilante' ), 'label_en' => 'Enable Session Limits', 'keywords' => _x( 'enable session limits sessions concurrent', 'settings search keywords', 'vigilante' ) ),
1756 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Session limits', 'vigilante' ), 'anchor' => 'vigilante-section-users-sessions', 'label' => __( 'Maximum Sessions', 'vigilante' ), 'label_en' => 'Maximum Sessions', 'keywords' => _x( 'maximum sessions session concurrent', 'settings search keywords', 'vigilante' ) ),
1757 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Session limits', 'vigilante' ), 'anchor' => 'vigilante-section-users-sessions', 'label' => __( 'When Limit Exceeded', 'vigilante' ), 'label_en' => 'When Limit Exceeded', 'keywords' => _x( 'when limit exceeded', 'settings search keywords', 'vigilante' ) ),
1758 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Session limits', 'vigilante' ), 'anchor' => 'vigilante-section-users-sessions', 'label' => __( 'Exclude Administrators', 'vigilante' ), 'label_en' => 'Exclude Administrators', 'keywords' => _x( 'exclude administrators', 'settings search keywords', 'vigilante' ) ),
1759 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Password expiration', 'vigilante' ), 'anchor' => 'vigilante-section-users-password-exp', 'label' => __( 'Enable Password Expiration', 'vigilante' ), 'label_en' => 'Enable Password Expiration', 'keywords' => _x( 'enable password expiration passwords credentials expiry expire caducity', 'settings search keywords', 'vigilante' ) ),
1760 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Password expiration', 'vigilante' ), 'anchor' => 'vigilante-section-users-password-exp', 'label' => __( 'Expire After', 'vigilante' ), 'label_en' => 'Expire After', 'keywords' => _x( 'expire after', 'settings search keywords', 'vigilante' ) ),
1761 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Password expiration', 'vigilante' ), 'anchor' => 'vigilante-section-users-password-exp', 'label' => __( 'Warning Period', 'vigilante' ), 'label_en' => 'Warning Period', 'keywords' => _x( 'warning period', 'settings search keywords', 'vigilante' ) ),
1762 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Password expiration', 'vigilante' ), 'anchor' => 'vigilante-section-users-password-exp', 'label' => __( 'Password History', 'vigilante' ), 'label_en' => 'Password History', 'keywords' => _x( 'password history passwords credentials reuse previous', 'settings search keywords', 'vigilante' ) ),
1763 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Password expiration', 'vigilante' ), 'anchor' => 'vigilante-section-users-password-exp', 'label' => __( 'Email Reminder', 'vigilante' ), 'label_en' => 'Email Reminder', 'keywords' => _x( 'email reminder mail notification notify', 'settings search keywords', 'vigilante' ) ),
1764 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Password expiration', 'vigilante' ), 'anchor' => 'vigilante-section-users-password-exp', 'label' => __( 'Affected Roles', 'vigilante' ), 'label_en' => 'Affected Roles', 'keywords' => _x( 'affected roles role capabilities', 'settings search keywords', 'vigilante' ) ),
1765 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Password expiration', 'vigilante' ), 'anchor' => 'vigilante-section-users-password-exp', 'label' => __( 'Exclude specific users', 'vigilante' ), 'label_en' => 'Exclude specific users', 'keywords' => _x( 'exclude specific users user accounts', 'settings search keywords', 'vigilante' ) ),
1766 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Email verification', 'vigilante' ), 'anchor' => 'vigilante-section-users-email-verify', 'label' => __( 'Enable Email Verification', 'vigilante' ), 'label_en' => 'Enable Email Verification', 'keywords' => _x( 'enable email verification mail notification notify verify confirm', 'settings search keywords', 'vigilante' ) ),
1767 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Email verification', 'vigilante' ), 'anchor' => 'vigilante-section-users-email-verify', 'label' => __( 'Link Expiration', 'vigilante' ), 'label_en' => 'Link Expiration', 'keywords' => _x( 'link expiration expiry expire caducity', 'settings search keywords', 'vigilante' ) ),
1768 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Email verification', 'vigilante' ), 'anchor' => 'vigilante-section-users-email-verify', 'label' => __( 'Allow Resend', 'vigilante' ), 'label_en' => 'Allow Resend', 'keywords' => _x( 'allow resend', 'settings search keywords', 'vigilante' ) ),
1769 array( 'tab' => 'users', 'tab_label' => __( 'User Security', 'vigilante' ), 'section' => __( 'Email verification', 'vigilante' ), 'anchor' => 'vigilante-section-users-email-verify', 'label' => __( 'Auto-delete Unverified', 'vigilante' ), 'label_en' => 'Auto-delete Unverified', 'keywords' => _x( 'auto-delete unverified', 'settings search keywords', 'vigilante' ) ),
1770 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'Database Hardening', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-database', 'label' => __( 'Current prefix', 'vigilante' ), 'label_en' => 'Current prefix', 'keywords' => _x( 'current prefix database db table tables mysql', 'settings search keywords', 'vigilante' ) ),
1771 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'Database Hardening', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-database', 'label' => __( 'New prefix', 'vigilante' ), 'label_en' => 'New prefix', 'keywords' => _x( 'new prefix database db table tables mysql', 'settings search keywords', 'vigilante' ) ),
1772 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'wp-config.php Security', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-wpconfig', 'label' => __( 'Disable File Editor', 'vigilante' ), 'label_en' => 'Disable File Editor', 'keywords' => _x( 'disable file editor files edit editing', 'settings search keywords', 'vigilante' ) ),
1773 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'wp-config.php Security', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-wpconfig', 'label' => __( 'Disable File Modifications', 'vigilante' ), 'label_en' => 'Disable File Modifications', 'keywords' => _x( 'disable file modifications files modify install update', 'settings search keywords', 'vigilante' ) ),
1774 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'wp-config.php Security', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-wpconfig', 'label' => __( 'Hide PHP errors from visitors', 'vigilante' ), 'label_en' => 'Hide PHP errors from visitors', 'keywords' => _x( 'hide php errors from visitors error debug log', 'settings search keywords', 'vigilante' ) ),
1775 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'XML-RPC', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-xmlrpc', 'label' => __( 'XML-RPC access', 'vigilante' ), 'label_en' => 'XML-RPC access', 'keywords' => _x( 'xml-rpc access xmlrpc rpc remote jetpack app', 'settings search keywords', 'vigilante' ) ),
1776 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'Comment Security', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-comments', 'label' => __( 'Disable Pingbacks', 'vigilante' ), 'label_en' => 'Disable Pingbacks', 'keywords' => _x( 'disable pingbacks pingback ping', 'settings search keywords', 'vigilante' ) ),
1777 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'Comment Security', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-comments', 'label' => __( 'Disable Trackbacks', 'vigilante' ), 'label_en' => 'Disable Trackbacks', 'keywords' => _x( 'disable trackbacks trackback ping', 'settings search keywords', 'vigilante' ) ),
1778 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'Comment Security', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-comments', 'label' => __( 'Require Moderation', 'vigilante' ), 'label_en' => 'Require Moderation', 'keywords' => _x( 'require moderation moderate approve', 'settings search keywords', 'vigilante' ) ),
1779 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'Comment Security', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-comments', 'label' => __( 'Close Old Comments', 'vigilante' ), 'label_en' => 'Close Old Comments', 'keywords' => _x( 'close old comments comment discussion', 'settings search keywords', 'vigilante' ) ),
1780 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'Comment Security', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-comments', 'label' => __( 'Honeypot Protection', 'vigilante' ), 'label_en' => 'Honeypot Protection', 'keywords' => _x( 'honeypot protection spam bots trap', 'settings search keywords', 'vigilante' ) ),
1781 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'Header Cleanup', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-headers', 'label' => __( 'Remove Generator', 'vigilante' ), 'label_en' => 'Remove Generator', 'keywords' => _x( 'remove generator version meta', 'settings search keywords', 'vigilante' ) ),
1782 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'Header Cleanup', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-headers', 'label' => __( 'Remove RSD Link', 'vigilante' ), 'label_en' => 'Remove RSD Link', 'keywords' => _x( 'remove rsd link discovery', 'settings search keywords', 'vigilante' ) ),
1783 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'Header Cleanup', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-headers', 'label' => __( 'Remove WLW Manifest', 'vigilante' ), 'label_en' => 'Remove WLW Manifest', 'keywords' => _x( 'remove wlw manifest wlwmanifest', 'settings search keywords', 'vigilante' ) ),
1784 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'Header Cleanup', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-headers', 'label' => __( 'Remove Shortlink', 'vigilante' ), 'label_en' => 'Remove Shortlink', 'keywords' => _x( 'remove shortlink link', 'settings search keywords', 'vigilante' ) ),
1785 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'Header Cleanup', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-headers', 'label' => __( 'Remove REST API Link', 'vigilante' ), 'label_en' => 'Remove REST API Link', 'keywords' => _x( 'remove rest api link json endpoint', 'settings search keywords', 'vigilante' ) ),
1786 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'RSS Feed Settings', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-rss', 'label' => __( 'Disable Feeds', 'vigilante' ), 'label_en' => 'Disable Feeds', 'keywords' => _x( 'disable feeds feed rss atom syndication', 'settings search keywords', 'vigilante' ) ),
1787 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'RSS Feed Settings', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-rss', 'label' => __( 'Disable If No Content', 'vigilante' ), 'label_en' => 'Disable If No Content', 'keywords' => _x( 'disable if no content', 'settings search keywords', 'vigilante' ) ),
1788 array( 'tab' => 'wp-hardening', 'tab_label' => __( 'WP Hardening', 'vigilante' ), 'section' => __( 'RSS Feed Settings', 'vigilante' ), 'anchor' => 'vigilante-section-hardening-rss', 'label' => __( 'Remove Feed Version', 'vigilante' ), 'label_en' => 'Remove Feed Version', 'keywords' => _x( 'remove feed version feeds rss atom', 'settings search keywords', 'vigilante' ) ),
1789 array( 'tab' => 'activity-log', 'tab_label' => __( 'Security Audit', 'vigilante' ), 'section' => __( 'Audit Alerts', 'vigilante' ), 'anchor' => 'vigilante-section-audit-alerts', 'label' => __( 'Alert on severity', 'vigilante' ), 'label_en' => 'Alert on severity', 'keywords' => _x( 'alert on severity alerts notification warning level critical', 'settings search keywords', 'vigilante' ) ),
1790 array( 'tab' => 'activity-log', 'tab_label' => __( 'Security Audit', 'vigilante' ), 'section' => __( 'Audit Alerts', 'vigilante' ), 'anchor' => 'vigilante-section-audit-alerts', 'label' => __( 'Time window', 'vigilante' ), 'label_en' => 'Time window', 'keywords' => _x( 'time window', 'settings search keywords', 'vigilante' ) ),
1791 array( 'tab' => 'activity-log', 'tab_label' => __( 'Security Audit', 'vigilante' ), 'section' => __( 'Audit Alerts', 'vigilante' ), 'anchor' => 'vigilante-section-audit-alerts', 'label' => __( 'Thresholds per category', 'vigilante' ), 'label_en' => 'Thresholds per category', 'keywords' => _x( 'thresholds per category threshold limit', 'settings search keywords', 'vigilante' ) ),
1792 array( 'tab' => 'activity-log', 'tab_label' => __( 'Security Audit', 'vigilante' ), 'section' => __( 'Audit Alerts', 'vigilante' ), 'anchor' => 'vigilante-section-audit-alerts', 'label' => __( 'Recipients', 'vigilante' ), 'label_en' => 'Recipients', 'keywords' => _x( 'recipients email recipient', 'settings search keywords', 'vigilante' ) ),
1793 array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'File Integrity Monitoring', 'vigilante' ), 'anchor' => 'vigilante-section-fi-monitoring', 'label' => __( 'Automatic Scans', 'vigilante' ), 'label_en' => 'Automatic Scans', 'keywords' => _x( 'automatic scans scan scanning', 'settings search keywords', 'vigilante' ) ),
1794 array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'File Integrity Monitoring', 'vigilante' ), 'anchor' => 'vigilante-section-fi-monitoring', 'label' => __( 'Scan Frequency', 'vigilante' ), 'label_en' => 'Scan Frequency', 'keywords' => _x( 'scan frequency scans scanning check', 'settings search keywords', 'vigilante' ) ),
1795 array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'File Integrity Monitoring', 'vigilante' ), 'anchor' => 'vigilante-section-fi-monitoring', 'label' => __( 'Email Notifications', 'vigilante' ), 'label_en' => 'Email Notifications', 'keywords' => _x( 'email notifications mail notification notify', 'settings search keywords', 'vigilante' ) ),
1796 array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'File Integrity Monitoring', 'vigilante' ), 'anchor' => 'vigilante-section-fi-monitoring', 'label' => __( 'Test email', 'vigilante' ), 'label_en' => 'Test email', 'keywords' => _x( 'test email mail notification notify', 'settings search keywords', 'vigilante' ) ),
1797 array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'File Integrity Monitoring', 'vigilante' ), 'anchor' => 'vigilante-section-fi-monitoring', 'label' => __( 'Scan Scope', 'vigilante' ), 'label_en' => 'Scan Scope', 'keywords' => _x( 'scan scope scans scanning check', 'settings search keywords', 'vigilante' ) ),
1798 array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'File Integrity Monitoring', 'vigilante' ), 'anchor' => 'vigilante-section-fi-monitoring', 'label' => __( 'Excluded Paths', 'vigilante' ), 'label_en' => 'Excluded Paths', 'keywords' => _x( 'excluded paths exclude exclusions ignore ignored path folder folders', 'settings search keywords', 'vigilante' ) ),
1799 array( 'tab' => 'file-integrity', 'tab_label' => __( 'File Integrity', 'vigilante' ), 'section' => __( 'File Integrity Monitoring', 'vigilante' ), 'anchor' => 'vigilante-section-fi-monitoring', 'label' => __( 'Excluded Extensions', 'vigilante' ), 'label_en' => 'Excluded Extensions', 'keywords' => _x( 'excluded extensions exclude exclusions ignore ignored extension filetype', 'settings search keywords', 'vigilante' ) ),
1800 );
1801 }
1802
1803 /**
1804 * Enqueue admin assets
1805 *
1806 * @param string $hook Current admin page.
1807 */
1808 public function enqueue_assets( $hook ) {
1809 // toplevel_page_vigilante for top-level menu page
1810 if ( 'toplevel_page_vigilante' !== $hook ) {
1811 return;
1812 }
1813
1814 wp_enqueue_style(
1815 'vigilante-admin',
1816 VIGILANTE_ASSETS_URL . 'css/admin.css',
1817 array(),
1818 VIGILANTE_VERSION
1819 );
1820
1821 wp_enqueue_script(
1822 'vigilante-admin',
1823 VIGILANTE_ASSETS_URL . 'js/admin.js',
1824 array( 'jquery' ),
1825 VIGILANTE_VERSION,
1826 true
1827 );
1828
1829 wp_localize_script( 'vigilante-admin', 'vigilanteAdmin', array(
1830 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
1831 'selfBoxUrl' => esc_url( admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' ) ),
1832 'nonce' => wp_create_nonce( 'vigilante_admin_nonce' ),
1833 'currentUserId' => get_current_user_id(),
1834 'logoutUrl' => wp_logout_url( wp_login_url() ),
1835 'adminUrl' => admin_url( 'admin.php?page=vigilante' ),
1836 'searchIndex' => $this->get_search_index(),
1837 // The scan repaints this table from JavaScript, so the same gate
1838 // has to travel with it or half the screen keeps the dead button.
1839 'approvalLocked' => $this->critical_approval_locked(),
1840 'underAttack' => array(
1841 'active' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->is_active(),
1842 'remaining' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->get_remaining_time(),
1843 ),
1844 'strings' => array(
1845 'saving' => __( 'Saving...', 'vigilante' ),
1846 'sendingTest' => __( 'Sending...', 'vigilante' ),
1847 'saved' => __( 'Settings saved', 'vigilante' ),
1848 'error' => __( 'Error saving settings', 'vigilante' ),
1849 'confirm' => __( 'Are you sure?', 'vigilante' ),
1850 'scanning' => __( 'Scanning...', 'vigilante' ),
1851 'scanComplete' => __( 'Scan complete', 'vigilante' ),
1852 'loading' => __( 'Loading...', 'vigilante' ),
1853 'searching' => __( 'Searching...', 'vigilante' ),
1854 'noUsersFound' => __( 'No users found', 'vigilante' ),
1855 'sending' => __( 'Sending...', 'vigilante' ),
1856 'sendNotification' => __( 'Send notification now', 'vigilante' ),
1857 'notificationsSent' => __( 'notifications sent', 'vigilante' ),
1858 'skipped' => __( 'skipped', 'vigilante' ),
1859 'failed' => __( 'failed', 'vigilante' ),
1860 'customConfig' => __( 'Custom Configuration', 'vigilante' ),
1861 // Header tester strings
1862 'testHeaders' => __( 'Test Headers', 'vigilante' ),
1863 'testing' => __( 'Testing...', 'vigilante' ),
1864 'score' => __( 'Score', 'vigilante' ),
1865 'enabledHeaders' => __( 'Passed', 'vigilante' ),
1866 'missingHeaders' => __( 'Failing', 'vigilante' ),
1867 'headersMeasured' => __( 'Measured right now on the home page of the site, with the same checks as Security Check.', 'vigilante' ),
1868 'warnings' => __( 'Warnings', 'vigilante' ),
1869 // File integrity scan results strings
1870 'scanResults' => __( 'Scan Results', 'vigilante' ),
1871 'ok' => __( 'OK', 'vigilante' ),
1872 'modified' => __( 'Modified', 'vigilante' ),
1873 'suspicious' => __( 'Suspicious', 'vigilante' ),
1874 'totalScanned' => __( 'Total Scanned', 'vigilante' ),
1875 'suspiciousFiles' => __( 'Suspicious Files', 'vigilante' ),
1876 'suspiciousWarning' => __( 'Files that can run code where none is expected, or that contain code written the way malware hides itself. Real infections show up here, and so do harmless files that plugins generate. Check the path and the reason before acting.', 'vigilante' ),
1877 'file' => __( 'File', 'vigilante' ),
1878 'reason' => __( 'Reason', 'vigilante' ),
1879 'type' => __( 'Type', 'vigilante' ),
1880 'unknown' => __( 'Unknown', 'vigilante' ),
1881 'selfType' => __( 'Vigilant (self)', 'vigilante' ),
1882 'logWhatHappened' => __( 'What happened', 'vigilante' ),
1883 'logWhatItMeans' => __( 'What it means', 'vigilante' ),
1884 'logWhatToDo' => __( 'What to do', 'vigilante' ),
1885 'logSelfSeeDetails' => __( 'Open File Integrity for the full detail', 'vigilante' ),
1886 'modifiedFiles' => __( 'Modified Files', 'vigilante' ),
1887 'modifiedDescription' => __( 'These files do not match the copy WordPress.org publishes for the installed version.', 'vigilante' ),
1888 'extraFiles' => __( 'Extra Files', 'vigilante' ),
1889 'extra' => __( 'Extra', 'vigilante' ),
1890 'ignored' => __( 'Ignored', 'vigilante' ),
1891 'extraDescription' => __( 'Files that are not part of WordPress, or of the plugin or theme they sit in. Many are legitimate, and an injected file looks the same from here, so confirm where each one comes from.', 'vigilante' ),
1892 'actions' => __( 'Actions', 'vigilante' ),
1893 'ignore' => __( 'Ignore', 'vigilante' ),
1894 'ignoring' => __( 'Ignoring...', 'vigilante' ),
1895 'fileIgnored' => __( 'File added to ignored list.', 'vigilante' ),
1896 'fileUnignored' => __( 'File removed from ignored list.', 'vigilante' ),
1897 'confirmClearIgnored' => __( 'Remove all files from the ignored list? They will appear in scan results again.', 'vigilante' ),
1898 'ignoredCleared' => __( 'Ignored files list cleared. Page will reload...', 'vigilante' ),
1899 'selectAll' => __( 'Select all', 'vigilante' ),
1900 'bulkIgnoreSelected' => __( 'Ignore selected', 'vigilante' ),
1901 'bulkNoSelection' => __( 'Select at least one file first.', 'vigilante' ),
1902 'bulkConfirmIgnore' => __( 'Ignore the selected files? They will be hidden from future scan results until you remove them from the ignored list.', 'vigilante' ),
1903 'bulkConfirmUnignore' => __( 'Remove the selected files from the ignored list? They will appear in scan results again.', 'vigilante' ),
1904 'bulkProcessing' => __( 'Processing...', 'vigilante' ),
1905 /* translators: %d: number of files selected for bulk action. */
1906 'bulkSelectedCount' => __( '%d selected', 'vigilante' ),
1907 'allClear' => __( 'Good job! Nothing to report in the files that were checked.', 'vigilante' ),
1908 'criticalConfigTitle' => __( 'Critical config files modified', 'vigilante' ),
1909 'criticalConfigDesc' => __( 'These files changed since you last approved them. They are a common target for code injection, and they are also rewritten by cache, security and redirection plugins, often on every update. Review the changes and approve them if they belong to something you use: approving records the current content as the reference and does not touch the file. Vigilant\'s own blocks are excluded from this check, and Vigilant never restores these files by itself.', 'vigilante' ),
1910 'approve' => __( 'Approve', 'vigilante' ),
1911 'approving' => __( 'Approving...', 'vigilante' ),
1912 'approvalLockedNotice' => $this->critical_approval_notice(),
1913 'criticalApproved' => __( 'Change approved. Next scan will use the current state as baseline.', 'vigilante' ),
1914 'reviewChanges' => __( 'Review changes', 'vigilante' ),
1915 'hideChanges' => __( 'Hide changes', 'vigilante' ),
1916 'changes' => __( 'Changes', 'vigilante' ),
1917 'diffUnavailable' => __( 'Diff not available for this file (baseline was created before diff tracking was added). Approve to enable diff on future changes.', 'vigilante' ),
1918 'diffNetwork' => __( 'This file belongs to the whole network, so its line changes are only shown to network administrators, on the main site.', 'vigilante' ),
1919 'diffRescan' => __( 'Run a new scan to see the line changes of this file.', 'vigilante' ),
1920 'diffRedaction' => __( 'The line changes of this file are not shown because a value in it could not be hidden safely. The change itself is still detected.', 'vigilante' ),
1921 'diffEmpty' => __( 'No line-level changes detected (may be whitespace or reordering).', 'vigilante' ),
1922 'diffLines' => __( 'lines', 'vigilante' ),
1923 // Under Attack mode strings
1924 'underAttackConfirmActivate' => __( 'Activate Under Attack mode? All visitors will see a verification page for the next 4 hours.', 'vigilante' ),
1925 'underAttackConfirmDeactivate' => __( 'Deactivate Under Attack mode?', 'vigilante' ),
1926 'underAttackActivating' => __( 'Activating...', 'vigilante' ),
1927 'underAttackDeactivating' => __( 'Deactivating...', 'vigilante' ),
1928 // Database backup strings
1929 'dbBackupDownloading' => __( 'Generating backup...', 'vigilante' ),
1930 'dbBackupNoTables' => __( 'Please select at least one table.', 'vigilante' ),
1931 'dbBackupSuccess' => __( 'Database backup downloaded successfully.', 'vigilante' ),
1932 // Firewall unblock
1933 'confirmUnblockIp' => __( 'Unblock this IP from firewall rate limiting?', 'vigilante' ),
1934 // Database prefix strings
1935 'dbPrefixConfirm' => __( 'This operation will change your database prefix. It is irreversible. Make sure you have a current database backup before proceeding.', 'vigilante' ),
1936 'dbPrefixChanging' => __( 'Changing prefix...', 'vigilante' ),
1937 'dbPrefixSuccess' => __( 'Database prefix changed successfully. The page will reload now.', 'vigilante' ),
1938 'dbPrefixCheckbox' => __( 'You must confirm that you have a database backup.', 'vigilante' ),
1939 /* translators: 1: Hours, 2: Minutes */
1940 'underAttackRemaining' => __( '%1$dh %2$dm remaining', 'vigilante' ),
1941 'underAttackLabel' => __( 'Under Attack', 'vigilante' ),
1942 'standardLabel' => __( 'Standard', 'vigilante' ),
1943 'maximumLabel' => __( 'Maximum Security', 'vigilante' ),
1944 'deactivate' => __( 'Deactivate', 'vigilante' ),
1945 'underAttackActivate' => __( 'Activate for 4 hours', 'vigilante' ),
1946 // Settings strings
1947 'saveSettings' => __( 'Save Settings', 'vigilante' ),
1948 'settingsResetDefaults' => __( 'Settings reset to defaults.', 'vigilante' ),
1949 'confirmOverwrite' => __( 'This will overwrite your current settings.', 'vigilante' ),
1950 'importFailed' => __( 'Could not import settings. Check the file and try again.', 'vigilante' ),
1951 /* translators: 1: tests passed, 2: total tests in this category */
1952 'testsCounter' => __( '%1$d/%2$d tests', 'vigilante' ),
1953 'confirmResetAll' => __( 'This will reset ALL settings to defaults.', 'vigilante' ),
1954 'couldNotDetermineSection' => __( 'Could not determine section.', 'vigilante' ),
1955 'confirmResetSection' => __( 'Reset this section to default values? This cannot be undone.', 'vigilante' ),
1956 'sectionResetDefaults' => __( 'Section reset to defaults.', 'vigilante' ),
1957 /* translators: %s: preset name */
1958 'confirmApplyPreset' => __( 'Apply the "%s" preset?', 'vigilante' ),
1959 // Scan strings
1960 'scanFailed' => __( 'Scan failed', 'vigilante' ),
1961 /* translators: %s: error message */
1962 'scanError' => __( 'Scan error: %s', 'vigilante' ),
1963 'runScanNow' => __( 'Run Scan Now', 'vigilante' ),
1964 'confirmClearScan' => __( 'Are you sure you want to clear all scan results?', 'vigilante' ),
1965 'clearing' => __( 'Clearing...', 'vigilante' ),
1966 'scanResultsCleared' => __( 'Scan results cleared. Page will reload...', 'vigilante' ),
1967 'failedClearResults' => __( 'Failed to clear results', 'vigilante' ),
1968 /* translators: %s: error message */
1969 'ajaxError' => __( 'AJAX Error: %s', 'vigilante' ),
1970 // Activity log popup strings
1971 'logRequest' => __( 'Request', 'vigilante' ),
1972 'logDate' => __( 'Date', 'vigilante' ),
1973 'logMethod' => __( 'Method', 'vigilante' ),
1974 'logType' => __( 'Type', 'vigilante' ),
1975 'logAction' => __( 'Action', 'vigilante' ),
1976 'logSeverity' => __( 'Severity', 'vigilante' ),
1977 'logMessage' => __( 'Message', 'vigilante' ),
1978 'logRequestUri' => __( 'Address', 'vigilante' ),
1979 'logClient' => __( 'Client', 'vigilante' ),
1980 'logUser' => __( 'User', 'vigilante' ),
1981 'logIpAddress' => __( 'IP Address', 'vigilante' ),
1982 'logUserAgent' => __( 'User Agent', 'vigilante' ),
1983 'logIpLabel' => __( 'IP:', 'vigilante' ),
1984 'logUaLabel' => __( 'UA:', 'vigilante' ),
1985 'logWhitelist' => __( 'Whitelist', 'vigilante' ),
1986 'logBlacklist' => __( 'Blacklist', 'vigilante' ),
1987 'logInWhitelist' => __( 'In whitelist', 'vigilante' ),
1988 'logInBlacklist' => __( 'In blacklist', 'vigilante' ),
1989 'logAdded' => __( 'Added!', 'vigilante' ),
1990 'logErrorAddingToList' => __( 'Error adding to list', 'vigilante' ),
1991 'logRequestFailed' => __( 'Request failed', 'vigilante' ),
1992 // Activity log table strings
1993 'noLogEntries' => __( 'No log entries found.', 'vigilante' ),
1994 'view' => __( 'View', 'vigilante' ),
1995 'confirmClearLogs' => __( 'This will delete all audit logs.', 'vigilante' ),
1996 // Export logs strings
1997 'exporting' => __( 'Exporting...', 'vigilante' ),
1998 /* translators: %d: number of entries */
1999 'logsExported' => __( 'Logs exported (%d entries)', 'vigilante' ),
2000 'noLogsToExport' => __( 'No logs to export', 'vigilante' ),
2001 'exportFailed' => __( 'Export failed', 'vigilante' ),
2002 'exportLogs' => __( 'Export Logs', 'vigilante' ),
2003 // Backup strings
2004 'createBackupNow' => __( 'Download Backup', 'vigilante' ),
2005 'downloadBackup' => __( 'Download Backup (.zip)', 'vigilante' ),
2006 /* translators: 1: table count, 2: human-readable size */
2007 'dbTablesTotal' => __( '%1$d tables total (%2$s)', 'vigilante' ),
2008 /* translators: 1: selected count, 2: human-readable size */
2009 'dbTablesSelected' => __( '%1$d tables selected (%2$s)', 'vigilante' ),
2010 // Settings search strings
2011 'searchNoResults' => __( 'No matching settings found.', 'vigilante' ),
2012 /* translators: %d: number of results that did not fit in the list. */
2013 'searchMoreResults' => __( '%d more results. Refine the search to see them.', 'vigilante' ),
2014 // Activity log label maps for JS rendering
2015 'eventTypeLabels' => array(
2016 'login' => __( 'Login', 'vigilante' ),
2017 '2fa' => __( 'Two-factor', 'vigilante' ),
2018 'user' => __( 'User', 'vigilante' ),
2019 'content' => __( 'Content', 'vigilante' ),
2020 'plugin' => __( 'Plugin', 'vigilante' ),
2021 'theme' => __( 'Theme', 'vigilante' ),
2022 'settings' => __( 'Settings', 'vigilante' ),
2023 'comment' => __( 'Comment', 'vigilante' ),
2024 'media' => __( 'Media', 'vigilante' ),
2025 'firewall' => __( 'Firewall', 'vigilante' ),
2026 'file' => __( 'File', 'vigilante' ),
2027 'security' => __( 'Security', 'vigilante' ),
2028 'system' => __( 'System', 'vigilante' ),
2029 ),
2030 'severityLabels' => array(
2031 'info' => __( 'Info', 'vigilante' ),
2032 'warning' => __( 'Warning', 'vigilante' ),
2033 'critical' => __( 'Critical', 'vigilante' ),
2034 ),
2035 // Password reset strings
2036 'noUsersFoundSearch' => __( 'No users found', 'vigilante' ),
2037 /* translators: %d: number of users */
2038 'confirmForceReset' => __( 'Force password reset for %d user(s)? A password reset email will be sent to each user.', 'vigilante' ),
2039 'warningResettingSelf' => __( 'WARNING: You are including yourself. Your session will end and you will need to set a new password.', 'vigilante' ),
2040 'processing' => __( 'Processing...', 'vigilante' ),
2041 'anErrorOccurred' => __( 'An error occurred', 'vigilante' ),
2042 'forceResetSelected' => __( 'Force Reset for Selected Users', 'vigilante' ),
2043 'confirmForceResetAll' => __( 'This will force ALL users to reset their password. All users will receive a password reset email. Are you sure you want to continue?', 'vigilante' ),
2044 'warningResettingSelfAll' => __( 'WARNING: You are including yourself. Your session will end immediately.', 'vigilante' ),
2045 'forceResetAll' => __( 'Force Reset for ALL Users', 'vigilante' ),
2046 // Role-based password reset strings
2047 /* translators: %d: number of users */
2048 'confirmForceResetByRole' => __( 'Force password reset for %d user(s) with the selected roles? A password reset email will be sent to each user.', 'vigilante' ),
2049 'noRolesSelected' => __( 'Please select at least one role.', 'vigilante' ),
2050 'forceResetByRole' => __( 'Force Reset for Selected Roles', 'vigilante' ),
2051 // User approval strings
2052 'confirmApprove' => __( 'Approve this user?', 'vigilante' ),
2053 'approve' => __( 'Approve', 'vigilante' ),
2054 'rejectReason' => __( 'Enter rejection reason (optional):', 'vigilante' ),
2055 'reject' => __( 'Reject', 'vigilante' ),
2056 'noPending' => __( 'No pending registrations.', 'vigilante' ),
2057 // Session management strings
2058 'confirmRevoke' => __( 'Revoke this session?', 'vigilante' ),
2059 'confirmRevokeAll' => __( 'Revoke all other sessions?', 'vigilante' ),
2060 'revokeOthers' => __( 'Revoke All Other Sessions', 'vigilante' ),
2061 'confirmRevokeAllUser' => __( 'Revoke ALL sessions for this user? They will be logged out everywhere.', 'vigilante' ),
2062 'sessionsFor' => __( 'Sessions for:', 'vigilante' ),
2063 'noSessions' => __( 'No active sessions', 'vigilante' ),
2064 'revoke' => __( 'Revoke', 'vigilante' ),
2065 'noUsers' => __( 'No users found', 'vigilante' ),
2066 // Time ago strings
2067 'timeYear' => __( 'year', 'vigilante' ),
2068 'timeYears' => __( 'years', 'vigilante' ),
2069 'timeMonth' => __( 'month', 'vigilante' ),
2070 'timeMonths' => __( 'months', 'vigilante' ),
2071 'timeDay' => __( 'day', 'vigilante' ),
2072 'timeDays' => __( 'days', 'vigilante' ),
2073 'timeHour' => __( 'hour', 'vigilante' ),
2074 'timeHours' => __( 'hours', 'vigilante' ),
2075 'timeMinute' => __( 'minute', 'vigilante' ),
2076 'timeMinutes' => __( 'minutes', 'vigilante' ),
2077 /* translators: %1$d: count, %2$s: time unit */
2078 'timeAgo' => __( '%1$d %2$s ago', 'vigilante' ),
2079 'justNow' => __( 'Just now', 'vigilante' ),
2080 // Pagination strings
2081 /* translators: 1: first item number, 2: last item number, 3: total items */
2082 'paginationOf' => __( '%1$d–%2$d of %3$d', 'vigilante' ),
2083 'paginationEmpty' => __( '0 items', 'vigilante' ),
2084 // Security Analyzer strings
2085 'analyzerFastPhase' => __( 'Running fast checks…', 'vigilante' ),
2086 'analyzerSlowPhase' => __( 'Running remote checks…', 'vigilante' ),
2087 'analyzerScanComplete' => __( 'Security scan complete.', 'vigilante' ),
2088 'analyzerScanFailed' => __( 'Security scan failed.', 'vigilante' ),
2089 'analyzerShowDetails' => __( 'Show detailed breakdown', 'vigilante' ),
2090 'analyzerHideDetails' => __( 'Hide detailed breakdown', 'vigilante' ),
2091 'analyzerGoToSetting' => __( 'Go to setting', 'vigilante' ),
2092 'analyzerSettingsSaved' => __( 'Analyzer settings saved.', 'vigilante' ),
2093 'analyzerLastScanJustNow' => __( 'Last scan just now', 'vigilante' ),
2094 'analyzerQualityExcellent' => __( 'Excellent', 'vigilante' ),
2095 'analyzerQualityGood' => __( 'Good', 'vigilante' ),
2096 'analyzerQualityFair' => __( 'Fair', 'vigilante' ),
2097 'analyzerQualityPoor' => __( 'Poor', 'vigilante' ),
2098 'analyzerQualityCritical' => __( 'Critical', 'vigilante' ),
2099 'analyzerNotMeasured' => __( 'Not measured', 'vigilante' ),
2100 'analyzerLearnMore' => __( 'Learn more', 'vigilante' ),
2101 'analyzerInfoAllClear' => __( 'All clear', 'vigilante' ),
2102 /* translators: %d: number of findings in an info-only category */
2103 'analyzerInfoFindings' => __( '%d findings', 'vigilante' ),
2104 ),
2105 ) );
2106
2107 // 2FA Admin assets
2108 wp_enqueue_style(
2109 'vigilante-2fa-admin',
2110 VIGILANTE_ASSETS_URL . 'css/two-factor-admin.css',
2111 array( 'vigilante-admin' ),
2112 VIGILANTE_VERSION
2113 );
2114
2115 wp_enqueue_script(
2116 'vigilante-2fa-admin',
2117 VIGILANTE_ASSETS_URL . 'js/two-factor-admin.js',
2118 array( 'jquery', 'vigilante-admin' ),
2119 VIGILANTE_VERSION,
2120 true
2121 );
2122 }
2123
2124 /**
2125 * Show admin notices
2126 */
2127 /**
2128 * Self-protection notice.
2129 *
2130 * A change to Vigilant own files is shown on every admin screen, because
2131 * waiting for someone to open the plugin is exactly what an attacker who
2132 * patched it would want. A warning is shown only on Vigilant screens, so
2133 * the notice that matters is not diluted. Neither is dismissible, both are
2134 * for administrators only, and on a network the steps depend on whether
2135 * the person can install plugins at all.
2136 */
2137 public function maybe_show_self_protection_notice() {
2138 if ( ! current_user_can( 'manage_options' ) ) {
2139 return;
2140 }
2141 $summary = $this->self_integrity_summary();
2142 $tone = $summary['tone'];
2143 if ( ! in_array( $tone, array( 'critical', 'off', 'warning' ), true ) ) {
2144 return;
2145 }
2146
2147 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the screen slug to decide where a notice is shown; no action taken.
2148 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
2149 $on_vigilante = ( 0 === strpos( $page, 'vigilante' ) );
2150 if ( 'warning' === $tone && ! $on_vigilante ) {
2151 return;
2152 }
2153 $alarma = in_array( $tone, array( 'critical', 'off' ), true );
2154
2155 $link = admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' );
2156 $network = is_multisite() && ! current_user_can( 'update_plugins' );
2157 ?>
2158 <div class="notice notice-<?php echo $alarma ? 'error' : 'warning'; ?> vigilante-self-notice">
2159 <p class="vigilante-self-notice-title">
2160 <span class="dashicons dashicons-shield" aria-hidden="true"></span>
2161 <strong>
2162 <?php
2163 if ( 'critical' === $tone && in_array( $summary['kind'], array( 'tasks', 'hooks' ), true ) ) {
2164 echo esc_html( $this->self_integrity_headline( $tone, $summary['state'] ) );
2165 } elseif ( 'critical' === $tone ) {
2166 esc_html_e( 'Vigilant detected changes in its own files', 'vigilante' );
2167 } elseif ( 'off' === $tone ) {
2168 esc_html_e( 'Vigilant self-protection is switched off by code', 'vigilante' );
2169 } else {
2170 echo esc_html( $this->self_integrity_headline( $tone, $summary['state'] ) );
2171 }
2172 ?>
2173 </strong>
2174 </p>
2175 <p class="vigilante-self-notice-text">
2176 <span>
2177 <?php
2178 if ( 'critical' === $tone && ! empty( $summary['tasks'] ) ) {
2179 esc_html_e( 'Vigilant scheduled them again each time, but something keeps removing them. File Integrity says what to look for.', 'vigilante' );
2180 } elseif ( 'critical' === $tone && 'hooks' === $summary['kind'] ) {
2181 esc_html_e( 'Code on this site removed hooks that run the self-check of Vigilant. File Integrity says which ones and what to look for.', 'vigilante' );
2182 } elseif ( 'critical' === $tone ) {
2183 esc_html_e( 'Your security plugin may have been tampered with, and while that is true nothing it reports can be trusted.', 'vigilante' );
2184 } elseif ( 'off' === $tone ) {
2185 esc_html_e( 'Nothing is checking that Vigilant own files are intact. File Integrity says which file switches it off.', 'vigilante' );
2186 } else {
2187 esc_html_e( 'File Integrity says what was found, what it means and what to do about it.', 'vigilante' );
2188 }
2189 // Repairing is for a case about the files: tasks, removed
2190 // hooks and a filter are not repaired with a clean copy.
2191 if ( $network && ! empty( $summary['about_files'] ) ) {
2192 echo ' ' . esc_html__( 'Only your network administrator can repair Vigilant, because its files are shared by every site in the network. Let them know.', 'vigilante' );
2193 }
2194 ?>
2195 </span>
2196 <?php
2197 /*
2198 * One button, on the same line as the text it belongs to, and it
2199 * leads to the whole story. Repairing from a notice, without
2200 * seeing which files, what it means and what it will do, is
2201 * asking someone to fix what they have not read: the repair
2202 * button lives in the card, after all that.
2203 */
2204 ?>
2205 <a class="button button-primary button-small" href="<?php echo esc_url( $link ); ?>">
2206 <?php esc_html_e( 'See what to do', 'vigilante' ); ?>
2207 </a>
2208 </p>
2209 </div>
2210 <?php
2211 }
2212
2213 public function show_admin_notices() {
2214 $this->maybe_show_self_protection_notice();
2215
2216 // Activation notice
2217 if ( get_transient( 'vigilante_activated' ) ) {
2218 ?>
2219 <div class="notice notice-success is-dismissible vigilante-activation-notice">
2220 <p>
2221 <span class="dashicons dashicons-shield vigilante-notice-icon"></span>
2222 <strong><?php esc_html_e( 'Vigilant activated successfully!', 'vigilante' ); ?></strong>
2223 <?php esc_html_e( 'Security protection is now active.', 'vigilante' ); ?>
2224 <a href="<?php echo esc_url( admin_url( 'admin.php?page=vigilante' ) ); ?>">
2225 <?php esc_html_e( 'Configure settings', 'vigilante' ); ?>
2226 </a>
2227 </p>
2228 </div>
2229 <?php
2230 delete_transient( 'vigilante_activated' );
2231 }
2232
2233 // Under Attack mode notice (non-dismissible, shown on all admin pages)
2234 $ua_status = get_option( Vigilante_Under_Attack::OPTION_NAME, array() );
2235 if ( ! empty( $ua_status['active'] ) ) {
2236 $ua_remaining = ( $ua_status['activated_at'] + $ua_status['duration'] ) - time();
2237 if ( $ua_remaining > 0 ) {
2238 $ua_hours = floor( $ua_remaining / 3600 );
2239 $ua_mins = floor( ( $ua_remaining % 3600 ) / 60 );
2240 $dashboard_url = admin_url( 'admin.php?page=vigilante' );
2241 ?>
2242 <div class="notice notice-warning vigilante-ua-notice">
2243 <p>
2244 <span class="dashicons dashicons-shield"></span>
2245 <strong><?php esc_html_e( 'Under Attack mode is active', 'vigilante' ); ?></strong>
2246 &mdash;
2247 <?php
2248 printf(
2249 /* translators: 1: Hours, 2: Minutes */
2250 esc_html__( '%1$dh %2$dm remaining.', 'vigilante' ),
2251 absint( $ua_hours ),
2252 absint( $ua_mins )
2253 );
2254 ?>
2255 <a href="<?php echo esc_url( $dashboard_url ); ?>">
2256 <?php esc_html_e( 'Go to Vigilant dashboard', 'vigilante' ); ?>
2257 </a>
2258 </p>
2259 <p>
2260 <em><?php esc_html_e( 'Vigilant has applied the Maximum preset plus extra hardening on top of your previous configuration. Any changes you make to Vigilant settings while this mode is active will be reverted when it ends.', 'vigilante' ); ?></em>
2261 </p>
2262 <?php
2263 // The cache-bypass rules could not be written (a host where
2264 // WordPress cannot write files by itself, a held lock, a
2265 // failed read-back): show them, so they can be added by hand.
2266 $ua_instance = new Vigilante_Under_Attack( $this->settings, $this->activity_log );
2267 if ( $ua_instance->cache_rules_missing() ) :
2268 ?>
2269 <p>
2270 <strong><?php esc_html_e( 'The cache-bypass rules could not be written to your .htaccess.', 'vigilante' ); ?></strong>
2271 <?php esc_html_e( 'Without them a page cache may keep serving stored pages during the attack. Add this block at the top of the .htaccess in your site root (the activity log records why it was not written):', 'vigilante' ); ?>
2272 </p>
2273 <textarea readonly rows="9" class="large-text code" onclick="this.select();"><?php echo esc_textarea( Vigilante_Under_Attack::get_cache_bypass_block() ); ?></textarea>
2274 <?php endif; ?>
2275 </div>
2276 <?php
2277 }
2278 }
2279
2280 // Proxy/CDN detection: if IP detection is set to "direct" but requests
2281 // arrive with a forwarded-for header carrying a different valid IP, the
2282 // site is very likely behind a proxy/CDN that has not been declared, so
2283 // the firewall is seeing the proxy IP for every visitor. Guide the admin.
2284 //
2285 // Two deliberate silencers (2.9.2):
2286 // - A loopback forwarded IP (::1 / 127.x) means the person browsing IS
2287 // the machine itself: that only happens in local development stacks
2288 // (Local's nginx router, Docker...), never behind a production
2289 // proxy/CDN, so the notice would be pure noise there.
2290 // - The notice is dismissible and the dismissal persists site-wide via
2291 // the vigilante_dismissed_notices option (the admin evaluated it and
2292 // decided; nagging forever helps nobody).
2293 $dismissed_notices = get_option( 'vigilante_dismissed_notices', array() );
2294 if (
2295 current_user_can( 'manage_options' )
2296 && '' === Vigilante_IP_Utils::trusted_proxy_header()
2297 && ! isset( $dismissed_notices['proxy_detection'] )
2298 ) {
2299 $remote = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
2300 $detected = '';
2301 foreach ( Vigilante_IP_Utils::trusted_header_map() as $proxy_label => $server_key ) {
2302 if ( empty( $_SERVER[ $server_key ] ) ) {
2303 continue;
2304 }
2305 $candidate = sanitize_text_field( wp_unslash( $_SERVER[ $server_key ] ) );
2306 if ( false !== strpos( $candidate, ',' ) ) {
2307 $parts = explode( ',', $candidate );
2308 $candidate = trim( $parts[0] );
2309 }
2310 if ( ! filter_var( $candidate, FILTER_VALIDATE_IP ) || $candidate === $remote ) {
2311 continue;
2312 }
2313 // Loopback forwarded IP = local development, not a real proxy.
2314 if ( '::1' === $candidate || 0 === strpos( $candidate, '127.' ) ) {
2315 continue;
2316 }
2317 $detected = $proxy_label;
2318 break;
2319 }
2320
2321 if ( '' !== $detected ) {
2322 $firewall_url = admin_url( 'admin.php?page=vigilante&tab=firewall' );
2323 $detail_message = sprintf(
2324 /* translators: %s: detected forwarded header name wrapped in a code tag. */
2325 esc_html__( 'Requests are arriving with a %s header, but visitor IP detection is set to direct connection. The firewall is reading the proxy address instead of the real visitor IP, which affects the IP lists and rate limiting.', 'vigilante' ),
2326 '<code>' . esc_html( strtoupper( $detected ) ) . '</code>'
2327 );
2328 ?>
2329 <div class="notice notice-warning is-dismissible vigilante-proxy-notice">
2330 <p>
2331 <span class="dashicons dashicons-shield"></span>
2332 <strong><?php esc_html_e( 'Vigilant: this site looks like it is behind a proxy or CDN', 'vigilante' ); ?></strong>
2333 </p>
2334 <p><?php echo wp_kses_post( $detail_message ); ?></p>
2335 <p>
2336 <a href="<?php echo esc_url( $firewall_url ); ?>" class="button button-secondary">
2337 <?php esc_html_e( 'Set your proxy in Firewall settings', 'vigilante' ); ?>
2338 </a>
2339 </p>
2340 </div>
2341 <script>
2342 jQuery( function ( $ ) {
2343 $( document ).on( 'click', '.vigilante-proxy-notice .notice-dismiss', function () {
2344 $.post( ajaxurl, {
2345 action: 'vigilante_dismiss_notice',
2346 notice_id: 'proxy_detection',
2347 nonce: <?php echo wp_json_encode( wp_create_nonce( 'vigilante_dismiss_notice' ) ); ?>
2348 } );
2349 } );
2350 } );
2351 </script>
2352 <?php
2353 }
2354 }
2355 }
2356
2357 /**
2358 * Render settings page
2359 */
2360 public function render_settings_page() {
2361 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2362 $this->current_tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'dashboard';
2363
2364 if ( ! array_key_exists( $this->current_tab, $this->tabs ) ) {
2365 $this->current_tab = 'dashboard';
2366 }
2367 ?>
2368 <div class="wrap vigilante-admin-wrap">
2369 <div class="vigilante-header-row">
2370 <h1 class="vigilante-page-title">
2371 <img src="<?php echo esc_url( VIGILANTE_ASSETS_URL . 'images/icon.png' ); ?>" alt="Vigilante" class="vigilante-title-icon">
2372 <?php esc_html_e( 'Vigilant', 'vigilante' ); ?>
2373 <span class="vigilante-version">v<?php echo esc_html( VIGILANTE_VERSION ); ?></span>
2374 </h1>
2375 <div class="vigilante-search-wrapper">
2376 <div class="vigilante-search-input-wrap">
2377 <span class="vigilante-search-icon dashicons dashicons-search" aria-hidden="true"></span>
2378 <input type="search" id="vigilante-settings-search" class="vigilante-settings-search" aria-label="<?php esc_attr_e( 'Search settings', 'vigilante' ); ?>" placeholder="<?php esc_attr_e( 'Search settings…', 'vigilante' ); ?>" autocomplete="off">
2379 <span class="vigilante-search-shortcut" aria-hidden="true">/</span>
2380 </div>
2381 <div id="vigilante-settings-search-results" class="vigilante-search-results" hidden role="listbox"></div>
2382 </div>
2383 </div>
2384
2385 <?php $this->render_tabs(); ?>
2386
2387 <div class="vigilante-content">
2388 <div class="vigilante-main">
2389 <?php $this->render_tab_content(); ?>
2390 </div>
2391 <div class="vigilante-sidebar">
2392 <?php $this->render_sidebar(); ?>
2393 </div>
2394 </div>
2395 </div>
2396 <?php
2397 }
2398
2399 /**
2400 * Render tabs navigation
2401 */
2402 private function render_tabs() {
2403 $options = $this->settings->get_all_options();
2404
2405 // Map tabs to modules
2406 $tab_to_module = array(
2407 'firewall' => 'firewall',
2408 'headers' => 'security_headers',
2409 'login' => 'login_security',
2410 'rest-api' => 'rest_api_security',
2411 'users' => 'user_security',
2412 'wp-hardening' => 'wp_hardening',
2413 'file-integrity' => 'file_integrity',
2414 'activity-log' => 'activity_log',
2415 );
2416 ?>
2417 <nav class="nav-tab-wrapper vigilante-nav-tabs">
2418 <?php foreach ( $this->tabs as $tab_id => $tab_name ) :
2419 $is_disabled = false;
2420 $module = isset( $tab_to_module[ $tab_id ] ) ? $tab_to_module[ $tab_id ] : null;
2421 if ( $module && empty( $options['modules'][ $module ] ) ) {
2422 $is_disabled = true;
2423 }
2424 ?>
2425 <a href="<?php echo esc_url( admin_url( 'admin.php?page=vigilante&tab=' . $tab_id ) ); ?>"
2426 class="nav-tab <?php echo $this->current_tab === $tab_id ? 'nav-tab-active' : ''; ?> <?php echo $is_disabled ? 'vigilante-tab-disabled' : ''; ?>"
2427 <?php if ( $is_disabled ) : ?>title="<?php esc_attr_e( 'Module Disabled', 'vigilante' ); ?>"<?php endif; ?>>
2428 <?php echo esc_html( $tab_name ); ?>
2429 <?php if ( $is_disabled ) : ?><span class="vigilante-tab-off">OFF</span><?php endif; ?>
2430 </a>
2431 <?php endforeach; ?>
2432 </nav>
2433 <?php
2434 }
2435
2436 /**
2437 * Render current tab content
2438 */
2439 private function render_tab_content() {
2440 $method = 'render_tab_' . str_replace( '-', '_', $this->current_tab );
2441
2442 if ( method_exists( $this, $method ) ) {
2443 $this->$method();
2444 } else {
2445 $this->render_tab_coming_soon();
2446 }
2447 }
2448
2449 /**
2450 * Render coming soon placeholder
2451 */
2452 private function render_tab_coming_soon() {
2453 ?>
2454 <div class="vigilante-settings-section">
2455 <h2><?php esc_html_e( 'Coming Soon', 'vigilante' ); ?></h2>
2456 <p><?php esc_html_e( 'This section is under development.', 'vigilante' ); ?></p>
2457 </div>
2458 <?php
2459 }
2460
2461 /**
2462 * Values to display for a section that this site does not control
2463 *
2464 * On a subsite the stored options are its own copy, which nothing acts on:
2465 * wp-config.php and .htaccess are written from the main site. Painting the
2466 * local copy describes a configuration that is not running, so a subsite
2467 * admin sees a box ticked here and the constant absent from the file, or the
2468 * other way round. Read the main site's values instead, which are the ones in
2469 * force, and fall back to the local ones if they cannot be read.
2470 *
2471 * @since 2.9.8
2472 *
2473 * @param string $section Settings section.
2474 * @return array
2475 */
2476 private function get_section_for_display( $section ) {
2477 $local = $this->settings->get_section( $section );
2478
2479 if ( ! $this->shared_files_locked() ) {
2480 return $local;
2481 }
2482
2483 // shared_files_locked() is only true on multisite, where get_blog_option() exists.
2484 $main = get_blog_option( get_main_site_id(), Vigilante_Settings::OPTION_NAME, array() );
2485
2486 if ( ! is_array( $main ) || empty( $main[ $section ] ) || ! is_array( $main[ $section ] ) ) {
2487 return $local;
2488 }
2489
2490 return wp_parse_args( $main[ $section ], $local );
2491 }
2492
2493 /**
2494 * Whether the sections that write wp-config.php and .htaccess are read-only here
2495 *
2496 * True on a network when this is not the main site, or the user is not a
2497 * network administrator. See Vigilante_Settings::can_write_shared_files().
2498 *
2499 * @since 2.9.8
2500 *
2501 * @return bool
2502 */
2503 private function shared_files_locked() {
2504 return ! Vigilante_Settings::can_write_shared_files();
2505 }
2506
2507 /**
2508 * Whether this is the main site and the user cannot change what it builds the shared files from
2509 *
2510 * See Vigilante_Settings::get_main_site_file_settings(). On a subsite those
2511 * settings only act on that site, so they are never locked there.
2512 *
2513 * @since 2.11.6
2514 *
2515 * @return bool
2516 */
2517 private function main_site_files_locked() {
2518 return $this->shared_files_locked() && Vigilante_Settings::owns_shared_files();
2519 }
2520
2521 /**
2522 * Sentence added to a bulk change when some settings were left as they were
2523 *
2524 * Importing a file, applying a preset and restoring the defaults touch every
2525 * section at once, so the user is told that the shared file settings did
2526 * not move.
2527 *
2528 * @since 2.11.6
2529 *
2530 * @return string Empty when the user can change every setting.
2531 */
2532 private function locked_file_settings_message() {
2533 if ( ! Vigilante_Settings::get_locked_file_settings() ) {
2534 return '';
2535 }
2536
2537 return ' ' . __( 'The settings that end up in wp-config.php or .htaccess were left as they were.', 'vigilante' ) . ' ' . Vigilante_Settings::get_shared_files_notice();
2538 }
2539
2540 /**
2541 * Print the shared-files notice for a section that cannot be edited here
2542 *
2543 * @since 2.9.8
2544 */
2545 private function render_shared_files_notice() {
2546 if ( ! $this->shared_files_locked() ) {
2547 return;
2548 }
2549 ?>
2550 <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;">
2551 <p style="margin:0;"><?php echo esc_html( Vigilante_Settings::get_shared_files_notice() ); ?></p>
2552 </div>
2553 <?php
2554 }
2555
2556 /**
2557 * Acting on another user's account needs permission over that user
2558 *
2559 * Since 2.10.3 the handlers behind these tools ask for edit_user over the
2560 * target, which is the rule WordPress itself applies. On a network the core
2561 * grants edit_user only to network administrators, so for anybody else these
2562 * controls do nothing. Better to say so than to paint a button that silently
2563 * skips every user.
2564 *
2565 * @since 2.10.4
2566 * @return bool
2567 */
2568 private function forwarded_chain_readings() {
2569 // Shown, not decided on: the firewall resolves the address elsewhere.
2570 $chain = Vigilante_IP_Utils::trusted_forwarded_for();
2571
2572 if ( '' === $chain ) {
2573 return array();
2574 }
2575
2576 $public = array();
2577
2578 foreach ( explode( ',', $chain ) as $entry ) {
2579 $address = Vigilante_IP_Utils::unmap_ipv4( trim( $entry ) );
2580
2581 if ( filter_var( $address, FILTER_VALIDATE_IP ) && ! Vigilante_IP_Utils::is_own_network( $address ) ) {
2582 $public[] = $address;
2583 }
2584 }
2585
2586 if ( count( $public ) < 2 ) {
2587 return array();
2588 }
2589
2590 return array(
2591 'now' => Vigilante_IP_Utils::client_from_chain( $chain ),
2592 'before' => $public[0],
2593 );
2594 }
2595
2596 /**
2597 * Whether the user tools of this screen are out of reach for this user
2598 *
2599 * @return bool
2600 */
2601 private function user_actions_locked() {
2602 // On a single site edit_user maps to edit_users, which a custom role with
2603 // manage_options may lack: since 2.11.8 approving and rejecting a pending
2604 // registration ask for it, so the buttons have to say so there too.
2605 return is_multisite() ? ! current_user_can( 'manage_network_users' ) : ! current_user_can( 'edit_users' );
2606 }
2607
2608 /**
2609 * Print the notice for user tools that cannot be used from this site
2610 *
2611 * @since 2.10.4
2612 */
2613 private function render_user_actions_notice() {
2614 if ( ! $this->user_actions_locked() ) {
2615 return;
2616 }
2617 ?>
2618 <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;">
2619 <?php if ( is_multisite() ) : ?>
2620 <p style="margin:0;"><?php esc_html_e( 'These tools act on user accounts, which on a network belong to the whole network rather than to one site. WordPress reserves that to network administrators, so they are managed from the network admin.', 'vigilante' ); ?></p>
2621 <?php else : ?>
2622 <p style="margin:0;"><?php esc_html_e( 'These tools act on other user accounts, and your role cannot edit users, so they are not available to you.', 'vigilante' ); ?></p>
2623 <?php endif; ?>
2624 </div>
2625 <?php
2626 }
2627
2628 /**
2629 * Approving a change to the shared config files needs the network
2630 *
2631 * Since 2.11.3 the handler behind the Approve button asks for
2632 * manage_network_options, because the two files it approves, wp-config.php
2633 * and the root .htaccess, belong to the installation, and so does the
2634 * record of them. The button, though, went on being painted for everybody,
2635 * so the administrator of a subsite saw the warning, saw the button,
2636 * pressed it and got "Permission denied" with no explanation. That is
2637 * exactly what user_actions_locked() above exists to avoid, one release
2638 * later and one screen over. Flagged by @calzbert.
2639 *
2640 * @since 2.11.4
2641 * @return bool
2642 */
2643 private function critical_approval_locked() {
2644 return is_multisite() && ! current_user_can( 'manage_network_options' );
2645 }
2646
2647 /**
2648 * The line that replaces the Approve button where it cannot be used
2649 *
2650 * @since 2.11.4
2651 * @return string
2652 */
2653 private function critical_approval_notice() {
2654 return __( 'These files belong to the whole network rather than to this site, so a change to them is approved from the network admin.', 'vigilante' );
2655 }
2656
2657 /**
2658 * Check if module is disabled and render warning
2659 *
2660 * @param string $module_key Module key.
2661 * @return bool True if disabled.
2662 */
2663 private function render_module_disabled_notice( $module_key ) {
2664 if ( $this->settings->is_module_enabled( $module_key ) ) {
2665 return false;
2666 }
2667
2668 $module_labels = $this->settings->get_module_labels();
2669 $module_name = isset( $module_labels[ $module_key ] ) ? $module_labels[ $module_key ] : $module_key;
2670 ?>
2671 <div class="notice notice-warning vigilante-module-disabled-notice">
2672 <p>
2673 <strong><?php esc_html_e( 'Module Disabled', 'vigilante' ); ?></strong> -
2674 <?php
2675 printf(
2676 /* translators: %s: Module name */
2677 esc_html__( 'The %s module is currently disabled. Enable it from the Dashboard to use these settings.', 'vigilante' ),
2678 esc_html( $module_name )
2679 );
2680 ?>
2681 </p>
2682 <p>
2683 <a href="<?php echo esc_url( admin_url( 'admin.php?page=vigilante&tab=dashboard' ) ); ?>" class="button">
2684 <?php esc_html_e( 'Go to Dashboard', 'vigilante' ); ?>
2685 </a>
2686 </p>
2687 </div>
2688 <?php
2689 return true;
2690 }
2691
2692 /**
2693 * Render the Security Analyzer (Security Check) widget + expandable full report.
2694 *
2695 * Lives in the Dashboard tab above the configuration card and the modules
2696 * grid: it carries the only letter of the page. Uses the last persisted scan to hydrate server-side so the
2697 * first paint shows real data; "Scan now" runs the 2-phase AJAX to refresh.
2698 *
2699 * @param array $last_scan Result of Vigilante_Security_Analyzer::get_last_scan().
2700 * @param array $history Score history (oldest first).
2701 * @param array $categories_def Category metadata (slug => label/max).
2702 * @param array $analyzer_settings Settings subsection for weekly cron + email.
2703 */
2704 private function render_analyzer_widget( $last_scan, $history, $categories_def, $analyzer_settings ) {
2705 $has_data = ! empty( $last_scan['ran_at'] );
2706 $score = isset( $last_scan['score'] ) ? (int) $last_scan['score'] : 0;
2707 $grade = isset( $last_scan['grade'] ) ? (string) $last_scan['grade'] : '';
2708 $counts = isset( $last_scan['counts'] ) && is_array( $last_scan['counts'] ) ? $last_scan['counts'] : array();
2709 $ran_at_human = $has_data
2710 ? sprintf(
2711 /* translators: %s: relative time like "2 hours" */
2712 __( 'Last scan %s ago', 'vigilante' ),
2713 human_time_diff( (int) $last_scan['ran_at'], time() )
2714 )
2715 : __( 'Never scanned', 'vigilante' );
2716 $categories = isset( $last_scan['categories'] ) && is_array( $last_scan['categories'] ) ? $last_scan['categories'] : array();
2717 $weekly_enabled = ! isset( $analyzer_settings['weekly_scan_enabled'] ) || ! empty( $analyzer_settings['weekly_scan_enabled'] );
2718 $email_enabled = ! empty( $analyzer_settings['email_on_regression'] );
2719
2720 /*
2721 * Self-protection caps the score, and it does so here and not only in
2722 * the stored report: the report is reused until the next Security
2723 * Check, so tampering found after it ran would otherwise be shown next
2724 * to the score the site earned before it happened.
2725 */
2726 $self_summary = $this->self_integrity_summary();
2727 $self_capped = in_array( $self_summary['tone'], array( 'critical', 'off' ), true )
2728 || ( 'self_integrity' === ( isset( $last_scan['capped_by'] ) ? $last_scan['capped_by'] : '' ) );
2729 // Why the note under the title says the score is capped: something
2730 // that is true now, about tasks or about files, or only what the
2731 // stored report found when it ran. In that last case the note does not
2732 // say that anything is wrong now: a report stored before the notice
2733 // about tasks closed, or before a repair, kept saying in the present
2734 // that the files had been changed until the next scan.
2735 $self_capped_now = in_array( $self_summary['tone'], array( 'critical', 'off' ), true );
2736 $self_capped_why = 'stored';
2737 if ( $self_capped_now ) {
2738 $self_capped_why = 'files';
2739 if ( 'tasks' === $self_summary['kind'] ) {
2740 $self_capped_why = 'tasks';
2741 } elseif ( in_array( $self_summary['kind'], array( 'off', 'hooks' ), true ) ) {
2742 // The check itself is switched off or unhooked: no file was found changed.
2743 $self_capped_why = 'check';
2744 }
2745 }
2746 if ( $self_capped && $has_data && $score > Vigilante_Security_Analyzer::SCORE_CAP_ON_TAMPER ) {
2747 $score = Vigilante_Security_Analyzer::SCORE_CAP_ON_TAMPER;
2748 }
2749 if ( $has_data && '' !== $grade ) {
2750 $grade = Vigilante_SA_Helpers::grade_for_score( $score );
2751 }
2752
2753 $quality = self::analyzer_quality_tag( $score );
2754 $not_measured = isset( $counts['skip'] ) ? (int) $counts['skip'] : 0;
2755 ?>
2756 <div class="vigilante-analyzer" id="vigilante-analyzer"
2757 data-has-data="<?php echo $has_data ? '1' : '0'; ?>">
2758 <div class="vigilante-analyzer-header">
2759 <div class="vigilante-analyzer-title">
2760 <h2>
2761 <span class="dashicons dashicons-shield-alt" aria-hidden="true"></span>
2762 <?php esc_html_e( 'Security Check', 'vigilante' ); ?>
2763 </h2>
2764 <p class="description">
2765 <?php esc_html_e( 'On-demand audit of what an attacker would see right now, plus internal checks impossible from the outside.', 'vigilante' ); ?>
2766 </p>
2767 <p class="vigilante-analyzer-last-scan" data-role="ran-at">
2768 <span class="dashicons dashicons-clock" aria-hidden="true"></span>
2769 <?php echo esc_html( $ran_at_human ); ?>
2770 </p>
2771 </div>
2772 <div class="vigilante-analyzer-actions">
2773 <button type="button" class="button button-primary" id="vigilante-analyzer-scan">
2774 <span class="dashicons dashicons-update" aria-hidden="true"></span>
2775 <?php esc_html_e( 'Scan now', 'vigilante' ); ?>
2776 </button>
2777 </div>
2778 </div>
2779
2780 <?php if ( $self_capped ) : ?>
2781 <p class="vigilante-analyzer-capped" data-why="<?php echo esc_attr( $self_capped_why ); ?>">
2782 <span class="dashicons dashicons-shield" aria-hidden="true"></span>
2783 <strong><?php 'stored' === $self_capped_why ? esc_html_e( 'This score is from an earlier scan.', 'vigilante' ) : esc_html_e( 'This score is not reliable right now.', 'vigilante' ); ?></strong>
2784 <?php
2785 if ( 'stored' === $self_capped_why ) {
2786 esc_html_e( 'When this scan ran, Vigilant self-protection was reporting a critical finding, so the score was held at the bottom of the scale. Run a new scan to bring it up to date.', 'vigilante' );
2787 } elseif ( 'tasks' === $self_capped_why ) {
2788 esc_html_e( 'Something keeps removing Vigilant scheduled tasks, so its scans and its daily check do not run when they should. The score is held at the bottom of the scale while that goes on.', 'vigilante' );
2789 } elseif ( 'check' === $self_capped_why ) {
2790 esc_html_e( 'Code on this site is keeping Vigilant self-protection from running as it should, so the code that produces every other result on this page is not being checked as it should. The score is held at the bottom of the scale while that goes on.', 'vigilante' );
2791 } else {
2792 esc_html_e( 'Vigilant own files have been changed, and every other result on this page is produced by that same code. The score is held at the bottom of the scale until the files verify clean again.', 'vigilante' );
2793 }
2794 ?>
2795 <a href="<?php echo esc_url( admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' ) ); ?>">
2796 <?php esc_html_e( 'See what to do', 'vigilante' ); ?>
2797 </a>
2798 </p>
2799 <?php endif; ?>
2800
2801 <div class="vigilante-analyzer-summary">
2802 <div class="vigilante-analyzer-score-card">
2803 <?php if ( $has_data && $grade ) : ?>
2804 <div class="vigilante-score-circle vigilante-grade-<?php echo esc_attr( strtolower( $grade ) ); ?>">
2805 <span class="vigilante-grade"><?php echo esc_html( $grade ); ?></span>
2806 <span class="vigilante-score-text"><?php echo esc_html( $score ); ?>%</span>
2807 </div>
2808 <?php else : ?>
2809 <div class="vigilante-score-circle vigilante-grade-empty">
2810 <span class="vigilante-grade">—</span>
2811 <span class="vigilante-score-text"><?php esc_html_e( 'N/A', 'vigilante' ); ?></span>
2812 </div>
2813 <?php endif; ?>
2814 <div class="vigilante-analyzer-score-meta">
2815 <p class="vigilante-analyzer-score-label">
2816 <?php esc_html_e( 'Security Score', 'vigilante' ); ?>
2817 </p>
2818 <span class="vigilante-analyzer-quality-tag vigilante-analyzer-quality-<?php echo esc_attr( $quality['slug'] ); ?>"
2819 data-role="quality-tag">
2820 <?php echo esc_html( $quality['label'] ); ?>
2821 </span>
2822 </div>
2823 </div>
2824
2825 <div class="vigilante-analyzer-counts">
2826 <span class="vigilante-analyzer-count vigilante-analyzer-count--pass">
2827 <span class="dashicons dashicons-yes-alt" aria-hidden="true"></span>
2828 <strong data-role="pass"><?php echo esc_html( isset( $counts['pass'] ) ? $counts['pass'] : 0 ); ?></strong>
2829 <span class="vigilante-analyzer-count-label"><?php esc_html_e( 'Passed', 'vigilante' ); ?></span>
2830 </span>
2831 <span class="vigilante-analyzer-count vigilante-analyzer-count--warn">
2832 <span class="dashicons dashicons-warning" aria-hidden="true"></span>
2833 <strong data-role="warn"><?php echo esc_html( isset( $counts['warn'] ) ? $counts['warn'] : 0 ); ?></strong>
2834 <span class="vigilante-analyzer-count-label"><?php esc_html_e( 'Warnings', 'vigilante' ); ?></span>
2835 </span>
2836 <span class="vigilante-analyzer-count vigilante-analyzer-count--fail">
2837 <span class="dashicons dashicons-dismiss" aria-hidden="true"></span>
2838 <strong data-role="fail"><?php echo esc_html( isset( $counts['fail'] ) ? $counts['fail'] : 0 ); ?></strong>
2839 <span class="vigilante-analyzer-count-label"><?php esc_html_e( 'Failing', 'vigilante' ); ?></span>
2840 </span>
2841 <?php
2842 // What could not be measured is left out of the score, so
2843 // it is said here: a score over fewer checks is not the
2844 // same score.
2845 ?>
2846 <span class="vigilante-analyzer-count vigilante-analyzer-count--skip" data-role="skip-wrap"
2847 title="<?php esc_attr_e( 'Checks that could not be run this time. They are not part of the score.', 'vigilante' ); ?>"
2848 <?php echo $not_measured > 0 ? '' : 'hidden'; ?>>
2849 <span class="dashicons dashicons-minus" aria-hidden="true"></span>
2850 <strong data-role="skip"><?php echo esc_html( $not_measured ); ?></strong>
2851 <span class="vigilante-analyzer-count-label"><?php esc_html_e( 'Not measured', 'vigilante' ); ?></span>
2852 </span>
2853 </div>
2854
2855 <?php
2856 // Sparkline of recent scores. Require at least 3 data points so the trend
2857 // is meaningful (2 points is just a line between dots, no real trend).
2858 $hist_points = array();
2859 foreach ( $history as $h ) {
2860 $hist_points[] = (int) $h['score'];
2861 }
2862 $hist_count = count( $hist_points );
2863 if ( $hist_count >= 3 ) :
2864 $current_score = (int) end( $hist_points );
2865 $previous_score = (int) $hist_points[ $hist_count - 2 ];
2866 $delta = $current_score - $previous_score;
2867 $delta_class = $delta > 0 ? 'vigilante-analyzer-delta--up' : ( $delta < 0 ? 'vigilante-analyzer-delta--down' : 'vigilante-analyzer-delta--flat' );
2868 $delta_icon = $delta > 0 ? 'arrow-up-alt' : ( $delta < 0 ? 'arrow-down-alt' : 'minus' );
2869 if ( 0 === $delta ) {
2870 $delta_text = __( 'No change', 'vigilante' );
2871 } else {
2872 $delta_text = sprintf(
2873 /* translators: %s: signed delta, e.g. "+3" or "-5" */
2874 _n( '%s pt vs. previous scan', '%s pts vs. previous scan', abs( $delta ), 'vigilante' ),
2875 ( $delta > 0 ? '+' : '' ) . (int) $delta
2876 );
2877 }
2878 ?>
2879 <div class="vigilante-analyzer-sparkline-wrap">
2880 <div class="vigilante-analyzer-sparkline-head">
2881 <span class="vigilante-analyzer-sparkline-label">
2882 <?php
2883 echo esc_html( sprintf(
2884 /* translators: %d: number of scans */
2885 _n( 'Score trend (last %d scan)', 'Score trend (last %d scans)', $hist_count, 'vigilante' ),
2886 $hist_count
2887 ) );
2888 ?>
2889 </span>
2890 <span class="vigilante-analyzer-delta <?php echo esc_attr( $delta_class ); ?>">
2891 <span class="dashicons dashicons-<?php echo esc_attr( $delta_icon ); ?>" aria-hidden="true"></span>
2892 <?php echo esc_html( $delta_text ); ?>
2893 </span>
2894 </div>
2895 <div class="vigilante-analyzer-sparkline" data-role="sparkline"
2896 data-points="<?php echo esc_attr( wp_json_encode( $hist_points ) ); ?>">
2897 <?php echo self::sparkline_svg( $hist_points ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Safe SVG from helper ?>
2898 </div>
2899 </div>
2900 <?php elseif ( $has_data ) : ?>
2901 <div class="vigilante-analyzer-sparkline-wrap vigilante-analyzer-sparkline-wrap--placeholder">
2902 <span class="vigilante-analyzer-sparkline-label">
2903 <?php esc_html_e( 'Score trend', 'vigilante' ); ?>
2904 </span>
2905 <p class="vigilante-analyzer-sparkline-hint">
2906 <span class="dashicons dashicons-chart-line" aria-hidden="true"></span>
2907 <?php
2908 $needed = 3 - $hist_count;
2909 echo esc_html( sprintf(
2910 /* translators: %d: number of additional scans needed */
2911 _n( '%d more scan needed to show a trend.', '%d more scans needed to show a trend.', $needed, 'vigilante' ),
2912 $needed
2913 ) );
2914 ?>
2915 </p>
2916 </div>
2917 <?php endif; ?>
2918 </div>
2919
2920 <div class="vigilante-analyzer-toggle-row">
2921 <button type="button" class="button-link vigilante-analyzer-toggle" aria-expanded="false">
2922 <?php esc_html_e( 'Show detailed breakdown', 'vigilante' ); ?>
2923 <span class="vigilante-analyzer-toggle-chevron" aria-hidden="true"></span>
2924 </button>
2925 </div>
2926
2927 <div class="vigilante-analyzer-details" hidden>
2928 <div class="vigilante-analyzer-categories" data-role="categories">
2929 <?php foreach ( $categories_def as $slug => $meta ) :
2930 $cat = isset( $categories[ $slug ] ) ? $categories[ $slug ] : array();
2931 $earned = isset( $cat['earned'] ) ? (int) $cat['earned'] : 0;
2932 $checks = isset( $cat['checks'] ) ? (array) $cat['checks'] : array();
2933 $info_only = ! empty( $meta['info_only'] ) || 0 === (int) $meta['max'];
2934 // The maximum of a category is what its checks could
2935 // score this time: one that could not be measured, or
2936 // that is only informative, is not in it. The report
2937 // adds it up from the checks themselves
2938 // (Vigilante_Security_Analyzer::summarize_categories()),
2939 // so it cannot drift from them. Before the first scan
2940 // there are no checks and the declared figure is shown.
2941 $cat_max = ( ! empty( $checks ) && isset( $cat['max'] ) ) ? (int) $cat['max'] : (int) $meta['max'];
2942 $cat_pct = $cat_max > 0 ? (int) round( ( $earned / $cat_max ) * 100 ) : 0;
2943 $cat_counts = isset( $cat['counts'] ) && is_array( $cat['counts'] ) ? $cat['counts'] : array( 'pass' => 0, 'warn' => 0, 'fail' => 0, 'info' => 0 );
2944 $cat_quality = self::analyzer_quality_tag( $cat_pct );
2945 // Nothing in the category could be scored: that is not
2946 // a zero, and it does not get the red label of one.
2947 if ( ! $info_only && ! empty( $checks ) && 0 === $cat_max ) {
2948 $cat_quality = array(
2949 'label' => __( 'Not measured', 'vigilante' ),
2950 'slug' => 'info',
2951 );
2952 }
2953 $info_count = isset( $cat_counts['info'] ) ? (int) $cat_counts['info'] : 0;
2954 ?>
2955 <details class="vigilante-analyzer-category<?php echo $info_only ? ' vigilante-analyzer-category--info' : ''; ?>"
2956 data-category="<?php echo esc_attr( $slug ); ?>"
2957 data-info-only="<?php echo $info_only ? '1' : '0'; ?>">
2958 <summary class="vigilante-analyzer-category-summary">
2959 <span class="vigilante-analyzer-category-chevron" aria-hidden="true"></span>
2960 <span class="vigilante-analyzer-category-label"><?php echo esc_html( $meta['label'] ); ?></span>
2961 <?php if ( $info_only ) : ?>
2962 <span class="vigilante-analyzer-category-quality vigilante-analyzer-quality-info"
2963 data-role="category-quality"
2964 title="<?php esc_attr_e( 'Informational — does not affect the security score.', 'vigilante' ); ?>">
2965 <span class="dashicons dashicons-info-outline" aria-hidden="true"></span>
2966 <?php esc_html_e( 'Informational', 'vigilante' ); ?>
2967 </span>
2968 <?php else :
2969 $passed_count = (int) ( $cat_counts['pass'] ?? 0 );
2970 $scored_total = $passed_count
2971 + (int) ( $cat_counts['warn'] ?? 0 )
2972 + (int) ( $cat_counts['fail'] ?? 0 );
2973 ?>
2974 <span class="vigilante-analyzer-category-quality vigilante-analyzer-quality-<?php echo esc_attr( $cat_quality['slug'] ); ?>"
2975 data-role="category-quality">
2976 <span data-role="category-quality-label"><?php echo esc_html( $cat_quality['label'] ); ?></span>
2977 <span class="vigilante-analyzer-category-quality-sep" aria-hidden="true">·</span>
2978 <span class="vigilante-analyzer-category-tests" data-role="category-tests"
2979 data-passed="<?php echo esc_attr( $passed_count ); ?>"
2980 data-total="<?php echo esc_attr( $scored_total ); ?>">
2981 <?php
2982 echo esc_html( sprintf(
2983 /* translators: 1: tests passed, 2: total tests in this category */
2984 __( '%1$d/%2$d tests', 'vigilante' ),
2985 $passed_count,
2986 $scored_total
2987 ) );
2988 ?>
2989 </span>
2990 </span>
2991 <?php endif; ?>
2992 <?php if ( $info_only ) : ?>
2993 <span class="vigilante-analyzer-category-states" data-role="category-states">
2994 <?php if ( $info_count > 0 ) : ?>
2995 <span class="vigilante-analyzer-category-state vigilante-analyzer-category-state--info" title="<?php esc_attr_e( 'Informational', 'vigilante' ); ?>">
2996 <span data-role="state-info"><?php echo esc_html( $info_count ); ?></span><span class="dashicons dashicons-info-outline" aria-hidden="true"></span>
2997 </span>
2998 <?php endif; ?>
2999 </span>
3000 <?php endif; ?>
3001 <?php if ( ! $info_only ) : ?>
3002 <span class="vigilante-analyzer-category-score">
3003 <span data-role="earned"><?php echo esc_html( $earned ); ?></span><span class="vigilante-analyzer-category-score-sep">/</span><span data-role="max"><?php echo esc_html( $cat_max ); ?></span>
3004 <span class="vigilante-analyzer-category-score-unit"><?php esc_html_e( 'pts', 'vigilante' ); ?></span>
3005 </span>
3006 <span class="vigilante-analyzer-category-bar" aria-hidden="true">
3007 <span class="vigilante-analyzer-category-bar-fill vigilante-analyzer-category-bar-fill--<?php echo esc_attr( $cat_quality['slug'] ); ?>"
3008 style="width: <?php echo esc_attr( $cat_pct ); ?>%"
3009 data-role="category-bar"></span>
3010 </span>
3011 <?php else :
3012 $info_warn = isset( $cat_counts['warn'] ) ? (int) $cat_counts['warn'] : 0;
3013 $info_fail = isset( $cat_counts['fail'] ) ? (int) $cat_counts['fail'] : 0;
3014 $info_issues = $info_warn + $info_fail;
3015 if ( $info_issues > 0 ) : ?>
3016 <span class="vigilante-analyzer-category-status vigilante-analyzer-category-status--attention" data-role="info-status">
3017 <span class="dashicons dashicons-warning" aria-hidden="true"></span>
3018 <?php
3019 echo esc_html( sprintf(
3020 /* translators: %d: number of findings */
3021 _n( '%d finding', '%d findings', $info_issues, 'vigilante' ),
3022 $info_issues
3023 ) );
3024 ?>
3025 </span>
3026 <?php else : ?>
3027 <span class="vigilante-analyzer-category-status vigilante-analyzer-category-status--clear" data-role="info-status">
3028 <span class="dashicons dashicons-yes-alt" aria-hidden="true"></span>
3029 <?php esc_html_e( 'All clear', 'vigilante' ); ?>
3030 </span>
3031 <?php endif; ?>
3032 <?php endif; ?>
3033 </summary>
3034 <ul class="vigilante-analyzer-check-list" data-role="check-list">
3035 <?php if ( empty( $checks ) ) : ?>
3036 <li class="vigilante-analyzer-check-empty">
3037 <?php esc_html_e( 'No data yet — run a scan to populate this category.', 'vigilante' ); ?>
3038 </li>
3039 <?php else : ?>
3040 <?php foreach ( $checks as $c ) : ?>
3041 <?php echo self::render_analyzer_check_row( $c ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped inside helper ?>
3042 <?php endforeach; ?>
3043 <?php endif; ?>
3044 </ul>
3045 </details>
3046 <?php endforeach; ?>
3047 </div>
3048
3049 <div class="vigilante-analyzer-weekly">
3050 <h3><?php esc_html_e( 'Automatic weekly scan', 'vigilante' ); ?></h3>
3051 <p class="description">
3052 <?php esc_html_e( 'Vigilante runs this check once a week in the background. Enable email alerts to be notified if the score drops by 10 points or more, or if a new critical check starts failing.', 'vigilante' ); ?>
3053 </p>
3054 <label class="vigilante-analyzer-toggle-option">
3055 <input type="checkbox"
3056 name="security_analyzer[weekly_scan_enabled]"
3057 value="1"
3058 <?php checked( $weekly_enabled ); ?>>
3059 <?php esc_html_e( 'Run a weekly automatic scan', 'vigilante' ); ?>
3060 </label>
3061 <label class="vigilante-analyzer-toggle-option">
3062 <input type="checkbox"
3063 name="security_analyzer[email_on_regression]"
3064 value="1"
3065 <?php checked( $email_enabled ); ?>>
3066 <?php esc_html_e( 'Email me when the score drops significantly', 'vigilante' ); ?>
3067 </label>
3068 </div>
3069 </div>
3070 </div>
3071 <?php
3072 }
3073
3074 /**
3075 * Map a 0-100 percentage to a quality tag { label, slug } aligned with the
3076 * Dashboard grade palette (a/b/c/d/e).
3077 *
3078 * @param int $pct 0..100.
3079 * @return array{label:string,slug:string}
3080 */
3081 public static function analyzer_quality_tag( $pct ) {
3082 if ( ! class_exists( 'Vigilante_SA_Helpers' ) ) {
3083 require_once VIGILANTE_INCLUDES_DIR . 'security-analyzer/class-sa-helpers.php';
3084 }
3085
3086 // The word follows the letter, and the letter has one scale:
3087 // Vigilante_SA_Helpers::grade_for_score().
3088 $labels = array(
3089 'A' => __( 'Excellent', 'vigilante' ),
3090 'B' => __( 'Good', 'vigilante' ),
3091 'C' => __( 'Fair', 'vigilante' ),
3092 'D' => __( 'Poor', 'vigilante' ),
3093 'E' => __( 'Critical', 'vigilante' ),
3094 );
3095 $grade = Vigilante_SA_Helpers::grade_for_score( $pct );
3096
3097 return array(
3098 'label' => $labels[ $grade ],
3099 'slug' => strtolower( $grade ),
3100 );
3101 }
3102
3103 /**
3104 * Render a single analyzer check row (used both server-side and via JS template).
3105 *
3106 * @param array $check Check result array (from Vigilante_SA_Check_Result::to_array()).
3107 * @return string HTML (escaped).
3108 */
3109 private static function render_analyzer_check_row( $check ) {
3110 $id = isset( $check['id'] ) ? $check['id'] : '';
3111 $state = isset( $check['state'] ) ? $check['state'] : 'skip';
3112 $label = isset( $check['label'] ) ? $check['label'] : '';
3113 $detail = isset( $check['detail'] ) ? $check['detail'] : '';
3114 $score = isset( $check['score'] ) ? (int) $check['score'] : 0;
3115 $max = isset( $check['max'] ) ? (int) $check['max'] : 0;
3116 $fix_link = isset( $check['fix_link'] ) ? $check['fix_link'] : '';
3117
3118 $icons = array(
3119 'pass' => 'yes-alt',
3120 'warn' => 'warning',
3121 'fail' => 'dismiss',
3122 'info' => 'info',
3123 'skip' => 'minus',
3124 );
3125 $icon = isset( $icons[ $state ] ) ? $icons[ $state ] : 'minus';
3126
3127 $html = '<li class="vigilante-analyzer-check vigilante-analyzer-check--' . esc_attr( $state ) . '"';
3128 $html .= ' data-check-id="' . esc_attr( $id ) . '">';
3129 $html .= '<span class="vigilante-analyzer-check-icon dashicons dashicons-' . esc_attr( $icon ) . '" aria-hidden="true"></span>';
3130 $html .= '<div class="vigilante-analyzer-check-body">';
3131 $html .= '<div class="vigilante-analyzer-check-label">';
3132 $html .= '<span>' . esc_html( $label ) . '</span>';
3133 if ( $max > 0 && 'info' !== $state && 'skip' !== $state ) {
3134 $html .= '<span class="vigilante-analyzer-check-score">'
3135 . esc_html( $score . '/' . $max )
3136 . ' <span class="vigilante-analyzer-check-score-unit">' . esc_html__( 'pts', 'vigilante' ) . '</span>'
3137 . '</span>';
3138 }
3139 $html .= '</div>';
3140 if ( $detail ) {
3141 $html .= '<p class="vigilante-analyzer-check-detail">' . esc_html( $detail ) . '</p>';
3142 }
3143 if ( $fix_link && in_array( $state, array( 'fail', 'warn' ), true ) ) {
3144 $html .= '<a href="' . esc_url( $fix_link ) . '" class="vigilante-analyzer-fix-link">'
3145 . esc_html__( 'Go to setting', 'vigilante' )
3146 . '<span class="vigilante-analyzer-fix-arrow" aria-hidden="true">&rarr;</span></a>';
3147 } elseif ( $fix_link && 'info' === $state ) {
3148 // Info rows (e.g. DNSBL lookups) get an external "Learn more" link instead.
3149 $is_external = 0 === strpos( $fix_link, 'http' );
3150 $html .= '<a href="' . esc_url( $fix_link ) . '" class="vigilante-analyzer-fix-link"'
3151 . ( $is_external ? ' target="_blank" rel="noopener noreferrer"' : '' ) . '>'
3152 . esc_html__( 'Learn more', 'vigilante' )
3153 . '<span class="vigilante-analyzer-fix-arrow" aria-hidden="true">&rarr;</span></a>';
3154 }
3155 $html .= '</div>';
3156 $html .= '</li>';
3157 return $html;
3158 }
3159
3160 /**
3161 * Build a minimal SVG sparkline for the score history.
3162 *
3163 * @param int[] $points Score values (0..100), oldest to newest.
3164 * @return string SVG markup.
3165 */
3166 private static function sparkline_svg( $points ) {
3167 $points = array_map( 'intval', (array) $points );
3168 $count = count( $points );
3169 if ( $count < 2 ) {
3170 return '';
3171 }
3172 $width = 280;
3173 $height = 60;
3174 $padding = 4;
3175
3176 $usable_w = $width - ( $padding * 2 );
3177 $usable_h = $height - ( $padding * 2 );
3178
3179 $step = $usable_w / max( 1, $count - 1 );
3180 $max = 100; // Fixed scale — scores are 0..100.
3181
3182 $coords = array();
3183 foreach ( $points as $i => $v ) {
3184 $x = $padding + ( $i * $step );
3185 $y = $padding + ( $usable_h - ( ( $v / $max ) * $usable_h ) );
3186 $coords[] = round( $x, 2 ) . ',' . round( $y, 2 );
3187 }
3188 $path = 'M ' . implode( ' L ', $coords );
3189 $last = end( $points );
3190 $last_x = $padding + ( ( $count - 1 ) * $step );
3191 $last_y = $padding + ( $usable_h - ( ( $last / $max ) * $usable_h ) );
3192
3193 $svg = '<svg viewBox="0 0 ' . $width . ' ' . $height . '" preserveAspectRatio="none" role="img" aria-label="' . esc_attr__( 'Security Score history', 'vigilante' ) . '" focusable="false">';
3194 $svg .= '<path d="' . esc_attr( $path ) . '" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>';
3195 $svg .= '<circle cx="' . esc_attr( round( $last_x, 2 ) ) . '" cy="' . esc_attr( round( $last_y, 2 ) ) . '" r="3" fill="currentColor"/>';
3196 $svg .= '</svg>';
3197 return $svg;
3198 }
3199
3200 /**
3201 * Render dashboard tab
3202 */
3203 /**
3204 * The Vigilant configuration card of the Dashboard: how much is switched on
3205 *
3206 * A bar with a percentage and, on purpose, neither a circle nor a letter.
3207 * Until 3.0.4 this was the Configuration Score, drawn exactly like the
3208 * score of Security Check next to it, and two letters side by side read
3209 * as two grades of the same site that did not agree. The grade is the one
3210 * of Security Check, which measures the site; this only counts settings,
3211 * so it sits with the recommendations and the module switches it counts.
3212 * The figure is still the one of calculate_security_score().
3213 *
3214 * @since 3.0.5
3215 *
3216 * @param int $security_score What calculate_security_score() gives, 0 to 100.
3217 * @param array $options The settings of the plugin.
3218 * @param string|bool $active_preset Slug of the preset in force, if any.
3219 * @param array $presets The presets, by slug.
3220 */
3221 private function render_configuration_card( $security_score, $options, $active_preset, $presets ) {
3222 $modules = ( isset( $options['modules'] ) && is_array( $options['modules'] ) ) ? $options['modules'] : array();
3223 $modules_on = count( array_filter( $modules ) );
3224 $level = max( 0, min( 100, (int) $security_score ) );
3225 ?>
3226 <div class="vigilante-status-card vigilante-configuration-card">
3227 <h2><?php esc_html_e( 'Vigilant configuration', 'vigilante' ); ?></h2>
3228 <p class="vigilante-score-kind description">
3229 <?php esc_html_e( 'How much of what Vigilant offers is switched on here.', 'vigilante' ); ?>
3230 </p>
3231 <div class="vigilante-configuration-level">
3232 <div class="vigilante-configuration-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100"
3233 aria-valuenow="<?php echo esc_attr( $level ); ?>"
3234 aria-label="<?php esc_attr_e( 'Vigilant configuration', 'vigilante' ); ?>">
3235 <span class="vigilante-configuration-bar-fill" style="width: <?php echo esc_attr( $level ); ?>%;"></span>
3236 </div>
3237 <strong class="vigilante-configuration-percent"><?php echo esc_html( $level ); ?>%</strong>
3238 <div class="vigilante-config-status">
3239 <?php if ( $active_preset && isset( $presets[ $active_preset ] ) ) : ?>
3240 <span class="vigilante-preset-badge vigilante-preset-<?php echo esc_attr( $active_preset ); ?>">
3241 <?php echo esc_html( $presets[ $active_preset ]['name'] ); ?>
3242 </span>
3243 <?php else : ?>
3244 <span class="vigilante-preset-badge vigilante-preset-custom">
3245 <?php esc_html_e( 'Custom Configuration', 'vigilante' ); ?>
3246 </span>
3247 <?php endif; ?>
3248 </div>
3249 </div>
3250 <p class="vigilante-configuration-modules description">
3251 <?php
3252 echo esc_html(
3253 sprintf(
3254 /* translators: 1: number of modules switched on, 2: number of modules */
3255 __( '%1$d of %2$d modules switched on.', 'vigilante' ),
3256 $modules_on,
3257 count( $modules )
3258 )
3259 );
3260 ?>
3261 </p>
3262
3263 <?php
3264 $recommendations = $this->get_security_recommendations( $options );
3265 if ( ! empty( $recommendations ) ) :
3266 ?>
3267 <div class="vigilante-recommendations">
3268 <h4><?php esc_html_e( 'Recommendations', 'vigilante' ); ?></h4>
3269 <ul class="vigilante-recommendations-grid">
3270 <?php foreach ( $recommendations as $rec ) : ?>
3271 <li>
3272 <span class="dashicons dashicons-<?php echo esc_attr( $rec['icon'] ); ?> vigilante-priority-<?php echo esc_attr( $rec['priority'] ); ?>"></span>
3273 <?php echo esc_html( $rec['message'] ); ?>
3274 <?php if ( ! empty( $rec['tab'] ) ) : ?>
3275 <a href="<?php echo esc_url( admin_url( 'admin.php?page=vigilante&tab=' . $rec['tab'] ) ); ?>" class="vigilante-rec-link" title="<?php esc_attr_e( 'Go to settings', 'vigilante' ); ?>"><span class="dashicons dashicons-arrow-right-alt2"></span></a>
3276 <?php endif; ?>
3277 </li>
3278 <?php endforeach; ?>
3279 </ul>
3280 </div>
3281 <?php endif; ?>
3282 </div>
3283 <?php
3284 }
3285
3286 /**
3287 * One line strip at the top of the Dashboard tab: the state of Vigilant own
3288 * files, above the Security Check and the configuration card, because
3289 * nothing else on this screen means much while it is red.
3290 */
3291 private function render_self_protection_strip() {
3292 $summary = $this->self_integrity_summary();
3293 $tone = $summary['tone'];
3294 $state = $summary['state'];
3295 $count = count( $summary['findings'] );
3296 $link = admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' );
3297 ?>
3298 <div class="vigilante-self-strip vigilante-self-strip--<?php echo esc_attr( $tone ); ?>">
3299 <span class="dashicons dashicons-shield" aria-hidden="true"></span>
3300 <strong><?php esc_html_e( 'Vigilant self-protection', 'vigilante' ); ?></strong>
3301 <span class="vigilante-self-strip-state"><?php echo esc_html( $this->self_integrity_headline( $tone, $state ) ); ?></span>
3302 <?php if ( $count > 0 ) : ?>
3303 <span class="vigilante-self-strip-count">
3304 <?php
3305 printf(
3306 /* translators: %d: number of findings about Vigilant own files */
3307 esc_html( _n( '%d finding', '%d findings', $count, 'vigilante' ) ),
3308 (int) $count
3309 );
3310 ?>
3311 </span>
3312 <?php elseif ( ! empty( $state['last_check'] ) && 'off' !== $tone ) : ?>
3313 <span class="vigilante-self-strip-count">
3314 <?php
3315 printf(
3316 /* translators: %s: human time difference, like "2 hours" */
3317 esc_html__( 'checked %s ago', 'vigilante' ),
3318 esc_html( human_time_diff( (int) $state['last_check'], time() ) )
3319 );
3320 ?>
3321 </span>
3322 <?php endif; ?>
3323 <a href="<?php echo esc_url( $link ); ?>">
3324 <?php
3325 if ( in_array( $tone, array( 'critical', 'off', 'warning' ), true ) ) {
3326 esc_html_e( 'See what to do', 'vigilante' );
3327 } else {
3328 esc_html_e( 'See details', 'vigilante' );
3329 }
3330 ?>
3331 </a>
3332 </div>
3333 <?php
3334 }
3335
3336 private function render_tab_dashboard() {
3337 $options = $this->settings->get_all_options();
3338 $module_labels = $this->settings->get_module_labels();
3339 $module_descriptions = $this->settings->get_module_descriptions();
3340 $presets = $this->settings->get_presets();
3341 $active_preset = get_option( 'vigilante_active_preset', '' );
3342
3343 // Calculate security score with more factors
3344 $security_score = $this->calculate_security_score( $options );
3345
3346 // Security Analyzer (v2.1.0) — hydrate widget with the last persisted scan, if any.
3347 if ( ! class_exists( 'Vigilante_Security_Analyzer' ) ) {
3348 require_once VIGILANTE_INCLUDES_DIR . 'class-security-analyzer.php';
3349 }
3350 $analyzer_instance = new Vigilante_Security_Analyzer( $this->settings, $this->activity_log );
3351 $analyzer_last_scan = $analyzer_instance->get_last_scan();
3352 $analyzer_history = $analyzer_instance->get_score_history();
3353 $analyzer_categories_def = Vigilante_Security_Analyzer::get_categories();
3354 $analyzer_settings = isset( $options['security_analyzer'] ) ? $options['security_analyzer'] : array();
3355 ?>
3356 <div class="vigilante-dashboard">
3357 <?php $this->render_self_protection_strip(); ?>
3358 <?php $this->render_analyzer_widget( $analyzer_last_scan, $analyzer_history, $analyzer_categories_def, $analyzer_settings ); ?>
3359
3360 <?php $this->render_configuration_card( $security_score, $options, $active_preset, $presets ); ?>
3361
3362 <div class="vigilante-modules-grid">
3363 <h2 id="vigilante-section-dashboard-modules"><?php esc_html_e( 'Security Modules', 'vigilante' ); ?></h2>
3364 <p class="description"><?php esc_html_e( 'Enable or disable security modules. Each module controls a tab with detailed settings.', 'vigilante' ); ?></p>
3365 <div class="vigilante-modules-list">
3366 <?php foreach ( $options['modules'] as $module => $enabled ) :
3367 $label = isset( $module_labels[ $module ] ) ? $module_labels[ $module ] : ucwords( str_replace( '_', ' ', $module ) );
3368 $description = isset( $module_descriptions[ $module ] ) ? $module_descriptions[ $module ] : '';
3369 $vg_module_locked = $this->main_site_files_locked() && in_array( $module, Vigilante_Settings::get_main_site_file_settings()['modules'], true );
3370 ?>
3371 <div class="vigilante-module-item <?php echo $enabled ? 'enabled' : 'disabled'; ?>">
3372 <div class="vigilante-module-header">
3373 <span class="vigilante-module-status"></span>
3374 <span class="vigilante-module-name"><?php echo esc_html( $label ); ?></span>
3375 <label class="vigilante-toggle">
3376 <?php
3377 /* translators: %s: Security module name, for example Firewall. */
3378 $toggle_label = sprintf( __( 'Enable %s', 'vigilante' ), $label );
3379 ?>
3380 <input type="checkbox"
3381 name="modules[<?php echo esc_attr( $module ); ?>]"
3382 value="1"
3383 <?php checked( $enabled ); ?>
3384 <?php disabled( $vg_module_locked ); ?>
3385 aria-label="<?php echo esc_attr( $toggle_label ); ?>"
3386 data-module="<?php echo esc_attr( $module ); ?>">
3387 <span class="vigilante-toggle-slider"></span>
3388 </label>
3389 </div>
3390 <?php if ( $description ) : ?>
3391 <p class="vigilante-module-desc"><?php echo esc_html( $description ); ?></p>
3392 <?php endif; ?>
3393 <?php if ( $vg_module_locked ) : ?>
3394 <p class="vigilante-module-desc"><?php esc_html_e( 'On the main site of a network this module also writes files every site shares, so only a network administrator can switch it.', 'vigilante' ); ?></p>
3395 <?php endif; ?>
3396 </div>
3397 <?php endforeach; ?>
3398 </div>
3399 </div>
3400
3401 <div class="vigilante-presets-card">
3402 <h2><?php esc_html_e( 'Quick Configuration Presets', 'vigilante' ); ?></h2>
3403 <p><?php esc_html_e( 'Apply a preset to quickly set up recommended settings for standard or maximum security level.', 'vigilante' ); ?></p>
3404 <div class="vigilante-presets-grid">
3405 <?php foreach ( $presets as $preset_id => $preset ) :
3406 $is_active = ( $active_preset === $preset_id );
3407 ?>
3408 <div class="vigilante-preset-card <?php echo $is_active ? 'vigilante-preset-active' : ''; ?>">
3409 <?php if ( $is_active ) : ?>
3410 <span class="vigilante-active-indicator"><?php esc_html_e( 'Active', 'vigilante' ); ?></span>
3411 <?php endif; ?>
3412 <h3><?php echo esc_html( $preset['name'] ); ?></h3>
3413 <p><?php echo esc_html( $preset['description'] ); ?></p>
3414 <button type="button" class="button vigilante-preset-btn <?php echo $is_active ? 'button-primary' : ''; ?>" data-preset="<?php echo esc_attr( $preset_id ); ?>">
3415 <?php esc_html_e( 'Apply Preset', 'vigilante' ); ?>
3416 </button>
3417 </div>
3418 <?php endforeach; ?>
3419
3420 <?php
3421 // Under Attack mode card
3422 $under_attack = new Vigilante_Under_Attack( $this->settings, $this->activity_log );
3423 $ua_active = $under_attack->is_active();
3424 $ua_remaining = $under_attack->get_remaining_time();
3425 $ua_remaining_hours = floor( $ua_remaining / 3600 );
3426 $ua_remaining_mins = floor( ( $ua_remaining % 3600 ) / 60 );
3427 ?>
3428 <div class="vigilante-preset-card vigilante-under-attack-card <?php echo $ua_active ? 'vigilante-under-attack-active' : ''; ?>">
3429 <h3 id="vigilante-section-dashboard-under-attack">
3430 <span class="dashicons dashicons-shield"></span>
3431 <?php esc_html_e( 'Under Attack', 'vigilante' ); ?>
3432 </h3>
3433 <p><?php esc_html_e( 'Emergency mode. JavaScript challenge for all visitors, aggressive rate limiting, and restricted access. Auto-deactivates after 4 hours.', 'vigilante' ); ?></p>
3434 <?php if ( $ua_active ) : ?>
3435 <div class="vigilante-ua-countdown" data-expires="<?php echo esc_attr( $under_attack->get_status()['activated_at'] + $under_attack->get_status()['duration'] ); ?>">
3436 <span class="dashicons dashicons-clock"></span>
3437 <span class="vigilante-ua-time">
3438 <?php
3439 printf(
3440 /* translators: 1: Hours, 2: Minutes */
3441 esc_html__( '%1$dh %2$dm remaining', 'vigilante' ),
3442 absint( $ua_remaining_hours ),
3443 absint( $ua_remaining_mins )
3444 );
3445 ?>
3446 </span>
3447 </div>
3448 <button type="button" class="button vigilante-ua-btn vigilante-ua-deactivate">
3449 <?php esc_html_e( 'Deactivate', 'vigilante' ); ?>
3450 </button>
3451 <?php else : ?>
3452 <button type="button" class="button vigilante-ua-btn vigilante-ua-activate">
3453 <?php esc_html_e( 'Activate for 4 hours', 'vigilante' ); ?>
3454 </button>
3455 <?php endif; ?>
3456 </div>
3457 </div>
3458 </div>
3459 </div>
3460 <?php
3461 }
3462
3463 /**
3464 * Render tools tab
3465 */
3466 private function render_tab_tools() {
3467 // Get current settings for notification summary
3468 $email_options = $this->settings->get_section( 'email' );
3469 $login_options = $this->settings->get_section( 'login_security' );
3470 $user_options = $this->settings->get_section( 'user_security' );
3471 $fi_options = $this->settings->get_section( 'file_integrity' );
3472 $alerts_options = $this->settings->get_section( 'audit_alerts' );
3473 $monitoring = $user_options['admin_monitoring'] ?? array();
3474 $registration = $user_options['registration_approval'] ?? array();
3475 $current_admin = get_option( 'admin_email' );
3476 $send_to_admin = ! isset( $email_options['send_to_admin_email'] ) || ! empty( $email_options['send_to_admin_email'] );
3477 $additional_raw = $email_options['additional_recipients'] ?? array();
3478 $additional = is_array( $additional_raw ) ? implode( "\n", $additional_raw ) : trim( $additional_raw );
3479 ?>
3480
3481 <!-- Notification Settings -->
3482 <div id="vigilante-section-tools-notifications" class="vigilante-settings-section vigilante-notification-section">
3483 <h2><?php esc_html_e( 'Notification settings', 'vigilante' ); ?></h2>
3484 <p><?php esc_html_e( 'Configure who receives all administrative email notifications from Vigilant. Individual notifications are enabled in their respective tabs.', 'vigilante' ); ?></p>
3485
3486 <div class="vigilante-notification-layout">
3487
3488 <!-- Left column: Recipients settings -->
3489 <div class="vigilante-notification-settings">
3490 <form class="vigilante-settings-form" data-section="email">
3491
3492 <table class="form-table vigilante-compact-form">
3493 <tr>
3494 <th scope="row"><?php esc_html_e( 'WordPress Admin Email', 'vigilante' ); ?></th>
3495 <td>
3496 <label>
3497 <input type="checkbox" name="email[send_to_admin_email]" value="1" <?php checked( $send_to_admin ); ?>>
3498 <?php
3499 printf(
3500 /* translators: %s: Admin email address */
3501 esc_html__( 'Send to admin email (%s)', 'vigilante' ),
3502 '<code>' . esc_html( $current_admin ) . '</code>'
3503 );
3504 ?>
3505 </label>
3506 </td>
3507 </tr>
3508 <tr>
3509 <th scope="row"><label for="vigilante-f-email-additional-recipients"><?php esc_html_e( 'Additional Recipients', 'vigilante' ); ?></label></th>
3510 <td>
3511 <textarea id="vigilante-f-email-additional-recipients" name="email[additional_recipients]" rows="3" class="large-text code" placeholder="[email protected]&#10;[email protected]"><?php echo esc_textarea( $additional ); ?></textarea>
3512 <p class="description"><?php esc_html_e( 'One email per line.', 'vigilante' ); ?></p>
3513 </td>
3514 </tr>
3515 <tr>
3516 <th scope="row"><?php esc_html_e( 'Plugin Deactivation', 'vigilante' ); ?></th>
3517 <td>
3518 <label>
3519 <input type="checkbox" name="email[send_deactivation_email]" value="1" <?php checked( ! empty( $email_options['send_deactivation_email'] ) ); ?>>
3520 <?php esc_html_e( 'Send email when Vigilant is deactivated', 'vigilante' ); ?>
3521 </label>
3522 </td>
3523 </tr>
3524 </table>
3525
3526 <p class="submit vigilante-notification-submit">
3527 <button type="submit" class="button button-primary vigilante-save-btn" data-original-text="<?php esc_attr_e( 'Save Settings', 'vigilante' ); ?>">
3528 <?php esc_html_e( 'Save Settings', 'vigilante' ); ?>
3529 </button>
3530 <button type="button" class="button vigilante-test-email-btn" data-original-text="<?php esc_attr_e( 'Send test email', 'vigilante' ); ?>">
3531 <?php esc_html_e( 'Send test email', 'vigilante' ); ?>
3532 </button>
3533 <span class="vigilante-test-email-result" style="margin-left:8px;vertical-align:middle;"></span>
3534 </p>
3535 <p class="description"><?php esc_html_e( 'The test goes to the recipients above and confirms that email delivery works for every Vigilant notification.', 'vigilante' ); ?></p>
3536
3537 </form>
3538 </div>
3539
3540 <!-- Right column: Active notifications summary -->
3541 <div class="vigilante-notification-summary">
3542 <h4><?php esc_html_e( 'Active notifications', 'vigilante' ); ?></h4>
3543
3544 <table class="widefat striped">
3545 <thead>
3546 <tr>
3547 <th><?php esc_html_e( 'Notification', 'vigilante' ); ?></th>
3548 <th style="width: 1%; white-space: nowrap; text-align: center;"><?php esc_html_e( 'Status', 'vigilante' ); ?></th>
3549 <th style="width: 50px; text-align: center;"></th>
3550 </tr>
3551 </thead>
3552 <tbody>
3553 <?php
3554 $notifications = array(
3555 array(
3556 'label' => __( 'Login lockout', 'vigilante' ),
3557 'active' => ! empty( $login_options['notify_on_lockout'] ),
3558 'tab' => 'login',
3559 ),
3560 array(
3561 'label' => __( 'Administrator login', 'vigilante' ),
3562 'active' => ! empty( $login_options['notify_on_admin_login'] ),
3563 'tab' => 'login',
3564 ),
3565 array(
3566 'label' => __( 'New administrator created', 'vigilante' ),
3567 'active' => ! empty( $monitoring['alert_new_admin'] ),
3568 'tab' => 'users',
3569 ),
3570 array(
3571 'label' => __( 'Administrator email changed', 'vigilante' ),
3572 'active' => ! empty( $monitoring['alert_admin_email_change'] ),
3573 'tab' => 'users',
3574 ),
3575 array(
3576 'label' => __( 'Permission elevation', 'vigilante' ),
3577 'active' => ! empty( $monitoring['alert_permission_elevation'] ),
3578 'tab' => 'users',
3579 ),
3580 array(
3581 'label' => __( 'Admin password changed', 'vigilante' ),
3582 'active' => ! empty( $monitoring['alert_admin_password_change'] ),
3583 'tab' => 'users',
3584 ),
3585 array(
3586 'label' => __( 'Registration pending approval', 'vigilante' ),
3587 'active' => ! empty( $registration['enabled'] ) && ! empty( $registration['notify_admin'] ),
3588 'tab' => 'users',
3589 ),
3590 array(
3591 'label' => __( 'File integrity scan report', 'vigilante' ),
3592 'active' => ( $fi_options['notify_level'] ?? 'disabled' ) !== 'disabled',
3593 'tab' => 'file-integrity',
3594 ),
3595 array(
3596 'label' => __( 'File integrity instant alert', 'vigilante' ),
3597 'active' => ! empty( $fi_options['instant_alert'] ),
3598 'tab' => 'file-integrity',
3599 ),
3600 array(
3601 'label' => __( 'Audit alert: immediate', 'vigilante' ),
3602 'active' => Vigilante_Audit_Alerts::immediate_is_active( $alerts_options ),
3603 'tab' => 'activity-log',
3604 'anchor' => 'vigilante-section-audit-alerts',
3605 ),
3606 array(
3607 'label' => __( 'Audit alert: threshold', 'vigilante' ),
3608 'active' => Vigilante_Audit_Alerts::threshold_is_active( $alerts_options ),
3609 'tab' => 'activity-log',
3610 'anchor' => 'vigilante-section-audit-alerts',
3611 ),
3612 array(
3613 'label' => __( 'Under Attack mode', 'vigilante' ),
3614 'active' => true,
3615 'tab' => '',
3616 'note' => __( 'Always active', 'vigilante' ),
3617 ),
3618 array(
3619 'label' => __( 'Plugin deactivation', 'vigilante' ),
3620 'active' => ! empty( $email_options['send_deactivation_email'] ),
3621 'tab' => 'tools',
3622 ),
3623 );
3624
3625 foreach ( $notifications as $notif ) :
3626 $status_class = $notif['active'] ? 'vigilante-status-active' : 'vigilante-status-inactive';
3627 $status_label = $notif['active'] ? __( 'Active', 'vigilante' ) : __( 'Inactive', 'vigilante' );
3628 if ( ! empty( $notif['note'] ) ) {
3629 $status_label = $notif['note'];
3630 }
3631 ?>
3632 <tr>
3633 <td><?php echo esc_html( $notif['label'] ); ?></td>
3634 <td style="text-align: center; white-space: nowrap;">
3635 <span class="<?php echo esc_attr( $status_class ); ?>"><?php echo esc_html( $status_label ); ?></span>
3636 </td>
3637 <td style="text-align: center;">
3638 <?php if ( ! empty( $notif['tab'] ) ) : ?>
3639 <a href="<?php echo esc_url( admin_url( 'admin.php?page=vigilante&tab=' . $notif['tab'] ) . ( ! empty( $notif['anchor'] ) ? '#' . $notif['anchor'] : '' ) ); ?>" class="button button-small">
3640 <span class="dashicons dashicons-admin-generic" style="font-size: 14px; line-height: 1.8;"></span>
3641 </a>
3642 <?php else : ?>
3643 &mdash;
3644 <?php endif; ?>
3645 </td>
3646 </tr>
3647 <?php endforeach; ?>
3648 </tbody>
3649 </table>
3650 </div>
3651
3652 </div>
3653 </div>
3654
3655 <h2 id="vigilante-section-tools-main" class="vigilante-tools-heading"><?php esc_html_e( 'Tools', 'vigilante' ); ?></h2>
3656
3657 <?php
3658 // Warn that during Under Attack mode the export/import operate against
3659 // the temporary hardened config and any imported changes will be
3660 // reverted when the mode ends.
3661 $ua_status = get_option( Vigilante_Under_Attack::OPTION_NAME, array() );
3662 if ( ! empty( $ua_status['active'] ) ) :
3663 ?>
3664 <div class="vigilante-ua-tools-notice-wrap">
3665 <div class="notice notice-warning inline vigilante-ua-tools-notice">
3666 <p>
3667 <strong><?php esc_html_e( 'Under Attack mode is active.', 'vigilante' ); ?></strong>
3668 <?php esc_html_e( 'Exports will reflect the temporary hardened configuration, not your saved one. Imports will apply on top of the hardened config and will be reverted when the mode ends. Consider waiting until you deactivate Under Attack before exporting or importing settings.', 'vigilante' ); ?>
3669 </p>
3670 </div>
3671 </div>
3672 <?php
3673 endif;
3674 ?>
3675
3676 <div class="vigilante-tools-grid">
3677 <div class="vigilante-tool-card">
3678 <h3><?php esc_html_e( 'Export Settings', 'vigilante' ); ?></h3>
3679 <p><?php esc_html_e( 'Download your current security settings as a JSON file.', 'vigilante' ); ?></p>
3680 <button type="button" class="button vigilante-export-settings">
3681 <?php esc_html_e( 'Export Settings', 'vigilante' ); ?>
3682 </button>
3683 </div>
3684
3685 <div class="vigilante-tool-card">
3686 <h3><?php esc_html_e( 'Import Settings', 'vigilante' ); ?></h3>
3687 <p><?php esc_html_e( 'Import settings from a previously exported JSON file.', 'vigilante' ); ?></p>
3688 <input type="file" id="vigilante-import-file" aria-label="<?php esc_attr_e( 'Configuration file to import', 'vigilante' ); ?>" accept=".json" style="display: none;">
3689 <button type="button" class="button vigilante-import-settings">
3690 <?php esc_html_e( 'Import Settings', 'vigilante' ); ?>
3691 </button>
3692 </div>
3693
3694 <div class="vigilante-tool-card">
3695 <h3><?php esc_html_e( 'Reset to Defaults', 'vigilante' ); ?></h3>
3696 <p><?php esc_html_e( 'Reset all the Vigilant security settings to default values. Your IP lists, custom login address, two-factor setup, scan exclusions and extra alert recipients are kept.', 'vigilante' ); ?></p>
3697 <button type="button" class="button vigilante-reset-settings" style="color: #a00;">
3698 <?php esc_html_e( 'Reset All Settings', 'vigilante' ); ?>
3699 </button>
3700 </div>
3701
3702 <div class="vigilante-tool-card">
3703 <h3><?php esc_html_e( 'Download Config Backup', 'vigilante' ); ?></h3>
3704 <p><?php esc_html_e( 'Download a ZIP backup of your wp-config.php and .htaccess (plus robots.txt if present) before making security changes. The archive is built on the fly and sent to your browser, so nothing is left on the server.', 'vigilante' ); ?></p>
3705 <?php if ( $this->shared_files_locked() ) : ?>
3706 <p class="description"><?php esc_html_e( 'Both files belong to the whole network, and wp-config.php carries the database credentials and the authentication salts of every site. The copy is taken from the main site.', 'vigilante' ); ?></p>
3707 <?php else : ?>
3708 <button type="button" class="button vigilante-create-backup">
3709 <?php esc_html_e( 'Download Backup', 'vigilante' ); ?>
3710 </button>
3711 <?php endif; ?>
3712 </div>
3713
3714 <?php if ( ! $this->shared_files_locked() ) : ?>
3715
3716 <div class="vigilante-tool-card vigilante-tool-card-wide">
3717 <h3><?php esc_html_e( 'Database Backup', 'vigilante' ); ?></h3>
3718 <p><?php esc_html_e( 'Download a backup of your database as a ZIP file. Select which tables to include.', 'vigilante' ); ?></p>
3719 <button type="button" class="button vigilante-db-backup-toggle">
3720 <?php esc_html_e( 'Download Database Backup', 'vigilante' ); ?>
3721 </button>
3722
3723 <div class="vigilante-db-backup-panel" style="display: none;">
3724 <div class="vigilante-db-tables-loading">
3725 <span class="spinner is-active"></span>
3726 <?php esc_html_e( 'Loading tables...', 'vigilante' ); ?>
3727 </div>
3728
3729 <div class="vigilante-db-tables-content" style="display: none;">
3730 <div class="vigilante-db-tables-controls">
3731 <label>
3732 <input type="checkbox" id="vigilante-db-select-all" checked>
3733 <strong><?php esc_html_e( 'Select / deselect all', 'vigilante' ); ?></strong>
3734 </label>
3735 <span class="vigilante-db-tables-info"></span>
3736 </div>
3737
3738 <div class="vigilante-db-tables-group">
3739 <h4><?php esc_html_e( 'WordPress core tables', 'vigilante' ); ?></h4>
3740 <div class="vigilante-db-tables-list" id="vigilante-db-core-tables"></div>
3741 </div>
3742
3743 <div class="vigilante-db-tables-group" id="vigilante-db-other-group" style="display: none;">
3744 <h4><?php esc_html_e( 'Plugin and custom tables', 'vigilante' ); ?></h4>
3745 <div class="vigilante-db-tables-list" id="vigilante-db-other-tables"></div>
3746 </div>
3747
3748 <div class="vigilante-db-backup-actions">
3749 <button type="button" class="button button-primary vigilante-db-backup-download">
3750 <?php esc_html_e( 'Download Backup (.zip)', 'vigilante' ); ?>
3751 </button>
3752 </div>
3753 </div>
3754 </div>
3755 </div>
3756 <?php else : ?>
3757 <div class="vigilante-tool-card vigilante-tool-card-wide">
3758 <h3><?php esc_html_e( 'Database Backup', 'vigilante' ); ?></h3>
3759 <p><?php esc_html_e( 'Download a backup of your database as a ZIP file. Select which tables to include.', 'vigilante' ); ?></p>
3760 <p class="description"><?php esc_html_e( 'The database is shared by the whole network, so a backup taken here would carry every other site and all of the network users. The copy is taken from the main site.', 'vigilante' ); ?></p>
3761 </div>
3762 <?php endif; ?>
3763 </div>
3764 <?php
3765 }
3766
3767 /**
3768 * Render firewall tab
3769 */
3770 private function render_tab_firewall() {
3771 $is_disabled = $this->render_module_disabled_notice( 'firewall' );
3772 $options = $this->settings->get_section( 'firewall' );
3773 ?>
3774 <form class="vigilante-settings-form <?php echo $is_disabled ? 'vigilante-form-disabled' : ''; ?>" data-section="firewall" <?php echo $is_disabled ? 'inert' : ''; ?>>
3775 <div id="vigilante-section-firewall-main" class="vigilante-settings-section">
3776 <h2>
3777 <?php esc_html_e( 'Firewall Protection', 'vigilante' ); ?>
3778 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
3779 </h2>
3780 <p><?php esc_html_e( 'PHP-based request filtering. Analyzes each request before WordPress loads.', 'vigilante' ); ?></p>
3781 <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;">
3782 <p style="margin:0;">
3783 <?php esc_html_e( 'Full page caching systems that serve cached pages before PHP executes (Varnish, LiteSpeed Cache, NGINX FastCGI Cache, Cloudflare APO) may bypass PHP-level firewall rules for cached requests. The .htaccess rules will still apply on Apache/LiteSpeed servers.', 'vigilante' ); ?>
3784 </p>
3785 </div>
3786
3787 <?php $vg_main_locked = $this->main_site_files_locked(); ?>
3788 <?php if ( $vg_main_locked ) : ?>
3789 <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;">
3790 <p style="margin:0;"><?php esc_html_e( 'On the main site of a network, blocking bad bots and bad query strings, the visitor IP detection and the two whitelists also build the .htaccess rules every site shares, so only a network administrator can change them.', 'vigilante' ); ?></p>
3791 </div>
3792 <?php endif; ?>
3793
3794 <table class="form-table">
3795 <tr>
3796 <th scope="row"><?php esc_html_e( 'Block Bad Query Strings', 'vigilante' ); ?></th>
3797 <td>
3798 <label>
3799 <input type="checkbox" name="firewall[block_bad_query_strings]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( ! empty( $options['block_bad_query_strings'] ) ); ?>>
3800 <?php esc_html_e( 'Block malicious query string patterns', 'vigilante' ); ?>
3801 </label>
3802 </td>
3803 </tr>
3804 <tr>
3805 <th scope="row"><?php esc_html_e( 'SQL Injection Protection', 'vigilante' ); ?></th>
3806 <td>
3807 <label>
3808 <input type="checkbox" name="firewall[block_sql_injection]" value="1" <?php checked( ! empty( $options['block_sql_injection'] ) ); ?>>
3809 <?php esc_html_e( 'Block SQL injection attempts', 'vigilante' ); ?>
3810 </label>
3811 </td>
3812 </tr>
3813 <tr>
3814 <th scope="row"><?php esc_html_e( 'XSS Protection', 'vigilante' ); ?></th>
3815 <td>
3816 <label>
3817 <input type="checkbox" name="firewall[block_xss_attacks]" value="1" <?php checked( ! empty( $options['block_xss_attacks'] ) ); ?>>
3818 <?php esc_html_e( 'Block cross-site scripting attacks', 'vigilante' ); ?>
3819 </label>
3820 </td>
3821 </tr>
3822 <tr>
3823 <th scope="row"><?php esc_html_e( 'File Inclusion Protection', 'vigilante' ); ?></th>
3824 <td>
3825 <label>
3826 <input type="checkbox" name="firewall[block_file_inclusion]" value="1" <?php checked( ! empty( $options['block_file_inclusion'] ) ); ?>>
3827 <?php esc_html_e( 'Block local/remote file inclusion attempts', 'vigilante' ); ?>
3828 </label>
3829 </td>
3830 </tr>
3831 <tr>
3832 <th scope="row"><?php esc_html_e( 'Directory Traversal Protection', 'vigilante' ); ?></th>
3833 <td>
3834 <label>
3835 <input type="checkbox" name="firewall[block_directory_traversal]" value="1" <?php checked( ! empty( $options['block_directory_traversal'] ) ); ?>>
3836 <?php esc_html_e( 'Block path traversal attempts', 'vigilante' ); ?>
3837 </label>
3838 </td>
3839 </tr>
3840 <tr>
3841 <th scope="row"><?php esc_html_e( 'Block Bad Bots', 'vigilante' ); ?></th>
3842 <td>
3843 <label>
3844 <input type="checkbox" name="firewall[block_bad_bots]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( ! empty( $options['block_bad_bots'] ) ); ?>>
3845 <?php esc_html_e( 'Block known malicious bots and scanners', 'vigilante' ); ?>
3846 </label>
3847 <p class="description"><?php esc_html_e( 'The list also stops SEO crawlers such as Ahrefs, Semrush, Majestic and Screaming Frog, and command line clients such as curl, wget and python-requests. To let one of them through, add its name to the User-Agent Whitelist below. On Apache part of the list is applied in .htaccess, before WordPress loads, so those requests get a 403 and do not appear in the Security Audit.', 'vigilante' ); ?></p>
3848 </td>
3849 </tr>
3850 </table>
3851
3852 <h3><?php esc_html_e( 'Rate Limiting', 'vigilante' ); ?></h3>
3853 <table class="form-table">
3854 <tr>
3855 <th scope="row"><?php esc_html_e( 'Enable Rate Limiting', 'vigilante' ); ?></th>
3856 <td>
3857 <label>
3858 <input type="checkbox" name="firewall[rate_limiting][enabled]" value="1" <?php checked( ! empty( $options['rate_limiting']['enabled'] ) ); ?>>
3859 <?php esc_html_e( 'Limit requests per IP address', 'vigilante' ); ?>
3860 </label>
3861 </td>
3862 </tr>
3863 <tr>
3864 <th scope="row"><label for="vigilante-f-firewall-rate-limiting-requests-per-minute"><?php esc_html_e( 'Requests per Minute', 'vigilante' ); ?></label></th>
3865 <td>
3866 <input id="vigilante-f-firewall-rate-limiting-requests-per-minute" type="number" name="firewall[rate_limiting][requests_per_minute]" value="<?php echo esc_attr( $options['rate_limiting']['requests_per_minute'] ?? 120 ); ?>" min="10" max="500" class="small-text">
3867 <p class="description">
3868 <?php esc_html_e( 'Counts only PHP requests to WordPress (pages, admin-ajax, REST, login) from a single IP, not static assets like images, CSS or JS. 120/min suits most sites; sustained traffic above that from one IP is usually a bot. To allow a legitimate service, whitelist its IP instead of raising the limit.', 'vigilante' ); ?>
3869 </p>
3870 </td>
3871 </tr>
3872 <tr>
3873 <th scope="row"><label for="vigilante-f-firewall-rate-limiting-block-duration"><?php esc_html_e( 'Block Duration (seconds)', 'vigilante' ); ?></label></th>
3874 <td>
3875 <input id="vigilante-f-firewall-rate-limiting-block-duration" type="number" name="firewall[rate_limiting][block_duration]" value="<?php echo esc_attr( $options['rate_limiting']['block_duration'] ?? 300 ); ?>" min="60" max="3600" class="small-text">
3876 </td>
3877 </tr>
3878 <tr>
3879 <th scope="row"><?php esc_html_e( 'Progressive Blocking', 'vigilante' ); ?></th>
3880 <td>
3881 <label>
3882 <input type="checkbox" name="firewall[rate_limiting][progressive]" value="1" <?php checked( ! empty( $options['rate_limiting']['progressive'] ) ); ?>>
3883 <?php esc_html_e( 'Double block duration on each repeat offense', 'vigilante' ); ?>
3884 </label>
3885 <p class="description">
3886 <?php
3887 $base = absint( $options['rate_limiting']['block_duration'] ?? 300 );
3888 printf(
3889 /* translators: 1: First block duration, 2: Second, 3: Third */
3890 esc_html__( 'Example: %1$s → %2$s → %3$s and so on, up to the maximum.', 'vigilante' ),
3891 esc_html( human_time_diff( 0, $base ) ),
3892 esc_html( human_time_diff( 0, $base * 2 ) ),
3893 esc_html( human_time_diff( 0, $base * 4 ) )
3894 );
3895 ?>
3896 </p>
3897 </td>
3898 </tr>
3899 <tr>
3900 <th scope="row"><label for="vigilante-f-firewall-rate-limiting-max-block-duration"><?php esc_html_e( 'Maximum Block Duration', 'vigilante' ); ?></label></th>
3901 <td>
3902 <select id="vigilante-f-firewall-rate-limiting-max-block-duration" name="firewall[rate_limiting][max_block_duration]">
3903 <?php
3904 $max_options = array(
3905 3600 => __( '1 hour', 'vigilante' ),
3906 21600 => __( '6 hours', 'vigilante' ),
3907 43200 => __( '12 hours', 'vigilante' ),
3908 86400 => __( '24 hours', 'vigilante' ),
3909 604800 => __( '7 days', 'vigilante' ),
3910 );
3911 $current_max = absint( $options['rate_limiting']['max_block_duration'] ?? 86400 );
3912 foreach ( $max_options as $val => $label ) :
3913 ?>
3914 <option value="<?php echo esc_attr( $val ); ?>" <?php selected( $current_max, $val ); ?>>
3915 <?php echo esc_html( $label ); ?>
3916 </option>
3917 <?php endforeach; ?>
3918 </select>
3919 <p class="description"><?php esc_html_e( 'Upper limit for progressive blocking.', 'vigilante' ); ?></p>
3920 </td>
3921 </tr>
3922 </table>
3923
3924 <?php
3925 // Currently blocked IPs from rate limiting
3926 $active_blocks = Vigilante_Firewall::get_active_blocks();
3927 if ( ! empty( $active_blocks ) ) :
3928 ?>
3929 <div class="vigilante-settings-section vigilante-lockout-section" style="margin-top:20px;">
3930 <h3><?php esc_html_e( 'Currently Blocked IPs', 'vigilante' ); ?></h3>
3931 <table class="wp-list-table widefat fixed striped" style="max-width:800px;">
3932 <thead>
3933 <tr>
3934 <th><?php esc_html_e( 'IP Address', 'vigilante' ); ?></th>
3935 <th><?php esc_html_e( 'Blocked', 'vigilante' ); ?></th>
3936 <th><?php esc_html_e( 'Expires in', 'vigilante' ); ?></th>
3937 <th><?php esc_html_e( 'Strikes', 'vigilante' ); ?></th>
3938 <th><?php esc_html_e( 'Action', 'vigilante' ); ?></th>
3939 </tr>
3940 </thead>
3941 <tbody>
3942 <?php foreach ( $active_blocks as $blocked_ip => $block_data ) : ?>
3943 <tr>
3944 <td><code><?php echo esc_html( $blocked_ip ); ?></code></td>
3945 <td><?php echo esc_html( human_time_diff( $block_data['blocked_at'] ) . ' ' . __( 'ago', 'vigilante' ) ); ?></td>
3946 <td><?php echo esc_html( human_time_diff( time(), $block_data['expires'] ) ); ?></td>
3947 <td><?php echo esc_html( $block_data['strikes'] ?? 1 ); ?></td>
3948 <td>
3949 <button type="button" class="button button-small vigilante-unblock-firewall-ip"
3950 data-ip="<?php echo esc_attr( $blocked_ip ); ?>">
3951 <?php esc_html_e( 'Unblock', 'vigilante' ); ?>
3952 </button>
3953 </td>
3954 </tr>
3955 <?php endforeach; ?>
3956 </tbody>
3957 </table>
3958 </div>
3959 <?php endif; ?>
3960
3961 <?php
3962 // Since 2.11.8 X-Forwarded-For is read from its end, where the proxy
3963 // writes. The administrator's own request shows whether that end is
3964 // a CDN or a balancer for everybody here. Cross review of 2.11.8.
3965 $xff_readings = $this->forwarded_chain_readings();
3966 if ( $xff_readings ) :
3967 ?>
3968 <div id="vigilante-xff-chain-notice" class="notice notice-warning inline" style="margin:10px 0 16px;padding:8px 12px;">
3969 <p style="margin:0;">
3970 <?php
3971 printf(
3972 /* translators: 1: last address in the header, the one Vigilant reads, 2: first address in the header, which a visitor can write */
3973 esc_html__( 'Your own request reaches the site with more than one public address in X-Forwarded-For. Vigilant reads the last one, %1$s, which is the one your proxy added, and not the first one, %2$s, which a visitor can write. If %1$s belongs to a CDN or a load balancer rather than to you, every visitor shares it for rate limiting, login lockouts and the IP lists: choose the header of that CDN in Visitor IP detection, such as CF-Connecting-IP for Cloudflare.', 'vigilante' ),
3974 esc_html( $xff_readings['now'] ),
3975 esc_html( $xff_readings['before'] )
3976 );
3977 ?>
3978 </p>
3979 </div>
3980 <?php endif; ?>
3981
3982 <h3><?php esc_html_e( 'IP Lists', 'vigilante' ); ?></h3>
3983 <p class="description">
3984 <?php
3985 printf(
3986 /* translators: %s: Current visitor IP address */
3987 esc_html__( 'Your current IP address: %s', 'vigilante' ),
3988 '<code>' . esc_html( $this->database->get_client_ip() ) . '</code>'
3989 );
3990 ?>
3991 </p>
3992 <table class="form-table">
3993 <tr>
3994 <th scope="row"><label for="vigilante-f-firewall-trusted-proxy-header"><?php esc_html_e( 'Visitor IP detection', 'vigilante' ); ?></label></th>
3995 <td>
3996 <?php $proxy_header = $options['trusted_proxy_header'] ?? ''; ?>
3997 <select id="vigilante-f-firewall-trusted-proxy-header" name="firewall[trusted_proxy_header]" <?php disabled( $vg_main_locked ); ?>>
3998 <option value="" <?php selected( $proxy_header, '' ); ?>><?php esc_html_e( 'Direct connection, only REMOTE_ADDR (recommended)', 'vigilante' ); ?></option>
3999 <option value="cf-connecting-ip" <?php selected( $proxy_header, 'cf-connecting-ip' ); ?>><?php esc_html_e( 'Behind Cloudflare (CF-Connecting-IP)', 'vigilante' ); ?></option>
4000 <option value="x-forwarded-for" <?php selected( $proxy_header, 'x-forwarded-for' ); ?>><?php esc_html_e( 'Behind a reverse proxy or load balancer (X-Forwarded-For)', 'vigilante' ); ?></option>
4001 <option value="x-real-ip" <?php selected( $proxy_header, 'x-real-ip' ); ?>><?php esc_html_e( 'Behind an nginx proxy (X-Real-IP)', 'vigilante' ); ?></option>
4002 </select>
4003 <p class="description">
4004 <?php esc_html_e( 'Where to read the visitor IP from. Leave on "Direct connection" unless your site really sits behind that proxy or CDN. Trusting a forwarded header on a site that is not behind it lets visitors spoof their IP and bypass the IP lists and rate limiting.', 'vigilante' ); ?>
4005 </p>
4006 </td>
4007 </tr>
4008 <tr>
4009 <th scope="row"><label for="vigilante-f-firewall-trusted-proxies"><?php esc_html_e( 'Trusted proxy IPs', 'vigilante' ); ?></label></th>
4010 <td>
4011 <textarea id="vigilante-f-firewall-trusted-proxies" name="firewall[trusted_proxies]" rows="3" class="large-text code" placeholder="10.0.0.0/8&#10;192.168.1.1" <?php disabled( $vg_main_locked ); ?>><?php echo esc_textarea( implode( "\n", $options['trusted_proxies'] ?? array() ) ); ?></textarea>
4012 <p class="description">
4013 <?php esc_html_e( 'Only used with a forwarded header selected above. One IP or CIDR range per line: the addresses your proxy or load balancer connects from. The forwarded header is accepted only from these. Left empty, Vigilant accepts it from your own private network, and for Cloudflare from Cloudflare\'s own ranges automatically.', 'vigilante' ); ?>
4014 <?php if ( in_array( $proxy_header, array( 'x-forwarded-for', 'x-real-ip' ), true ) && empty( $options['trusted_proxies'] ) ) : ?>
4015 <br><strong><?php esc_html_e( 'The header above is trusted but no proxy IPs are set. If your proxy or load balancer connects from a public address, add it here, or the header is ignored for safety and every visitor is seen as that proxy.', 'vigilante' ); ?></strong>
4016 <?php endif; ?>
4017 </p>
4018 </td>
4019 </tr>
4020 <tr>
4021 <th scope="row"><label for="vigilante-f-firewall-ip-whitelist"><?php esc_html_e( 'IP Whitelist', 'vigilante' ); ?></label></th>
4022 <td>
4023 <textarea id="vigilante-f-firewall-ip-whitelist" name="firewall[ip_whitelist]" <?php disabled( $vg_main_locked ); ?> rows="4" class="large-text code" placeholder="192.168.1.50&#10;192.168.1.0/24&#10;192.168.1.*"><?php echo esc_textarea( implode( "\n", $options['ip_whitelist'] ?? array() ) ); ?></textarea>
4024 <p class="description">
4025 <?php esc_html_e( 'One IP per line. These IPs bypass the firewall checks, and they also reach wp-admin when the login URL is hidden, so remote managers such as MainWP or ManageWP are not turned away with a 404. The hidden login form itself stays hidden for every IP, this one included.', 'vigilante' ); ?>
4026 <br>
4027 <?php
4028 printf(
4029 /* translators: 1: opening <code>, 2: closing </code>. Placeholders wrap the IP, CIDR and wildcard examples. */
4030 esc_html__( 'Accepts exact IPs (%1$s192.168.1.50%2$s), CIDR ranges (%1$s192.168.1.0/24%2$s, IPv4 and IPv6), and wildcards with %1$s*%2$s (e.g. %1$s192.168.1.*%2$s).', 'vigilante' ),
4031 '<code>',
4032 '</code>'
4033 ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML tags are hardcoded.
4034 ?>
4035 </p>
4036 </td>
4037 </tr>
4038 <tr>
4039 <th scope="row"><label for="vigilante-f-firewall-ip-blacklist"><?php esc_html_e( 'IP Blacklist', 'vigilante' ); ?></label></th>
4040 <td>
4041 <textarea id="vigilante-f-firewall-ip-blacklist" name="firewall[ip_blacklist]" rows="4" class="large-text code" placeholder="203.0.113.42&#10;203.0.113.0/24&#10;203.0.113.*"><?php echo esc_textarea( implode( "\n", $options['ip_blacklist'] ?? array() ) ); ?></textarea>
4042 <p class="description">
4043 <?php esc_html_e( 'One IP per line. These IPs will be blocked immediately.', 'vigilante' ); ?>
4044 <br>
4045 <?php
4046 printf(
4047 /* translators: 1: opening <code>, 2: closing </code>. Placeholders wrap the IP, CIDR and wildcard examples. */
4048 esc_html__( 'Accepts exact IPs (%1$s203.0.113.42%2$s), CIDR ranges (%1$s203.0.113.0/24%2$s, IPv4 and IPv6), and wildcards with %1$s*%2$s (e.g. %1$s203.0.113.*%2$s).', 'vigilante' ),
4049 '<code>',
4050 '</code>'
4051 ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML tags are hardcoded.
4052 ?>
4053 </p>
4054 </td>
4055 </tr>
4056 </table>
4057
4058 <h3><?php esc_html_e( 'User-Agent Lists', 'vigilante' ); ?></h3>
4059 <p><?php esc_html_e( 'Partial matching: enter a keyword and any User-Agent containing it will be matched.', 'vigilante' ); ?></p>
4060 <table class="form-table">
4061 <tr>
4062 <th scope="row"><label for="vigilante-f-firewall-ua-whitelist"><?php esc_html_e( 'User-Agent Whitelist', 'vigilante' ); ?></label></th>
4063 <td>
4064 <textarea id="vigilante-f-firewall-ua-whitelist" name="firewall[ua_whitelist]" <?php disabled( $vg_main_locked ); ?> rows="4" class="large-text code"><?php echo esc_textarea( implode( "\n", $options['ua_whitelist'] ?? array() ) ); ?></textarea>
4065 <p class="description"><?php esc_html_e( 'One User-Agent per line. These will bypass all firewall checks. Example: ManageWP, MainWP, UptimeRobot.', 'vigilante' ); ?></p>
4066 </td>
4067 </tr>
4068 <tr>
4069 <th scope="row"><label for="vigilante-f-firewall-ua-blacklist"><?php esc_html_e( 'User-Agent Blacklist', 'vigilante' ); ?></label></th>
4070 <td>
4071 <textarea id="vigilante-f-firewall-ua-blacklist" name="firewall[ua_blacklist]" rows="4" class="large-text code"><?php echo esc_textarea( implode( "\n", $options['ua_blacklist'] ?? array() ) ); ?></textarea>
4072 <p class="description"><?php esc_html_e( 'One User-Agent per line. These will be blocked immediately.', 'vigilante' ); ?></p>
4073 </td>
4074 </tr>
4075 </table>
4076 </div>
4077
4078 <?php
4079 $vg_shared_locked = $this->shared_files_locked();
4080 // Paint what is actually in force, not this site's unused copy.
4081 $vg_local_options = $options;
4082 $options = $this->get_section_for_display( 'firewall' );
4083 ?>
4084 <?php $this->render_shared_files_notice(); ?>
4085 <div id="vigilante-section-firewall-server" class="vigilante-settings-section <?php echo $vg_shared_locked ? 'vigilante-form-disabled' : ''; ?>" <?php echo $vg_shared_locked ? 'inert' : ''; ?>>
4086 <h2>
4087 <?php esc_html_e( 'Server Protection', 'vigilante' ); ?>
4088 <span class="vigilante-method-badge htaccess"><?php esc_html_e( 'HTACCESS', 'vigilante' ); ?></span>
4089 </h2>
4090 <p><?php esc_html_e( 'Server-level rules for Apache/LiteSpeed. These rules are processed before PHP.', 'vigilante' ); ?></p>
4091
4092 <table class="form-table">
4093 <tr id="field-disable-directory-browsing">
4094 <th scope="row"><?php esc_html_e( 'Directory Browsing', 'vigilante' ); ?></th>
4095 <td>
4096 <label>
4097 <input type="checkbox" name="firewall[disable_directory_browsing]" value="1" <?php checked( ! empty( $options['disable_directory_browsing'] ) ); ?>>
4098 <?php esc_html_e( 'Disable directory listing (Options -Indexes)', 'vigilante' ); ?>
4099 </label>
4100 </td>
4101 </tr>
4102 <tr>
4103 <th scope="row"><?php esc_html_e( 'Protect wp-config.php', 'vigilante' ); ?></th>
4104 <td>
4105 <label>
4106 <input type="checkbox" name="firewall[protect_wp_config]" value="1" <?php checked( ! empty( $options['protect_wp_config'] ) ); ?>>
4107 <?php esc_html_e( 'Block direct HTTP access to wp-config.php', 'vigilante' ); ?>
4108 </label>
4109 </td>
4110 </tr>
4111 <tr id="field-protect-wp-cron">
4112 <th scope="row"><?php esc_html_e( 'Protect wp-cron.php', 'vigilante' ); ?></th>
4113 <td>
4114 <label>
4115 <input type="checkbox" name="firewall[protect_wp_cron]" value="1" <?php checked( ! empty( $options['protect_wp_cron'] ) ); ?>>
4116 <?php esc_html_e( 'Block direct HTTP access to wp-cron.php (prevents cron-spam DoS abuse)', 'vigilante' ); ?>
4117 </label>
4118 <p class="description"><?php
4119 printf(
4120 /* translators: 1: opening <strong>, 2: closing </strong> */
4121 esc_html__( '%1$sWarning:%2$s Only enable if your host runs a real server-side cron job calling wp-cron.php (most managed WordPress hosts do; check with your provider). Otherwise scheduled tasks — publishing, updates, emails, backups — stop running. Pair with %3$sDISABLE_WP_CRON%4$s in WP Hardening for full coverage.', 'vigilante' ),
4122 '<strong>',
4123 '</strong>',
4124 '<code>',
4125 '</code>'
4126 ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML tags are hardcoded.
4127 ?></p>
4128 </td>
4129 </tr>
4130 <tr>
4131 <th scope="row"><?php esc_html_e( 'Protect wp-includes', 'vigilante' ); ?></th>
4132 <td>
4133 <label>
4134 <input type="checkbox" name="firewall[protect_wp_includes]" value="1" <?php checked( ! empty( $options['protect_wp_includes'] ) ); ?>>
4135 <?php esc_html_e( 'Block direct access to PHP files in wp-includes', 'vigilante' ); ?>
4136 </label>
4137 </td>
4138 </tr>
4139 <tr>
4140 <th scope="row"><?php esc_html_e( 'PHP in Uploads', 'vigilante' ); ?></th>
4141 <td>
4142 <label>
4143 <input type="checkbox" name="firewall[protect_uploads_php]" value="1" <?php checked( ! empty( $options['protect_uploads_php'] ) ); ?>>
4144 <?php esc_html_e( 'Block PHP execution in wp-content/uploads', 'vigilante' ); ?>
4145 </label>
4146 </td>
4147 </tr>
4148 <tr>
4149 <th scope="row"><?php esc_html_e( 'Sensitive Files', 'vigilante' ); ?></th>
4150 <td>
4151 <label>
4152 <input type="checkbox" name="firewall[protect_sensitive_files]" value="1" <?php checked( ! empty( $options['protect_sensitive_files'] ) ); ?>>
4153 <?php esc_html_e( 'Block access to .sql, .bak, .log, .ini, readme.html, license.txt, licencia.txt', 'vigilante' ); ?>
4154 </label>
4155 </td>
4156 </tr>
4157 <tr>
4158 <th scope="row"><?php esc_html_e( 'Limit HTTP Methods', 'vigilante' ); ?></th>
4159 <td>
4160 <label>
4161 <input type="checkbox" name="firewall[limit_http_methods]" value="1" <?php checked( ! empty( $options['limit_http_methods'] ) ); ?>>
4162 <?php esc_html_e( 'Block the HTTP methods WordPress does not use (TRACE, TRACK, CONNECT and any unknown one). GET, POST, HEAD, OPTIONS, PUT, PATCH and DELETE keep working.', 'vigilante' ); ?>
4163 </label>
4164 </td>
4165 </tr>
4166 </table>
4167 </div>
4168 <?php $options = $vg_local_options; ?>
4169
4170 <p class="submit vigilante-submit-buttons">
4171 <button type="submit" class="button button-primary vigilante-save-btn" data-original-text="<?php esc_attr_e( 'Save Settings', 'vigilante' ); ?>">
4172 <?php esc_html_e( 'Save Settings', 'vigilante' ); ?>
4173 </button>
4174 <button type="button" class="button vigilante-reset-section-btn" data-original-text="<?php esc_attr_e( 'Reset to Defaults', 'vigilante' ); ?>">
4175 <?php esc_html_e( 'Reset to Defaults', 'vigilante' ); ?>
4176 </button>
4177 </p>
4178 </form>
4179 <?php
4180 }
4181
4182 /**
4183 * Render login security tab
4184 */
4185 private function render_tab_login() {
4186 $is_disabled = $this->render_module_disabled_notice( 'login_security' );
4187 $options = $this->settings->get_section( 'login_security' );
4188 $lockouts = $this->database->get_active_lockouts();
4189 ?>
4190 <form class="vigilante-settings-form <?php echo $is_disabled ? 'vigilante-form-disabled' : ''; ?>" data-section="login_security" <?php echo $is_disabled ? 'inert' : ''; ?>>
4191 <div id="vigilante-section-login-main" class="vigilante-settings-section">
4192 <h2>
4193 <?php esc_html_e( 'Login Protection', 'vigilante' ); ?>
4194 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
4195 <span class="vigilante-method-badge database"><?php esc_html_e( 'Database', 'vigilante' ); ?></span>
4196 </h2>
4197 <p><?php esc_html_e( 'Brute force protection and WordPress login hardening.', 'vigilante' ); ?></p>
4198
4199 <table class="form-table">
4200 <tr id="field-max-attempts">
4201 <th scope="row"><label for="vigilante-f-login-security-max-attempts"><?php esc_html_e( 'Max Login Attempts', 'vigilante' ); ?></label></th>
4202 <td>
4203 <input id="vigilante-f-login-security-max-attempts" type="number" name="login_security[max_attempts]" value="<?php echo esc_attr( $options['max_attempts'] ?? 5 ); ?>" min="1" max="20" class="small-text">
4204 <p class="description"><?php esc_html_e( 'Number of failed attempts before lockout.', 'vigilante' ); ?></p>
4205 </td>
4206 </tr>
4207 <tr>
4208 <th scope="row"><label for="vigilante-f-login-security-lockout-duration"><?php esc_html_e( 'Lockout Duration', 'vigilante' ); ?></label></th>
4209 <td>
4210 <input id="vigilante-f-login-security-lockout-duration" type="number" name="login_security[lockout_duration]" value="<?php echo esc_attr( ( $options['lockout_duration'] ?? 1800 ) / 60 ); ?>" min="1" max="1440" class="small-text">
4211 <?php esc_html_e( 'minutes', 'vigilante' ); ?>
4212 </td>
4213 </tr>
4214 <tr>
4215 <th scope="row"><?php esc_html_e( 'Progressive Lockout', 'vigilante' ); ?></th>
4216 <td>
4217 <label>
4218 <input type="checkbox" name="login_security[lockout_increment]" value="1" <?php checked( ! empty( $options['lockout_increment'] ) ); ?>>
4219 <?php esc_html_e( 'Double lockout duration for repeat offenders', 'vigilante' ); ?>
4220 </label>
4221 </td>
4222 </tr>
4223 <tr>
4224 <th scope="row"><?php esc_html_e( 'Hide Login Errors', 'vigilante' ); ?></th>
4225 <td>
4226 <label>
4227 <input type="checkbox" name="login_security[hide_login_errors]" value="1" <?php checked( ! empty( $options['hide_login_errors'] ) ); ?>>
4228 <?php esc_html_e( 'Show generic error message instead of specific errors', 'vigilante' ); ?>
4229 </label>
4230 </td>
4231 </tr>
4232 <tr>
4233 <th scope="row"><?php esc_html_e( 'Disable Application Passwords', 'vigilante' ); ?></th>
4234 <td>
4235 <label>
4236 <input type="checkbox" name="login_security[disable_application_passwords]" value="1" <?php checked( ! empty( $options['disable_application_passwords'] ) ); ?>>
4237 <?php esc_html_e( 'Disable WordPress application passwords feature', 'vigilante' ); ?>
4238 </label>
4239 </td>
4240 </tr>
4241 </table>
4242
4243 <h3><?php esc_html_e( 'Custom Login URL', 'vigilante' ); ?></h3>
4244 <table class="form-table">
4245 <tr id="field-custom-login-url">
4246 <th scope="row"><?php esc_html_e( 'Login URL Slug', 'vigilante' ); ?></th>
4247 <td>
4248 <code><?php echo esc_url( home_url( '/' ) ); ?></code>
4249 <input type="text" name="login_security[custom_login_url]" id="vigilante_custom_login_url" value="<?php echo esc_attr( $options['custom_login_url'] ?? '' ); ?>" class="regular-text" placeholder="<?php esc_attr_e( 'my-secret-login', 'vigilante' ); ?>">
4250 <p class="description">
4251 <?php esc_html_e( '&#9432; Leave empty to use default wp-login.php. Use only lowercase letters, numbers and hyphens.', 'vigilante' ); ?>
4252 </p>
4253 <div class="vigilante-login-url-preview" <?php echo empty( $options['custom_login_url'] ) ? 'style="display:none;"' : ''; ?>>
4254 <p class="description">
4255 <strong><?php esc_html_e( 'Your login URL:', 'vigilante' ); ?></strong>
4256 <code class="vigilante-login-url-display"><?php echo esc_url( home_url( sanitize_title( $options['custom_login_url'] ?? '' ) . '/' ) ); ?></code>
4257 </p>
4258 <p class="description">
4259 <?php esc_html_e( 'Direct access to wp-login.php and wp-admin will return a 404 error for non-logged users.', 'vigilante' ); ?>
4260 </p>
4261 <p class="description">
4262 <?php esc_html_e( 'An IP in the firewall whitelist is still allowed into wp-admin, so remote managers keep working, but it does not get the login form: the hidden URL is the only way in for everyone.', 'vigilante' ); ?>
4263 </p>
4264 </div>
4265 </td>
4266 </tr>
4267 </table>
4268
4269 <div class="vigilante-login-url-notify-wrapper <?php echo empty( $options['custom_login_url'] ) ? 'vigilante-login-url-notify-disabled' : ''; ?>">
4270 <table class="form-table">
4271 <tr>
4272 <th scope="row"><?php esc_html_e( 'Notify users', 'vigilante' ); ?></th>
4273 <td>
4274 <label>
4275 <input type="checkbox" name="login_security[notify_on_login_url_change]" id="vigilante_notify_login_url_change" value="1" <?php checked( $options['notify_on_login_url_change'] ?? true ); ?>>
4276 <?php esc_html_e( 'Notify affected users when the login URL changes', 'vigilante' ); ?>
4277 </label>
4278 <div class="vigilante-login-url-send-notification" style="margin-top:12px;">
4279 <button type="button" id="vigilante_notify_login_url" class="button">
4280 <?php esc_html_e( 'Send notification now', 'vigilante' ); ?>
4281 </button>
4282 <span class="vigilante-login-url-notify-status"></span>
4283 <p class="description"><?php esc_html_e( 'Sends the new URL to administrators, editors, authors, and contributors.', 'vigilante' ); ?></p>
4284 </div>
4285 </td>
4286 </tr>
4287 </table>
4288 </div>
4289
4290 <?php $this->render_2fa_settings( $options ); ?>
4291
4292 <h3><?php esc_html_e( 'Notifications', 'vigilante' ); ?></h3>
4293 <table class="form-table">
4294 <tr>
4295 <th scope="row"><?php esc_html_e( 'Notify on Lockout', 'vigilante' ); ?></th>
4296 <td>
4297 <label>
4298 <input type="checkbox" name="login_security[notify_on_lockout]" value="1" <?php checked( ! empty( $options['notify_on_lockout'] ) ); ?>>
4299 <?php esc_html_e( 'Send email when an IP is locked out', 'vigilante' ); ?>
4300 </label>
4301 </td>
4302 </tr>
4303 <tr>
4304 <th scope="row"><?php esc_html_e( 'Notify on Admin Login', 'vigilante' ); ?></th>
4305 <td>
4306 <label>
4307 <input type="checkbox" name="login_security[notify_on_admin_login]" value="1" <?php checked( ! empty( $options['notify_on_admin_login'] ) ); ?>>
4308 <?php esc_html_e( 'Send email when an administrator logs in', 'vigilante' ); ?>
4309 </label>
4310 </td>
4311 </tr>
4312 </table>
4313
4314 <p class="description">
4315 <?php
4316 printf(
4317 /* translators: %s: Link to notification settings */
4318 esc_html__( '&#9432; Notifications are sent to the recipients configured in %s.', 'vigilante' ),
4319 '<a href="' . esc_url( admin_url( 'admin.php?page=vigilante&tab=tools' ) ) . '">' . esc_html__( 'Settings & Tools', 'vigilante' ) . '</a>'
4320 );
4321 ?>
4322 </p>
4323 </div>
4324
4325 <p class="submit vigilante-submit-buttons">
4326 <button type="submit" class="button button-primary vigilante-save-btn" data-original-text="<?php esc_attr_e( 'Save Settings', 'vigilante' ); ?>">
4327 <?php esc_html_e( 'Save Settings', 'vigilante' ); ?>
4328 </button>
4329 <button type="button" class="button vigilante-reset-section-btn" data-original-text="<?php esc_attr_e( 'Reset to Defaults', 'vigilante' ); ?>">
4330 <?php esc_html_e( 'Reset to Defaults', 'vigilante' ); ?>
4331 </button>
4332 </p>
4333 </form>
4334
4335 <?php $this->render_lockout_info_section( $options, $lockouts ); ?>
4336 <?php
4337 }
4338
4339 /**
4340 * Render lockout information and blocked IPs section
4341 *
4342 * @param array $options Login security options.
4343 * @param array $lockouts Currently locked out IPs.
4344 */
4345 private function render_lockout_info_section( $options, $lockouts ) {
4346 $max_attempts = absint( $options['max_attempts'] ?? 5 );
4347 $lockout_duration = absint( $options['lockout_duration'] ?? 1800 );
4348 $lockout_increment = ! empty( $options['lockout_increment'] );
4349 $max_lockout = absint( $options['max_lockout_duration'] ?? 86400 );
4350 $two_factor = $options['two_factor'] ?? array();
4351 $two_factor_enabled = ! empty( $two_factor['enabled'] );
4352 ?>
4353 <div class="vigilante-settings-section vigilante-lockout-section">
4354 <h2 id="vigilante-section-login-status"><?php esc_html_e( 'Login Protection Status', 'vigilante' ); ?></h2>
4355
4356 <table class="form-table">
4357 <tr>
4358 <th scope="row"><?php esc_html_e( 'Current settings', 'vigilante' ); ?></th>
4359 <td>
4360 <?php
4361 printf(
4362 /* translators: 1: Maximum login attempts, 2: Lockout duration in minutes */
4363 esc_html__( 'After %1$d failed login attempts, the IP address is blocked for %2$d minutes.', 'vigilante' ),
4364 absint( $max_attempts ),
4365 absint( ceil( $lockout_duration / 60 ) )
4366 );
4367
4368 if ( $lockout_increment ) {
4369 echo '<br>';
4370 printf(
4371 /* translators: %d: Maximum lockout duration in hours */
4372 esc_html__( 'Progressive lockout enabled (max: %d hours).', 'vigilante' ),
4373 absint( ceil( $max_lockout / 3600 ) )
4374 );
4375 }
4376
4377 if ( $two_factor_enabled ) {
4378 echo '<br>';
4379 esc_html_e( 'Failed 2FA codes also count toward the lockout limit.', 'vigilante' );
4380 }
4381 ?>
4382 </td>
4383 </tr>
4384 <tr>
4385 <th scope="row"><?php esc_html_e( 'Blocked IPs', 'vigilante' ); ?></th>
4386 <td>
4387 <?php if ( ! empty( $lockouts ) ) : ?>
4388 <div class="vigilante-paginated-section">
4389 <div class="vigilante-fi-pagination-wrap"></div>
4390 <table class="wp-list-table widefat fixed striped vigilante-fi-paginated">
4391 <thead>
4392 <tr>
4393 <th><?php esc_html_e( 'IP Address', 'vigilante' ); ?></th>
4394 <th><?php esc_html_e( 'Attempts', 'vigilante' ); ?></th>
4395 <th><?php esc_html_e( 'Blocked Until', 'vigilante' ); ?></th>
4396 <th><?php esc_html_e( 'Actions', 'vigilante' ); ?></th>
4397 </tr>
4398 </thead>
4399 <tbody>
4400 <?php foreach ( $lockouts as $lockout ) :
4401 $lockout_time = strtotime( $lockout->locked_until );
4402 $remaining_seconds = $lockout_time - time();
4403 $remaining_text = $this->format_remaining_time( $remaining_seconds );
4404 ?>
4405 <tr>
4406 <td><code><?php echo esc_html( $lockout->ip_address ); ?></code></td>
4407 <td><?php echo esc_html( $lockout->attempts ); ?></td>
4408 <td>
4409 <?php echo esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $lockout_time ) ); ?>
4410 <br>
4411 <small class="description">
4412 <?php
4413 printf(
4414 /* translators: %s: Remaining time */
4415 esc_html__( '%s remaining', 'vigilante' ),
4416 esc_html( $remaining_text )
4417 );
4418 ?>
4419 </small>
4420 </td>
4421 <td>
4422 <button type="button" class="button button-small vigilante-clear-lockout" data-ip="<?php echo esc_attr( $lockout->ip_address ); ?>">
4423 <?php esc_html_e( 'Unblock', 'vigilante' ); ?>
4424 </button>
4425 </td>
4426 </tr>
4427 <?php endforeach; ?>
4428 </tbody>
4429 </table>
4430 </div>
4431 <p class="description" style="margin-top: 10px;">
4432 <button type="button" class="button vigilante-clear-all-lockouts">
4433 <?php esc_html_e( 'Unblock All IPs', 'vigilante' ); ?>
4434 </button>
4435 <span style="margin-left: 10px;">
4436 <?php
4437 printf(
4438 /* translators: %d: Number of blocked IPs */
4439 esc_html( _n( '%d IP currently blocked', '%d IPs currently blocked', count( $lockouts ), 'vigilante' ) ),
4440 count( $lockouts )
4441 );
4442 ?>
4443 </span>
4444 </p>
4445 <?php else : ?>
4446 <span class="dashicons dashicons-yes-alt" style="color: #00a32a; font-size: 20px; width: 20px; height: 20px; vertical-align: middle;"></span>
4447 <span style="vertical-align: middle; margin-left: 5px;"><?php esc_html_e( 'No IPs are currently blocked. All clear!', 'vigilante' ); ?></span>
4448 <?php endif; ?>
4449 </td>
4450 </tr>
4451 </table>
4452 </div>
4453 <?php
4454 }
4455
4456 /**
4457 * Format remaining lockout time in human readable format
4458 *
4459 * @param int $seconds Remaining seconds.
4460 * @return string Formatted time string.
4461 */
4462 private function format_remaining_time( $seconds ) {
4463 if ( $seconds <= 0 ) {
4464 return __( 'expired', 'vigilante' );
4465 }
4466
4467 if ( $seconds < 60 ) {
4468 return sprintf(
4469 /* translators: %d: Number of seconds */
4470 _n( '%d second', '%d seconds', $seconds, 'vigilante' ),
4471 $seconds
4472 );
4473 }
4474
4475 if ( $seconds < 3600 ) {
4476 $minutes = ceil( $seconds / 60 );
4477 return sprintf(
4478 /* translators: %d: Number of minutes */
4479 _n( '%d minute', '%d minutes', $minutes, 'vigilante' ),
4480 $minutes
4481 );
4482 }
4483
4484 $hours = floor( $seconds / 3600 );
4485 $remaining_minutes = ceil( ( $seconds % 3600 ) / 60 );
4486
4487 if ( $remaining_minutes > 0 ) {
4488 return sprintf(
4489 /* translators: 1: Number of hours, 2: Number of minutes */
4490 __( '%1$d hours %2$d minutes', 'vigilante' ),
4491 $hours,
4492 $remaining_minutes
4493 );
4494 }
4495
4496 return sprintf(
4497 /* translators: %d: Number of hours */
4498 _n( '%d hour', '%d hours', $hours, 'vigilante' ),
4499 $hours
4500 );
4501 }
4502
4503 /**
4504 * Render 2FA settings section
4505 *
4506 * @param array $options Login security options.
4507 */
4508 private function render_2fa_settings( $options ) {
4509 $two_factor = $options['two_factor'] ?? array();
4510 $all_roles = wp_roles()->roles;
4511 $enforced = $two_factor['enforced_roles'] ?? array( 'administrator', 'editor' );
4512 $excluded = $two_factor['excluded_users'] ?? array();
4513 $method = $two_factor['method'] ?? 'email';
4514 $grace_days = $two_factor['grace_period_days'] ?? 3;
4515 ?>
4516 <h3 id="vigilante-section-login-2fa">
4517 <?php esc_html_e( 'Two-Factor Authentication (2FA)', 'vigilante' ); ?>
4518 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
4519 <span class="vigilante-method-badge database"><?php esc_html_e( 'Database', 'vigilante' ); ?></span>
4520 </h3>
4521 <p class="description"><?php esc_html_e( 'Require a second verification step after password. Choose between email codes or an authenticator app (TOTP).', 'vigilante' ); ?></p>
4522
4523 <table class="form-table">
4524 <tr>
4525 <th scope="row"><?php esc_html_e( 'Enable 2FA', 'vigilante' ); ?></th>
4526 <td>
4527 <label>
4528 <input type="checkbox" name="login_security[two_factor][enabled]" id="vigilante_2fa_enabled" value="1" <?php checked( ! empty( $two_factor['enabled'] ) ); ?>>
4529 <?php esc_html_e( 'Enable two-factor authentication', 'vigilante' ); ?>
4530 </label>
4531 </td>
4532 </tr>
4533 </table>
4534
4535 <div class="vigilante-2fa-settings-wrapper <?php echo empty( $two_factor['enabled'] ) ? 'vigilante-2fa-settings-disabled' : ''; ?>">
4536 <table class="form-table">
4537 <tr>
4538 <th scope="row"><?php esc_html_e( 'Verification method', 'vigilante' ); ?></th>
4539 <td>
4540 <fieldset class="vigilante-2fa-method-selector">
4541 <label class="vigilante-2fa-method-option <?php echo 'email' === $method ? 'selected' : ''; ?>">
4542 <input type="radio" name="login_security[two_factor][method]" value="email" <?php checked( $method, 'email' ); ?>>
4543 <span class="dashicons dashicons-email"></span>
4544 <span class="method-info">
4545 <strong><?php esc_html_e( 'Email code', 'vigilante' ); ?></strong>
4546 <span><?php esc_html_e( 'Users receive a 6-digit code via email after entering their password.', 'vigilante' ); ?></span>
4547 </span>
4548 </label>
4549 <label class="vigilante-2fa-method-option <?php echo 'totp' === $method ? 'selected' : ''; ?>">
4550 <input type="radio" name="login_security[two_factor][method]" value="totp" <?php checked( $method, 'totp' ); ?>>
4551 <span class="dashicons dashicons-smartphone"></span>
4552 <span class="method-info">
4553 <strong><?php esc_html_e( 'Authenticator app (TOTP)', 'vigilante' ); ?></strong>
4554 <span><?php esc_html_e( 'Users verify with a time-based code from Google Authenticator, Authy, etc.', 'vigilante' ); ?></span>
4555 </span>
4556 </label>
4557 </fieldset>
4558 </td>
4559 </tr>
4560 <tr>
4561 <th scope="row"><?php esc_html_e( 'Enforce for roles', 'vigilante' ); ?></th>
4562 <td>
4563 <div class="vigilante-2fa-roles">
4564 <?php $this->list_sentinel( 'login_security[two_factor][enforced_roles]' ); ?>
4565 <?php foreach ( $all_roles as $role_slug => $role_data ) :
4566 $user_count = count( get_users( array( 'role' => $role_slug, 'fields' => 'ID' ) ) );
4567 ?>
4568 <label>
4569 <input type="checkbox"
4570 name="login_security[two_factor][enforced_roles][]"
4571 value="<?php echo esc_attr( $role_slug ); ?>"
4572 <?php checked( in_array( $role_slug, $enforced, true ) ); ?>>
4573 <span class="role-name"><?php echo esc_html( translate_user_role( $role_data['name'] ) ); ?></span>
4574 <span class="role-count">(<?php echo esc_html( $user_count ); ?>)</span>
4575 </label>
4576 <?php endforeach; ?>
4577 </div>
4578 <p class="description"><?php esc_html_e( 'Users with these roles will be required to verify with the selected method.', 'vigilante' ); ?></p>
4579 </td>
4580 </tr>
4581 <tr>
4582 <th scope="row"><?php esc_html_e( 'Exclude specific users', 'vigilante' ); ?></th>
4583 <td>
4584 <div class="vigilante-2fa-user-search-container">
4585 <div class="vigilante-2fa-user-search">
4586 <span class="search-icon"></span>
4587 <input type="text"
4588 id="vigilante_2fa_user_search"
4589 placeholder="<?php esc_attr_e( 'Search users by name or email...', 'vigilante' ); ?>"
4590 autocomplete="off">
4591 <div class="vigilante-2fa-search-results"></div>
4592 </div>
4593 <div class="vigilante-2fa-excluded-users">
4594 <?php $this->list_sentinel( 'login_security[two_factor][excluded_users]' ); ?>
4595 <?php
4596 foreach ( $excluded as $user_id ) :
4597 $user = get_user_by( 'ID', $user_id );
4598 if ( ! $user ) continue;
4599 ?>
4600 <div class="vigilante-2fa-excluded-user" data-user-id="<?php echo esc_attr( $user_id ); ?>">
4601 <span class="user-display"><?php echo esc_html( $user->display_name . ' (' . $user->user_email . ')' ); ?></span>
4602 <button type="button" class="remove-user" aria-label="<?php esc_attr_e( 'Remove', 'vigilante' ); ?>">&times;</button>
4603 <input type="hidden" name="login_security[two_factor][excluded_users][]" value="<?php echo esc_attr( $user_id ); ?>">
4604 </div>
4605 <?php endforeach; ?>
4606 </div>
4607 </div>
4608 <p class="description"><?php esc_html_e( 'These users will not be required to use 2FA regardless of their role.', 'vigilante' ); ?></p>
4609 </td>
4610 </tr>
4611
4612 <!-- Remember device option -->
4613 <tr>
4614 <th scope="row"><?php esc_html_e( 'Remember device', 'vigilante' ); ?></th>
4615 <td>
4616 <label>
4617 <input type="checkbox" name="login_security[two_factor][allow_remember_device]" value="1" <?php checked( ! empty( $two_factor['allow_remember_device'] ) ); ?>>
4618 <?php esc_html_e( 'Allow users to skip 2FA verification on trusted devices', 'vigilante' ); ?>
4619 </label>
4620 <p class="description">
4621 <?php
4622 printf(
4623 /* translators: %d: Number of days */
4624 esc_html__( 'When enabled, users can check "Remember this device" on the verification screen to skip 2FA for %d days.', 'vigilante' ),
4625 absint( $two_factor['remember_device_days'] ?? 30 )
4626 );
4627 ?>
4628 </p>
4629 </td>
4630 </tr>
4631
4632 <!-- TOTP-specific: Grace period -->
4633 <tr class="vigilante-2fa-totp-only" <?php echo 'totp' !== $method ? 'style="display:none;"' : ''; ?>>
4634 <th scope="row"><label for="vigilante-f-login-security-two-factor-grace-period-days"><?php esc_html_e( 'Grace period', 'vigilante' ); ?></label></th>
4635 <td>
4636 <input id="vigilante-f-login-security-two-factor-grace-period-days" type="number"
4637 name="login_security[two_factor][grace_period_days]"
4638 value="<?php echo esc_attr( $grace_days ); ?>"
4639 min="0" max="30" class="small-text">
4640 <?php esc_html_e( 'days', 'vigilante' ); ?>
4641 <p class="description"><?php esc_html_e( 'Days users have to set up their authenticator app. During this period they can log in without TOTP. Set to 0 for immediate enforcement.', 'vigilante' ); ?></p>
4642 </td>
4643 </tr>
4644
4645 <!-- Email-specific: Sender name -->
4646 <tr class="vigilante-2fa-email-only" <?php echo 'email' !== $method ? 'style="display:none;"' : ''; ?>>
4647 <th scope="row"><label for="vigilante-f-login-security-two-factor-email-from-name"><?php esc_html_e( 'Email sender name', 'vigilante' ); ?></label></th>
4648 <td>
4649 <input id="vigilante-f-login-security-two-factor-email-from-name" type="text"
4650 name="login_security[two_factor][email_from_name]"
4651 value="<?php echo esc_attr( $two_factor['email_from_name'] ?? '' ); ?>"
4652 class="regular-text vigilante-2fa-email-from"
4653 placeholder="<?php echo esc_attr( get_bloginfo( 'name' ) ); ?>">
4654 <p class="description"><?php esc_html_e( 'Name shown in verification emails. Leave empty to use site name.', 'vigilante' ); ?></p>
4655 </td>
4656 </tr>
4657
4658 <!-- TOTP-specific: Reset users -->
4659 <tr class="vigilante-2fa-totp-only" <?php echo 'totp' !== $method ? 'style="display:none;"' : ''; ?>>
4660 <th scope="row"><?php esc_html_e( 'Reset user TOTP', 'vigilante' ); ?></th>
4661 <td>
4662 <div class="vigilante-totp-reset-container">
4663 <div class="vigilante-2fa-user-search">
4664 <span class="search-icon"></span>
4665 <input type="text"
4666 id="vigilante_totp_reset_search"
4667 placeholder="<?php esc_attr_e( 'Search users with TOTP configured...', 'vigilante' ); ?>"
4668 autocomplete="off">
4669 <div class="vigilante-totp-reset-results"></div>
4670 </div>
4671 <div class="vigilante-totp-reset-selected"></div>
4672 <button type="button" id="vigilante_totp_reset_btn" class="button" style="display:none;">
4673 <?php esc_html_e( 'Reset selected', 'vigilante' ); ?>
4674 </button>
4675 <span class="vigilante-totp-reset-status"></span>
4676 </div>
4677 <p class="description"><?php esc_html_e( 'Reset TOTP for users who lost access to their authenticator app. They will need to set up again.', 'vigilante' ); ?></p>
4678 </td>
4679 </tr>
4680 </table>
4681
4682 <h3><?php esc_html_e( 'User notification', 'vigilante' ); ?></h3>
4683 <table class="form-table">
4684 <tr>
4685 <th scope="row"><?php esc_html_e( 'Notify on enable', 'vigilante' ); ?></th>
4686 <td>
4687 <label>
4688 <input type="checkbox" name="login_security[two_factor][notify_on_enable]" value="1" <?php checked( $two_factor['notify_on_enable'] ?? true ); ?>>
4689 <?php esc_html_e( 'Send notification email to affected users when 2FA is enabled', 'vigilante' ); ?>
4690 </label>
4691
4692 <div class="vigilante-2fa-notification-options">
4693 <label>
4694 <input type="radio" name="vigilante_2fa_notify_mode" value="all" checked>
4695 <?php esc_html_e( 'Send to all affected users', 'vigilante' ); ?>
4696 </label>
4697 <label>
4698 <input type="radio" name="vigilante_2fa_notify_mode" value="new">
4699 <?php esc_html_e( 'Send only to users not previously notified', 'vigilante' ); ?>
4700 </label>
4701 </div>
4702
4703 <div class="vigilante-2fa-send-notification">
4704 <button type="button" id="vigilante_2fa_send_notification" class="button">
4705 <?php esc_html_e( 'Send notification now', 'vigilante' ); ?>
4706 </button>
4707 <span class="vigilante-2fa-notification-status"></span>
4708 </div>
4709 </td>
4710 </tr>
4711 </table>
4712 </div>
4713 <?php
4714 }
4715
4716 /**
4717 * Render security headers tab
4718 */
4719 /**
4720 * Offer back the header settings the 2.9.8 migration wiped.
4721 *
4722 * Rendered outside the settings form on purpose, so its buttons can never
4723 * submit it, and only when there is something to actually change. Shows the
4724 * difference before anything is written: nothing is applied that the owner
4725 * has not seen first.
4726 *
4727 * @since 2.10.0
4728 */
4729 private function render_headers_recovery_offer() {
4730 /*
4731 * On a network the .htaccess belongs to every site and only the main one
4732 * writes it, so this is not a decision a subsite gets to make. Its own
4733 * security_headers options are inert anyway: what the network serves
4734 * comes from the file the main site owns. Without this gate a subsite
4735 * administrator was shown a Restore button that could only ever answer
4736 * with a permission error, which is worse than showing nothing.
4737 */
4738 if ( ! Vigilante_Settings::can_write_shared_files() ) {
4739 return;
4740 }
4741
4742 if ( ! Vigilante_Htaccess_Recovery::is_available() ) {
4743 /*
4744 * Already restored. Offer to take it back for as long as the previous
4745 * section is still stored: a restore that cannot be undone is a second
4746 * irreversible change on top of the one being repaired.
4747 */
4748 if ( Vigilante_Htaccess_Recovery::has_undo() ) {
4749 ?>
4750 <div class="notice notice-info inline" id="vigilante-headers-recovery-undo">
4751 <p>
4752 <?php esc_html_e( 'The Security Headers settings were restored from the copy Vigilant had kept of your .htaccess.', 'vigilante' ); ?>
4753 <button type="button" class="button button-small" id="vigilante-recovery-undo">
4754 <?php esc_html_e( 'Undo the restore', 'vigilante' ); ?>
4755 </button>
4756 </p>
4757 </div>
4758 <?php
4759 }
4760
4761 return;
4762 }
4763
4764 $rows = Vigilante_Htaccess_Recovery::get_diff( $this->settings );
4765
4766 if ( empty( $rows ) ) {
4767 return;
4768 }
4769
4770 $snapshot = Vigilante_Htaccess_Recovery::get_snapshot();
4771 $taken = isset( $snapshot['time'] ) ? (int) $snapshot['time'] : 0;
4772 $block = Vigilante_Htaccess_Recovery::get_raw_block();
4773 ?>
4774 <div class="vigilante-settings-section" id="vigilante-headers-recovery">
4775 <h2><?php esc_html_e( 'Recover your previous header settings', 'vigilante' ); ?></h2>
4776 <p>
4777 <?php esc_html_e( 'An earlier update reset this tab to factory values: the migration replaced the whole section instead of merging into it. Your server kept sending the right headers, because the .htaccess had not been rewritten yet, so Vigilant saved a copy of that file before touching it. These are the settings it found in that copy.', 'vigilante' ); ?>
4778 </p>
4779 <?php if ( $taken ) : ?>
4780 <p class="description">
4781 <?php
4782 printf(
4783 /* translators: %s: date and time the .htaccess copy was taken. */
4784 esc_html__( 'Copy taken on %s.', 'vigilante' ),
4785 esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $taken ) )
4786 );
4787 ?>
4788 </p>
4789 <?php endif; ?>
4790
4791 <table class="widefat striped">
4792 <thead>
4793 <tr>
4794 <th scope="col"><?php esc_html_e( 'Setting', 'vigilante' ); ?></th>
4795 <th scope="col"><?php esc_html_e( 'Now', 'vigilante' ); ?></th>
4796 <th scope="col"><?php esc_html_e( 'Would be restored to', 'vigilante' ); ?></th>
4797 </tr>
4798 </thead>
4799 <tbody>
4800 <?php foreach ( $rows as $row ) : ?>
4801 <tr>
4802 <th scope="row"><?php echo esc_html( $row['label'] ); ?></th>
4803 <td><?php echo esc_html( $row['current'] ); ?></td>
4804 <td>
4805 <?php echo esc_html( $row['recovered'] ); ?>
4806 <?php if ( ! empty( $row['detail'] ) ) : ?>
4807 <br><span class="description"><?php echo esc_html( $row['detail'] ); ?></span>
4808 <?php endif; ?>
4809 </td>
4810 </tr>
4811 <?php endforeach; ?>
4812 </tbody>
4813 </table>
4814
4815 <p class="description">
4816 <?php esc_html_e( 'Only these settings are written. The .htaccess is then rebuilt from them, the same way saving this tab rebuilds it. The stored copy of the file is never written back, so nothing your host, your cache plugin or your CDN added to it is touched.', 'vigilante' ); ?>
4817 </p>
4818
4819 <?php if ( '' !== $block ) : ?>
4820 <details>
4821 <summary><?php esc_html_e( 'Show the saved .htaccess block', 'vigilante' ); ?></summary>
4822 <textarea readonly rows="12" class="large-text code" onclick="this.select();"><?php echo esc_textarea( $block ); ?></textarea>
4823 </details>
4824 <?php endif; ?>
4825
4826 <p class="submit vigilante-submit-buttons">
4827 <button type="button" class="button button-primary" id="vigilante-recovery-restore">
4828 <?php esc_html_e( 'Restore these settings', 'vigilante' ); ?>
4829 </button>
4830 <button type="button" class="button" id="vigilante-recovery-dismiss">
4831 <?php esc_html_e( 'No thanks, keep what I have', 'vigilante' ); ?>
4832 </button>
4833 </p>
4834 <div id="vigilante-recovery-result"></div>
4835 </div>
4836 <?php
4837 }
4838
4839 private function render_tab_headers() {
4840 $is_disabled = $this->render_module_disabled_notice( 'security_headers' );
4841 // Every setting on this tab ends up in .htaccess, so on a subsite the
4842 // whole tab is somebody else's, values included.
4843 $vg_shared_locked = $this->shared_files_locked();
4844 $options = $this->get_section_for_display( 'security_headers' );
4845 ?>
4846 <?php $this->render_headers_recovery_offer(); ?>
4847
4848 <form class="vigilante-settings-form <?php echo $is_disabled ? 'vigilante-form-disabled' : ''; ?>" data-section="security_headers" <?php echo $is_disabled ? 'inert' : ''; ?>>
4849 <?php $this->render_shared_files_notice(); ?>
4850 <div id="vigilante-section-headers-main" class="vigilante-settings-section <?php echo $vg_shared_locked ? 'vigilante-form-disabled' : ''; ?>" <?php echo $vg_shared_locked ? 'inert' : ''; ?>>
4851 <h2>
4852 <?php esc_html_e( 'Security Headers', 'vigilante' ); ?>
4853 <span class="vigilante-method-badge htaccess"><?php esc_html_e( 'HTACCESS', 'vigilante' ); ?></span>
4854 </h2>
4855 <p><?php esc_html_e( 'HTTP headers sent with every response via .htaccess (mod_headers).', 'vigilante' ); ?></p>
4856
4857 <table class="form-table">
4858 <tr>
4859 <th scope="row"><label for="vigilante-f-security-headers-x-frame-options"><?php esc_html_e( 'X-Frame-Options', 'vigilante' ); ?></label></th>
4860 <td>
4861 <select id="vigilante-f-security-headers-x-frame-options" name="security_headers[x_frame_options]">
4862 <option value="" <?php selected( empty( $options['x_frame_options'] ) ); ?>><?php esc_html_e( 'Disabled', 'vigilante' ); ?></option>
4863 <option value="SAMEORIGIN" <?php selected( $options['x_frame_options'] ?? '', 'SAMEORIGIN' ); ?>>SAMEORIGIN</option>
4864 <option value="DENY" <?php selected( $options['x_frame_options'] ?? '', 'DENY' ); ?>>DENY</option>
4865 </select>
4866 <p class="description"><?php esc_html_e( '&#9432; Prevents clickjacking attacks. Also sets CSP frame-ancestors automatically.', 'vigilante' ); ?></p>
4867 </td>
4868 </tr>
4869 <tr>
4870 <th scope="row"><?php esc_html_e( 'X-Content-Type-Options', 'vigilante' ); ?></th>
4871 <td>
4872 <label>
4873 <input type="checkbox" name="security_headers[x_content_type_options]" value="1" <?php checked( ! empty( $options['x_content_type_options'] ) ); ?>>
4874 <?php esc_html_e( 'Add nosniff header to prevent MIME type sniffing', 'vigilante' ); ?>
4875 </label>
4876 </td>
4877 </tr>
4878 <tr>
4879 <th scope="row"><label for="vigilante-f-security-headers-referrer-policy"><?php esc_html_e( 'Referrer-Policy', 'vigilante' ); ?></label></th>
4880 <td>
4881 <select id="vigilante-f-security-headers-referrer-policy" name="security_headers[referrer_policy]">
4882 <option value="" <?php selected( empty( $options['referrer_policy'] ) ); ?>><?php esc_html_e( 'Disabled', 'vigilante' ); ?></option>
4883 <option value="no-referrer" <?php selected( $options['referrer_policy'] ?? '', 'no-referrer' ); ?>>no-referrer</option>
4884 <option value="strict-origin-when-cross-origin" <?php selected( $options['referrer_policy'] ?? '', 'strict-origin-when-cross-origin' ); ?>>strict-origin-when-cross-origin</option>
4885 <option value="same-origin" <?php selected( $options['referrer_policy'] ?? '', 'same-origin' ); ?>>same-origin</option>
4886 </select>
4887 </td>
4888 </tr>
4889 </table>
4890
4891 <h3 id="vigilante-section-headers-csp"><?php esc_html_e( 'Content Security Policy', 'vigilante' ); ?></h3>
4892 <table class="form-table">
4893 <tr>
4894 <th scope="row"><?php esc_html_e( 'Enable CSP', 'vigilante' ); ?></th>
4895 <td>
4896 <label>
4897 <input type="checkbox" name="security_headers[csp][enabled]" value="1" <?php checked( ! empty( $options['csp']['enabled'] ) ); ?>>
4898 <?php esc_html_e( 'Enable Content Security Policy', 'vigilante' ); ?>
4899 </label>
4900 <p class="description"><?php esc_html_e( 'The default policy includes upgrade-insecure-requests, which asks browsers to request every http:// resource over https. Vigilant writes it only when the site address starts with https://, because on a site served over http it would stop every stylesheet, script and image from loading. If your site is served over https through a proxy or CDN that terminates TLS, set the site address to https:// in Settings > General; the rules follow the next time this tab is saved or Vigilant is updated.', 'vigilante' ); ?></p>
4901 </td>
4902 </tr>
4903 <tr>
4904 <th scope="row"><?php esc_html_e( 'Report Only Mode', 'vigilante' ); ?></th>
4905 <td>
4906 <label>
4907 <input type="checkbox" name="security_headers[csp][report_only]" value="1" <?php checked( ! empty( $options['csp']['report_only'] ) ); ?>>
4908 <?php esc_html_e( 'Report violations without blocking (for testing)', 'vigilante' ); ?>
4909 </label>
4910 </td>
4911 </tr>
4912 </table>
4913
4914 <h3 id="vigilante-section-headers-force-https"><?php esc_html_e( 'HTTPS', 'vigilante' ); ?></h3>
4915 <p class="description"><?php esc_html_e( 'HTTPS is strongly recommended, but Vigilant will not impose it. Enable only what your site already supports.', 'vigilante' ); ?></p>
4916 <table class="form-table">
4917 <tr>
4918 <th scope="row"><?php esc_html_e( 'Redirect HTTP to HTTPS', 'vigilante' ); ?></th>
4919 <td>
4920 <label>
4921 <input type="checkbox" name="security_headers[redirect_http_to_https]" value="1" <?php checked( ! empty( $options['redirect_http_to_https'] ) ); ?>>
4922 <?php esc_html_e( 'Send visitors arriving over HTTP to the HTTPS address', 'vigilante' ); ?>
4923 </label>
4924 <p class="description"><?php esc_html_e( 'Only applies when the site address is already an https:// one. On a site still published over HTTP it does nothing, so it cannot leave the site unreachable.', 'vigilante' ); ?></p>
4925 </td>
4926 </tr>
4927 <tr>
4928 <th scope="row"><?php esc_html_e( 'Fix Mixed Content', 'vigilante' ); ?></th>
4929 <td>
4930 <label>
4931 <input type="checkbox" name="security_headers[fix_mixed_content]" value="1" <?php checked( ! empty( $options['fix_mixed_content'] ) ); ?>>
4932 <?php esc_html_e( 'Rewrite this site http:// resources to https://', 'vigilante' ); ?>
4933 </label>
4934 <p class="description"><?php esc_html_e( 'Off by default. Only touches addresses of this same site, and only when the site is already served over HTTPS, so it cannot break an external resource. Useful right after moving a site to HTTPS, when old content still points at http:// addresses.', 'vigilante' ); ?></p>
4935 </td>
4936 </tr>
4937 <tr id="field-upgrade-insecure-requests">
4938 <th scope="row"><?php esc_html_e( 'Upgrade Insecure Requests', 'vigilante' ); ?></th>
4939 <td>
4940 <label>
4941 <input type="checkbox" name="security_headers[upgrade_insecure_requests]" value="1" <?php checked( ! empty( $options['upgrade_insecure_requests'] ) ); ?>>
4942 <?php esc_html_e( 'Ask browsers to upgrade every http:// request to https://', 'vigilante' ); ?>
4943 </label>
4944 <p class="description"><?php esc_html_e( '&#9888; Off by default. This one also covers resources hosted elsewhere: anything served from a domain with no HTTPS stops loading instead of loading insecurely. Turn it on once you know every external resource the site uses is available over HTTPS.', 'vigilante' ); ?></p>
4945 </td>
4946 </tr>
4947 <tr>
4948 <th scope="row"><?php esc_html_e( 'Rewrite Site Address on Activation', 'vigilante' ); ?></th>
4949 <td>
4950 <label>
4951 <input type="checkbox" name="security_headers[force_https]" value="1" <?php checked( ! empty( $options['force_https'] ) ); ?>>
4952 <?php esc_html_e( 'Change the WordPress and site addresses to https:// when the plugin is activated', 'vigilante' ); ?>
4953 </label>
4954 <p class="description"><?php esc_html_e( '&#9888; Off by default. This writes to the WordPress Address and Site Address settings, and turning the plugin off later does not undo it. It only runs on activation, and only when the site answers over HTTPS.', 'vigilante' ); ?></p>
4955 </td>
4956 </tr>
4957 </table>
4958
4959 <h3 id="vigilante-section-headers-hsts"><?php esc_html_e( 'HSTS (HTTP Strict Transport Security)', 'vigilante' ); ?></h3>
4960 <?php $vig_home_https = ( 0 === strpos( (string) get_option( 'home' ), 'https://' ) ); ?>
4961 <p class="description"><?php esc_html_e( 'Tells browsers to reach this site over HTTPS and never over HTTP, for as long as the max age below.', 'vigilante' ); ?></p>
4962 <?php if ( ! $vig_home_https ) : ?>
4963 <p class="description" style="color:#b32d2e"><strong><?php esc_html_e( 'Unavailable: the site address still starts with http://. Enabling HSTS on a site not published over HTTPS would make it unreachable in any browser that honours it.', 'vigilante' ); ?></strong></p>
4964 <?php endif; ?>
4965 <table class="form-table">
4966 <tr>
4967 <th scope="row"><?php esc_html_e( 'Enable HSTS', 'vigilante' ); ?></th>
4968 <td>
4969 <?php if ( ! $vig_home_https ) : ?>
4970 <?php /* A disabled checkbox is not submitted, and a boolean missing from the post is treated as unticked, so saving the tab would silently switch HSTS off. Carry the stored value instead. */ ?>
4971 <input type="hidden" name="security_headers[hsts][enabled]" value="<?php echo ! empty( $options['hsts']['enabled'] ) ? '1' : '0'; ?>">
4972 <?php endif; ?>
4973 <label>
4974 <input type="checkbox" name="security_headers[hsts][enabled]" value="1" <?php checked( ! empty( $options['hsts']['enabled'] ) ); ?> <?php disabled( ! $vig_home_https ); ?>>
4975 <?php esc_html_e( 'Send the Strict-Transport-Security header', 'vigilante' ); ?>
4976 </label>
4977 <p class="description"><?php esc_html_e( '&#9888; Hard to undo: browsers remember it for the whole max age even if you turn it off later, so a site that loses its certificate stays unreachable until it expires. Start with a short max age.', 'vigilante' ); ?></p>
4978 </td>
4979 </tr>
4980 <tr>
4981 <th scope="row"><label for="vigilante-f-security-headers-hsts-max-age"><?php esc_html_e( 'Max Age', 'vigilante' ); ?></label></th>
4982 <td>
4983 <select id="vigilante-f-security-headers-hsts-max-age" name="security_headers[hsts][max_age]">
4984 <option value="86400" <?php selected( $options['hsts']['max_age'] ?? 31536000, 86400 ); ?>><?php esc_html_e( '1 day (testing)', 'vigilante' ); ?></option>
4985 <option value="2592000" <?php selected( $options['hsts']['max_age'] ?? 31536000, 2592000 ); ?>><?php esc_html_e( '30 days', 'vigilante' ); ?></option>
4986 <option value="31536000" <?php selected( $options['hsts']['max_age'] ?? 31536000, 31536000 ); ?>><?php esc_html_e( '1 year (recommended)', 'vigilante' ); ?></option>
4987 <option value="63072000" <?php selected( $options['hsts']['max_age'] ?? 31536000, 63072000 ); ?>><?php esc_html_e( '2 years', 'vigilante' ); ?></option>
4988 </select>
4989 </td>
4990 </tr>
4991 <tr>
4992 <th scope="row"><?php esc_html_e( 'Include Subdomains', 'vigilante' ); ?></th>
4993 <td>
4994 <label>
4995 <input type="checkbox" name="security_headers[hsts][include_subdomains]" value="1" <?php checked( ! empty( $options['hsts']['include_subdomains'] ) ); ?>>
4996 <?php esc_html_e( 'Apply HSTS to all subdomains', 'vigilante' ); ?>
4997 </label>
4998 </td>
4999 </tr>
5000 </table>
5001
5002 <h3 id="vigilante-section-headers-fingerprint"><?php esc_html_e( 'Server Identity', 'vigilante' ); ?></h3>
5003 <p class="description"><?php esc_html_e( 'Hide identifying information that servers expose in responses.', 'vigilante' ); ?></p>
5004 <table class="form-table">
5005 <tr>
5006 <th scope="row"><?php esc_html_e( 'Server Signature', 'vigilante' ); ?></th>
5007 <td>
5008 <label>
5009 <input type="checkbox" name="security_headers[hide_server_signature]" value="1" <?php checked( ! empty( $options['hide_server_signature'] ) ); ?>>
5010 <?php esc_html_e( 'Hide server signature (ServerSignature Off)', 'vigilante' ); ?>
5011 </label>
5012 </td>
5013 </tr>
5014 <tr>
5015 <th scope="row"><?php esc_html_e( 'Remove Fingerprinting Headers', 'vigilante' ); ?></th>
5016 <td>
5017 <label>
5018 <input type="checkbox" name="security_headers[remove_fingerprinting_headers]" value="1" <?php checked( ! empty( $options['remove_fingerprinting_headers'] ) ); ?>>
5019 <?php esc_html_e( 'Remove X-Powered-By and Server headers', 'vigilante' ); ?>
5020 </label>
5021 </td>
5022 </tr>
5023 </table>
5024 </div>
5025
5026 <?php $vg_cop = ( isset( $options['cross_origin_policies'] ) && is_array( $options['cross_origin_policies'] ) ) ? $options['cross_origin_policies'] : array(); ?>
5027 <div id="vigilante-section-headers-cross-origin" class="vigilante-settings-section <?php echo $vg_shared_locked ? 'vigilante-form-disabled' : ''; ?>" <?php echo $vg_shared_locked ? 'inert' : ''; ?>>
5028 <h2>
5029 <?php esc_html_e( 'Cross-Origin Policies', 'vigilante' ); ?>
5030 <span class="vigilante-method-badge htaccess"><?php esc_html_e( 'HTACCESS', 'vigilante' ); ?></span>
5031 </h2>
5032 <p><?php esc_html_e( 'Control how other origins may open, embed or fetch your site. Vigilant already sends these headers with the values below.', 'vigilante' ); ?></p>
5033
5034 <table class="form-table">
5035 <tr>
5036 <th scope="row"><label for="vigilante-f-security-headers-coop"><?php esc_html_e( 'Cross-Origin-Opener-Policy (COOP)', 'vigilante' ); ?></label></th>
5037 <td>
5038 <select id="vigilante-f-security-headers-coop" name="security_headers[cross_origin_policies][opener_policy]">
5039 <option value="" <?php selected( empty( $vg_cop['opener_policy'] ) ); ?>><?php esc_html_e( 'Disabled (header not sent)', 'vigilante' ); ?></option>
5040 <option value="unsafe-none" <?php selected( $vg_cop['opener_policy'] ?? '', 'unsafe-none' ); ?>>unsafe-none</option>
5041 <option value="same-origin-allow-popups" <?php selected( $vg_cop['opener_policy'] ?? '', 'same-origin-allow-popups' ); ?>><?php esc_html_e( 'same-origin-allow-popups (recommended)', 'vigilante' ); ?></option>
5042 <option value="same-origin" <?php selected( $vg_cop['opener_policy'] ?? '', 'same-origin' ); ?>>same-origin</option>
5043 </select>
5044 <p class="description"><?php esc_html_e( '&#9432; Cuts the link between your site and a window from another origin that opened it. Side effect: external tools that open your site in a new tab and talk to it through window.opener, such as Google Tag Assistant, will report that they cannot connect. Pick unsafe-none or Disabled if you need those tools.', 'vigilante' ); ?></p>
5045 </td>
5046 </tr>
5047 <tr>
5048 <th scope="row"><label for="vigilante-f-security-headers-coep"><?php esc_html_e( 'Cross-Origin-Embedder-Policy (COEP)', 'vigilante' ); ?></label></th>
5049 <td>
5050 <select id="vigilante-f-security-headers-coep" name="security_headers[cross_origin_policies][embedder_policy]">
5051 <option value="unsafe-none" <?php selected( ( $vg_cop['embedder_policy'] ?? 'unsafe-none' ), 'unsafe-none' ); ?>><?php esc_html_e( 'unsafe-none (header not sent)', 'vigilante' ); ?></option>
5052 <option value="credentialless" <?php selected( $vg_cop['embedder_policy'] ?? '', 'credentialless' ); ?>>credentialless</option>
5053 <option value="require-corp" <?php selected( $vg_cop['embedder_policy'] ?? '', 'require-corp' ); ?>>require-corp</option>
5054 </select>
5055 <p class="description"><?php esc_html_e( '&#9432; Requires every cross-origin resource to opt in. require-corp can block third-party images, fonts, videos and embeds that do not send their own CORP or CORS headers.', 'vigilante' ); ?></p>
5056 </td>
5057 </tr>
5058 <tr>
5059 <th scope="row"><label for="vigilante-f-security-headers-corp"><?php esc_html_e( 'Cross-Origin-Resource-Policy (CORP)', 'vigilante' ); ?></label></th>
5060 <td>
5061 <select id="vigilante-f-security-headers-corp" name="security_headers[cross_origin_policies][resource_policy]">
5062 <option value="" <?php selected( empty( $vg_cop['resource_policy'] ) ); ?>><?php esc_html_e( 'Disabled (header not sent)', 'vigilante' ); ?></option>
5063 <option value="same-site" <?php selected( $vg_cop['resource_policy'] ?? '', 'same-site' ); ?>>same-site</option>
5064 <option value="same-origin" <?php selected( $vg_cop['resource_policy'] ?? '', 'same-origin' ); ?>>same-origin</option>
5065 <option value="cross-origin" <?php selected( $vg_cop['resource_policy'] ?? '', 'cross-origin' ); ?>><?php esc_html_e( 'cross-origin (recommended)', 'vigilante' ); ?></option>
5066 </select>
5067 <p class="description"><?php esc_html_e( '&#9432; Declares who may load resources from this site. same-origin stops hotlinking, but it also breaks CDNs, feed readers and any external service that fetches your images or files.', 'vigilante' ); ?></p>
5068 </td>
5069 </tr>
5070 </table>
5071 </div>
5072
5073 <p class="submit vigilante-submit-buttons">
5074 <?php if ( ! $vg_shared_locked ) : ?>
5075 <button type="submit" class="button button-primary vigilante-save-btn" data-original-text="<?php esc_attr_e( 'Save Settings', 'vigilante' ); ?>">
5076 <?php esc_html_e( 'Save Settings', 'vigilante' ); ?>
5077 </button>
5078 <button type="button" class="button vigilante-reset-section-btn" data-original-text="<?php esc_attr_e( 'Reset to Defaults', 'vigilante' ); ?>">
5079 <?php esc_html_e( 'Reset to Defaults', 'vigilante' ); ?>
5080 </button>
5081 <?php endif; ?>
5082 <?php /* Testing what the server actually sends is read-only and useful from any site of a network. */ ?>
5083 <button type="button" class="button vigilante-test-headers">
5084 <?php esc_html_e( 'Test Headers', 'vigilante' ); ?>
5085 </button>
5086 </p>
5087 </form>
5088
5089 <div id="vigilante-headers-result"></div>
5090 <?php
5091 }
5092
5093 /**
5094 * Render REST API tab
5095 */
5096 private function render_tab_rest_api() {
5097 $is_disabled = $this->render_module_disabled_notice( 'rest_api_security' );
5098 $options = $this->settings->get_section( 'rest_api_security' );
5099 ?>
5100 <form class="vigilante-settings-form <?php echo $is_disabled ? 'vigilante-form-disabled' : ''; ?>" data-section="rest_api_security" <?php echo $is_disabled ? 'inert' : ''; ?>>
5101 <div id="vigilante-section-rest-api-main" class="vigilante-settings-section">
5102 <h2>
5103 <?php esc_html_e( 'REST API Security', 'vigilante' ); ?>
5104 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
5105 </h2>
5106 <p><?php esc_html_e( 'Control access to WordPress REST API endpoints.', 'vigilante' ); ?></p>
5107
5108 <table class="form-table">
5109 <tr>
5110 <th scope="row"><label for="vigilante-f-rest-api-security-mode"><?php esc_html_e( 'Access Mode', 'vigilante' ); ?></label></th>
5111 <td>
5112 <select id="vigilante-f-rest-api-security-mode" name="rest_api_security[mode]">
5113 <option value="open" <?php selected( $options['mode'] ?? 'selective', 'open' ); ?>><?php esc_html_e( 'Open - Allow all requests', 'vigilante' ); ?></option>
5114 <option value="selective" <?php selected( $options['mode'] ?? 'selective', 'selective' ); ?>><?php esc_html_e( 'Selective - Protect sensitive endpoints', 'vigilante' ); ?></option>
5115 <option value="authenticated_only" <?php selected( $options['mode'] ?? 'selective', 'authenticated_only' ); ?>><?php esc_html_e( 'Authenticated - Require login for all', 'vigilante' ); ?></option>
5116 </select>
5117 <p class="description"><?php esc_html_e( 'Selective mode is recommended.', 'vigilante' ); ?></p>
5118 </td>
5119 </tr>
5120 <tr id="field-block-user-enumeration">
5121 <th scope="row"><?php esc_html_e( 'Block User Enumeration', 'vigilante' ); ?></th>
5122 <td>
5123 <label>
5124 <input type="checkbox" name="rest_api_security[block_user_enumeration]" value="1" <?php checked( ! empty( $options['block_user_enumeration'] ) ); ?>>
5125 <?php esc_html_e( 'Protect /wp/v2/users endpoint for unauthenticated users', 'vigilante' ); ?>
5126 </label>
5127 </td>
5128 </tr>
5129 <tr>
5130 <th scope="row"><?php esc_html_e( 'Disable JSONP', 'vigilante' ); ?></th>
5131 <td>
5132 <label>
5133 <input type="checkbox" name="rest_api_security[disable_jsonp]" value="1" <?php checked( ! empty( $options['disable_jsonp'] ) ); ?>>
5134 <?php esc_html_e( 'Disable JSONP support in REST API', 'vigilante' ); ?>
5135 </label>
5136 </td>
5137 </tr>
5138 </table>
5139 </div>
5140
5141 <p class="submit vigilante-submit-buttons">
5142 <button type="submit" class="button button-primary vigilante-save-btn" data-original-text="<?php esc_attr_e( 'Save Settings', 'vigilante' ); ?>">
5143 <?php esc_html_e( 'Save Settings', 'vigilante' ); ?>
5144 </button>
5145 <button type="button" class="button vigilante-reset-section-btn" data-original-text="<?php esc_attr_e( 'Reset to Defaults', 'vigilante' ); ?>">
5146 <?php esc_html_e( 'Reset to Defaults', 'vigilante' ); ?>
5147 </button>
5148 </p>
5149 </form>
5150 <?php
5151 }
5152
5153 /**
5154 * Render User Security tab
5155 */
5156 private function render_tab_users() {
5157 $is_disabled = $this->render_module_disabled_notice( 'user_security' );
5158 $options = $this->settings->get_section( 'user_security' );
5159 $monitoring = $options['admin_monitoring'] ?? array();
5160 $registration = $options['registration_approval'] ?? array();
5161 $session_limits = $options['session_limits'] ?? array();
5162 $password_exp = $options['password_expiration'] ?? array();
5163 $email_verify = $options['email_verification'] ?? array();
5164 ?>
5165
5166 <!-- ============================================================
5167 SETTINGS SECTION - Single form for all configuration
5168 ============================================================ -->
5169 <form class="vigilante-settings-form <?php echo $is_disabled ? 'vigilante-form-disabled' : ''; ?>" data-section="user_security" <?php echo $is_disabled ? 'inert' : ''; ?>>
5170
5171 <!-- Username & Password Protection -->
5172 <div id="vigilante-section-users-password" class="vigilante-settings-section">
5173 <h2>
5174 <?php esc_html_e( 'Username & password protection', 'vigilante' ); ?>
5175 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
5176 </h2>
5177 <p><?php esc_html_e( 'Enforce secure username and password policies.', 'vigilante' ); ?></p>
5178
5179 <table class="form-table">
5180 <tr>
5181 <th scope="row"><?php esc_html_e( 'Block Insecure Usernames', 'vigilante' ); ?></th>
5182 <td>
5183 <label>
5184 <input type="checkbox" name="user_security[block_insecure_usernames]" value="1" <?php checked( ! empty( $options['block_insecure_usernames'] ) ); ?>>
5185 <?php esc_html_e( 'Prevent creation of users with common usernames (admin, administrator, etc.)', 'vigilante' ); ?>
5186 </label>
5187 </td>
5188 </tr>
5189 <?php
5190 $pw_policy = wp_parse_args(
5191 ( isset( $options['password_policy'] ) && is_array( $options['password_policy'] ) ) ? $options['password_policy'] : array(),
5192 array(
5193 'require_uppercase' => false,
5194 'require_lowercase' => false,
5195 'require_number' => false,
5196 'require_special' => false,
5197 'block_common' => true,
5198 'block_username' => true,
5199 'affected_roles' => array(),
5200 )
5201 );
5202 $pw_policy_roles = (array) $pw_policy['affected_roles'];
5203 ?>
5204 <tr>
5205 <th scope="row"><?php esc_html_e( 'Enforce Strong Passwords', 'vigilante' ); ?></th>
5206 <td>
5207 <label>
5208 <input type="checkbox" name="user_security[force_strong_passwords]" value="1" <?php checked( ! empty( $options['force_strong_passwords'] ) ); ?>>
5209 <?php esc_html_e( 'Check passwords against the requirements below when a user sets or changes one', 'vigilante' ); ?>
5210 </label>
5211 </td>
5212 </tr>
5213 <tr>
5214 <th scope="row"><label for="vigilante-f-user-security-min-password-length"><?php esc_html_e( 'Minimum Password Length', 'vigilante' ); ?></label></th>
5215 <td>
5216 <input id="vigilante-f-user-security-min-password-length" type="number" name="user_security[min_password_length]" value="<?php echo esc_attr( $options['min_password_length'] ?? 12 ); ?>" min="6" max="32" class="small-text">
5217 <?php esc_html_e( 'characters', 'vigilante' ); ?>
5218 </td>
5219 </tr>
5220 <tr>
5221 <th scope="row"><?php esc_html_e( 'Password Requirements', 'vigilante' ); ?></th>
5222 <td>
5223 <label style="display:block;margin-bottom:5px;">
5224 <input type="checkbox" name="user_security[password_policy][require_uppercase]" value="1" <?php checked( ! empty( $pw_policy['require_uppercase'] ) ); ?>>
5225 <?php esc_html_e( 'Require an uppercase letter (A-Z)', 'vigilante' ); ?>
5226 </label>
5227 <label style="display:block;margin-bottom:5px;">
5228 <input type="checkbox" name="user_security[password_policy][require_lowercase]" value="1" <?php checked( ! empty( $pw_policy['require_lowercase'] ) ); ?>>
5229 <?php esc_html_e( 'Require a lowercase letter (a-z)', 'vigilante' ); ?>
5230 </label>
5231 <label style="display:block;margin-bottom:5px;">
5232 <input type="checkbox" name="user_security[password_policy][require_number]" value="1" <?php checked( ! empty( $pw_policy['require_number'] ) ); ?>>
5233 <?php esc_html_e( 'Require a number (0-9)', 'vigilante' ); ?>
5234 </label>
5235 <label style="display:block;margin-bottom:5px;">
5236 <input type="checkbox" name="user_security[password_policy][require_special]" value="1" <?php checked( ! empty( $pw_policy['require_special'] ) ); ?>>
5237 <?php esc_html_e( 'Require a special character (!, @, #, ...)', 'vigilante' ); ?>
5238 </label>
5239 <label style="display:block;margin-bottom:5px;">
5240 <input type="checkbox" name="user_security[password_policy][block_common]" value="1" <?php checked( ! empty( $pw_policy['block_common'] ) ); ?>>
5241 <?php esc_html_e( 'Reject well-known common passwords', 'vigilante' ); ?>
5242 </label>
5243 <label style="display:block;margin-bottom:5px;">
5244 <input type="checkbox" name="user_security[password_policy][block_username]" value="1" <?php checked( ! empty( $pw_policy['block_username'] ) ); ?>>
5245 <?php esc_html_e( 'Do not allow the username inside the password', 'vigilante' ); ?>
5246 </label>
5247 <p class="description"><?php esc_html_e( 'These rules apply only while "Enforce Strong Passwords" is on. Turn off individual rules to allow, for example, long passphrases without numbers or symbols.', 'vigilante' ); ?></p>
5248 </td>
5249 </tr>
5250 <tr>
5251 <th scope="row"><?php esc_html_e( 'Apply Password Rules To', 'vigilante' ); ?></th>
5252 <td>
5253 <?php $this->list_sentinel( 'user_security[password_policy][affected_roles]' ); ?>
5254 <?php foreach ( wp_roles()->get_names() as $role_slug => $role_name ) : ?>
5255 <label style="display:block;margin-bottom:5px;">
5256 <input type="checkbox" name="user_security[password_policy][affected_roles][]" value="<?php echo esc_attr( $role_slug ); ?>" <?php checked( empty( $pw_policy_roles ) || in_array( $role_slug, $pw_policy_roles, true ) ); ?>>
5257 <?php echo esc_html( translate_user_role( $role_name ) ); ?>
5258 </label>
5259 <?php endforeach; ?>
5260 <p class="description"><?php esc_html_e( 'All roles are covered by default. Uncheck a role to exclude it from the password policy.', 'vigilante' ); ?></p>
5261 </td>
5262 </tr>
5263 <tr id="field-block-author-scanning">
5264 <th scope="row"><?php esc_html_e( 'Block Author Scanning', 'vigilante' ); ?></th>
5265 <td>
5266 <label>
5267 <input type="checkbox" name="user_security[block_author_scanning]" value="1" <?php checked( ! empty( $options['block_author_scanning'] ) ); ?>>
5268 <?php esc_html_e( 'Prevent username discovery via ?author=N URLs', 'vigilante' ); ?>
5269 </label>
5270 </td>
5271 </tr>
5272 <tr>
5273 <th scope="row"><?php esc_html_e( 'Display Name Protection', 'vigilante' ); ?></th>
5274 <td>
5275 <label>
5276 <input type="checkbox" name="user_security[prevent_display_name_login_match]" value="1" <?php checked( ! empty( $options['prevent_display_name_login_match'] ) ); ?>>
5277 <?php esc_html_e( 'Prevent users from saving a display name that matches their login username', 'vigilante' ); ?>
5278 </label>
5279 <p class="description"><?php esc_html_e( 'The display name is publicly visible and should not reveal the login username.', 'vigilante' ); ?></p>
5280 </td>
5281 </tr>
5282 </table>
5283 </div>
5284
5285 <!-- Admin Monitoring -->
5286 <div id="vigilante-section-users-admin-monitoring" class="vigilante-settings-section">
5287 <h2>
5288 <?php esc_html_e( 'Admin monitoring', 'vigilante' ); ?>
5289 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
5290 </h2>
5291 <p><?php esc_html_e( 'Receive email alerts when administrator accounts are modified. All events are always logged to the Security Audit.', 'vigilante' ); ?></p>
5292
5293 <table class="form-table">
5294 <tr>
5295 <th scope="row"><?php esc_html_e( 'New Administrator Alert', 'vigilante' ); ?></th>
5296 <td>
5297 <label>
5298 <input type="checkbox" name="user_security[admin_monitoring][alert_new_admin]" value="1" <?php checked( ! empty( $monitoring['alert_new_admin'] ) ); ?>>
5299 <?php esc_html_e( 'Send email alert when a new administrator account is created', 'vigilante' ); ?>
5300 </label>
5301 </td>
5302 </tr>
5303 <tr>
5304 <th scope="row"><?php esc_html_e( 'Admin Email Change Alert', 'vigilante' ); ?></th>
5305 <td>
5306 <label>
5307 <input type="checkbox" name="user_security[admin_monitoring][alert_admin_email_change]" value="1" <?php checked( ! empty( $monitoring['alert_admin_email_change'] ) ); ?>>
5308 <?php esc_html_e( 'Send email alert when an administrator email address is changed', 'vigilante' ); ?>
5309 </label>
5310 </td>
5311 </tr>
5312 <tr>
5313 <th scope="row"><?php esc_html_e( 'Permission Elevation Alert', 'vigilante' ); ?></th>
5314 <td>
5315 <label>
5316 <input type="checkbox" name="user_security[admin_monitoring][alert_permission_elevation]" value="1" <?php checked( ! empty( $monitoring['alert_permission_elevation'] ) ); ?>>
5317 <?php esc_html_e( 'Send email alert when a user is elevated to administrator role', 'vigilante' ); ?>
5318 </label>
5319 </td>
5320 </tr>
5321 <tr>
5322 <th scope="row"><?php esc_html_e( 'Admin Password Change Alert', 'vigilante' ); ?></th>
5323 <td>
5324 <label>
5325 <input type="checkbox" name="user_security[admin_monitoring][alert_admin_password_change]" value="1" <?php checked( ! empty( $monitoring['alert_admin_password_change'] ) ); ?>>
5326 <?php esc_html_e( 'Send email alert when an administrator password is changed', 'vigilante' ); ?>
5327 </label>
5328 </td>
5329 </tr>
5330 </table>
5331
5332 <p class="description">
5333 <?php
5334 printf(
5335 /* translators: %s: Link to notification settings */
5336 esc_html__( '&#9432; Notifications are sent to the recipients configured in %s.', 'vigilante' ),
5337 '<a href="' . esc_url( admin_url( 'admin.php?page=vigilante&tab=tools' ) ) . '">' . esc_html__( 'Settings & Tools', 'vigilante' ) . '</a>'
5338 );
5339 ?>
5340 </p>
5341 </div>
5342
5343 <!-- Registration Approval -->
5344 <div id="vigilante-section-users-registration" class="vigilante-settings-section">
5345 <h2>
5346 <?php esc_html_e( 'Registration approval', 'vigilante' ); ?>
5347 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
5348 </h2>
5349 <p><?php esc_html_e( 'Require manual approval for new user registrations.', 'vigilante' ); ?></p>
5350
5351 <table class="form-table">
5352 <tr>
5353 <th scope="row"><?php esc_html_e( 'Enable Registration Approval', 'vigilante' ); ?></th>
5354 <td>
5355 <label>
5356 <input type="checkbox" name="user_security[registration_approval][enabled]" value="1" <?php checked( ! empty( $registration['enabled'] ) ); ?>>
5357 <?php esc_html_e( 'New users must be approved by an administrator before they can log in', 'vigilante' ); ?>
5358 </label>
5359 </td>
5360 </tr>
5361 <tr>
5362 <th scope="row"><?php esc_html_e( 'Notify Admin', 'vigilante' ); ?></th>
5363 <td>
5364 <label>
5365 <input type="checkbox" name="user_security[registration_approval][notify_admin]" value="1" <?php checked( ! empty( $registration['notify_admin'] ) ); ?>>
5366 <?php esc_html_e( 'Send email notification when a new user registers', 'vigilante' ); ?>
5367 </label>
5368 <p class="description"><?php esc_html_e( 'Disable on high-traffic sites to avoid email overload.', 'vigilante' ); ?></p>
5369 </td>
5370 </tr>
5371 <tr>
5372 <th scope="row"><label for="vigilante-f-user-security-registration-approval-auto-reject-days"><?php esc_html_e( 'Auto-reject After', 'vigilante' ); ?></label></th>
5373 <td>
5374 <input id="vigilante-f-user-security-registration-approval-auto-reject-days" type="number" name="user_security[registration_approval][auto_reject_days]" value="<?php echo esc_attr( $registration['auto_reject_days'] ?? 0 ); ?>" min="0" max="365" class="small-text">
5375 <?php esc_html_e( 'days (0 = never)', 'vigilante' ); ?>
5376 <p class="description"><?php esc_html_e( 'Automatically reject pending registrations after this many days.', 'vigilante' ); ?></p>
5377 </td>
5378 </tr>
5379 </table>
5380 </div>
5381
5382 <!-- Session Limits -->
5383 <div id="vigilante-section-users-sessions" class="vigilante-settings-section">
5384 <h2>
5385 <?php esc_html_e( 'Session limits', 'vigilante' ); ?>
5386 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
5387 </h2>
5388 <p><?php esc_html_e( 'Limit the number of simultaneous sessions per user.', 'vigilante' ); ?></p>
5389
5390 <?php if ( Vigilante_User_Security::session_limit_is_network_wide() ) : ?>
5391 <div class="notice notice-warning inline">
5392 <p>
5393 <?php esc_html_e( 'This limit does not apply on a network. WordPress keeps the sessions of an account for the whole network, not per site, so a limit set here would count and close the sessions that person opened on other sites, including an administrator session elsewhere. A network-wide session policy is planned; until then these settings are saved but not enforced.', 'vigilante' ); ?>
5394 </p>
5395 </div>
5396 <?php endif; ?>
5397
5398 <table class="form-table">
5399 <tr>
5400 <th scope="row"><?php esc_html_e( 'Enable Session Limits', 'vigilante' ); ?></th>
5401 <td>
5402 <label>
5403 <input type="checkbox" name="user_security[session_limits][enabled]" value="1" <?php checked( ! empty( $session_limits['enabled'] ) ); ?>>
5404 <?php esc_html_e( 'Limit the number of active sessions per user', 'vigilante' ); ?>
5405 </label>
5406 </td>
5407 </tr>
5408 <tr>
5409 <th scope="row"><label for="vigilante-f-user-security-session-limits-max-sessions"><?php esc_html_e( 'Maximum Sessions', 'vigilante' ); ?></label></th>
5410 <td>
5411 <input id="vigilante-f-user-security-session-limits-max-sessions" type="number" name="user_security[session_limits][max_sessions]" value="<?php echo esc_attr( $session_limits['max_sessions'] ?? 3 ); ?>" min="1" max="10" class="small-text">
5412 <?php esc_html_e( 'sessions per user', 'vigilante' ); ?>
5413 </td>
5414 </tr>
5415 <tr>
5416 <th scope="row"><label for="vigilante-f-user-security-session-limits-behavior"><?php esc_html_e( 'When Limit Exceeded', 'vigilante' ); ?></label></th>
5417 <td>
5418 <select id="vigilante-f-user-security-session-limits-behavior" name="user_security[session_limits][behavior]">
5419 <option value="block_new" <?php selected( ( $session_limits['behavior'] ?? 'close_oldest' ), 'block_new' ); ?>><?php esc_html_e( 'Block new login', 'vigilante' ); ?></option>
5420 <option value="close_oldest" <?php selected( ( $session_limits['behavior'] ?? 'close_oldest' ), 'close_oldest' ); ?>><?php esc_html_e( 'Close oldest session', 'vigilante' ); ?></option>
5421 </select>
5422 <p class="description"><?php esc_html_e( '"Close oldest" is recommended for security - ensures attackers cannot lock out legitimate users.', 'vigilante' ); ?></p>
5423 </td>
5424 </tr>
5425 <tr>
5426 <th scope="row"><?php esc_html_e( 'Exclude Administrators', 'vigilante' ); ?></th>
5427 <td>
5428 <label>
5429 <input type="checkbox" name="user_security[session_limits][exclude_admins]" value="1" <?php checked( ! empty( $session_limits['exclude_admins'] ) ); ?>>
5430 <?php esc_html_e( 'Do not apply session limits to administrators', 'vigilante' ); ?>
5431 </label>
5432 </td>
5433 </tr>
5434 </table>
5435 </div>
5436
5437 <!-- Password Expiration -->
5438 <div id="vigilante-section-users-password-exp" class="vigilante-settings-section">
5439 <h2>
5440 <?php esc_html_e( 'Password expiration', 'vigilante' ); ?>
5441 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
5442 </h2>
5443 <p><?php esc_html_e( 'Force users to change their password periodically.', 'vigilante' ); ?></p>
5444
5445 <table class="form-table">
5446 <tr>
5447 <th scope="row"><?php esc_html_e( 'Enable Password Expiration', 'vigilante' ); ?></th>
5448 <td>
5449 <label>
5450 <input type="checkbox" name="user_security[password_expiration][enabled]" value="1" <?php checked( ! empty( $password_exp['enabled'] ) ); ?>>
5451 <?php esc_html_e( 'Force password change after a set number of days', 'vigilante' ); ?>
5452 </label>
5453 </td>
5454 </tr>
5455 <tr>
5456 <th scope="row"><label for="vigilante-f-user-security-password-expiration-expire-days"><?php esc_html_e( 'Expire After', 'vigilante' ); ?></label></th>
5457 <td>
5458 <input id="vigilante-f-user-security-password-expiration-expire-days" type="number" name="user_security[password_expiration][expire_days]" value="<?php echo esc_attr( $password_exp['expire_days'] ?? 90 ); ?>" min="7" max="365" class="small-text">
5459 <?php esc_html_e( 'days', 'vigilante' ); ?>
5460 <p class="description"><?php esc_html_e( 'PCI-DSS recommends 90 days.', 'vigilante' ); ?></p>
5461 </td>
5462 </tr>
5463 <tr>
5464 <th scope="row"><label for="vigilante-f-user-security-password-expiration-warning-days"><?php esc_html_e( 'Warning Period', 'vigilante' ); ?></label></th>
5465 <td>
5466 <input id="vigilante-f-user-security-password-expiration-warning-days" type="number" name="user_security[password_expiration][warning_days]" value="<?php echo esc_attr( $password_exp['warning_days'] ?? 14 ); ?>" min="1" max="30" class="small-text">
5467 <?php esc_html_e( 'days before expiration', 'vigilante' ); ?>
5468 <p class="description"><?php esc_html_e( 'Show warning notice this many days before password expires.', 'vigilante' ); ?></p>
5469 </td>
5470 </tr>
5471 <tr>
5472 <th scope="row"><label for="vigilante-f-user-security-password-expiration-password-history"><?php esc_html_e( 'Password History', 'vigilante' ); ?></label></th>
5473 <td>
5474 <input id="vigilante-f-user-security-password-expiration-password-history" type="number" name="user_security[password_expiration][password_history]" value="<?php echo esc_attr( $password_exp['password_history'] ?? 3 ); ?>" min="0" max="24" class="small-text">
5475 <?php esc_html_e( 'passwords to remember', 'vigilante' ); ?>
5476 <p class="description"><?php esc_html_e( 'Prevent reusing recent passwords. Set to 0 to disable.', 'vigilante' ); ?></p>
5477 </td>
5478 </tr>
5479 <tr>
5480 <th scope="row"><?php esc_html_e( 'Email Reminder', 'vigilante' ); ?></th>
5481 <td>
5482 <label>
5483 <input type="checkbox" name="user_security[password_expiration][send_reminder]" value="1" <?php checked( ! empty( $password_exp['send_reminder'] ) ); ?>>
5484 <?php esc_html_e( 'Send email reminder when password is about to expire', 'vigilante' ); ?>
5485 </label>
5486 <p class="description"><?php esc_html_e( 'The reminder is sent once when the warning period starts, using the same number of days configured above.', 'vigilante' ); ?></p>
5487 </td>
5488 </tr>
5489 <tr>
5490 <th scope="row"><?php esc_html_e( 'Affected Roles', 'vigilante' ); ?></th>
5491 <td>
5492 <?php
5493 $affected_roles = $password_exp['affected_roles'] ?? array( 'administrator', 'editor' );
5494 $all_roles = wp_roles()->get_names();
5495 $this->list_sentinel( 'user_security[password_expiration][affected_roles]' );
5496 foreach ( $all_roles as $role_slug => $role_name ) :
5497 ?>
5498 <label style="display: block; margin-bottom: 5px;">
5499 <input type="checkbox" name="user_security[password_expiration][affected_roles][]" value="<?php echo esc_attr( $role_slug ); ?>" <?php checked( in_array( $role_slug, $affected_roles, true ) ); ?>>
5500 <?php echo esc_html( translate_user_role( $role_name ) ); ?>
5501 </label>
5502 <?php endforeach; ?>
5503 </td>
5504 </tr>
5505 <tr>
5506 <th scope="row"><?php esc_html_e( 'Exclude specific users', 'vigilante' ); ?></th>
5507 <td>
5508 <?php $pwexp_excluded = $password_exp['excluded_users'] ?? array(); ?>
5509 <div class="vigilante-2fa-user-search-container">
5510 <div class="vigilante-2fa-user-search">
5511 <span class="search-icon"></span>
5512 <input type="text"
5513 id="vigilante_pwexp_user_search"
5514 placeholder="<?php esc_attr_e( 'Search users by name or email...', 'vigilante' ); ?>"
5515 autocomplete="off">
5516 <div class="vigilante-pwexp-search-results"></div>
5517 </div>
5518 <div class="vigilante-pwexp-excluded-users">
5519 <?php $this->list_sentinel( 'user_security[password_expiration][excluded_users]' ); ?>
5520 <?php
5521 foreach ( $pwexp_excluded as $pwexp_excluded_id ) :
5522 $excluded_user = get_user_by( 'ID', $pwexp_excluded_id );
5523 if ( ! $excluded_user ) {
5524 continue;
5525 }
5526 ?>
5527 <div class="vigilante-pwexp-excluded-user" data-user-id="<?php echo esc_attr( $pwexp_excluded_id ); ?>">
5528 <span class="user-display"><?php echo esc_html( $excluded_user->display_name . ' (' . $excluded_user->user_email . ')' ); ?></span>
5529 <button type="button" class="remove-user" aria-label="<?php esc_attr_e( 'Remove', 'vigilante' ); ?>">&times;</button>
5530 <input type="hidden" name="user_security[password_expiration][excluded_users][]" value="<?php echo esc_attr( $pwexp_excluded_id ); ?>">
5531 </div>
5532 <?php endforeach; ?>
5533 </div>
5534 </div>
5535 <p class="description"><?php esc_html_e( 'Listed users will be excluded from password expiration regardless of their role.', 'vigilante' ); ?></p>
5536 </td>
5537 </tr>
5538 </table>
5539 </div>
5540
5541 <!-- Email Verification -->
5542 <div id="vigilante-section-users-email-verify" class="vigilante-settings-section">
5543 <h2>
5544 <?php esc_html_e( 'Email verification', 'vigilante' ); ?>
5545 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
5546 </h2>
5547 <p><?php esc_html_e( 'Require new users to verify their email address before logging in.', 'vigilante' ); ?></p>
5548
5549 <table class="form-table">
5550 <tr>
5551 <th scope="row"><?php esc_html_e( 'Enable Email Verification', 'vigilante' ); ?></th>
5552 <td>
5553 <label>
5554 <input type="checkbox" name="user_security[email_verification][enabled]" value="1" <?php checked( ! empty( $email_verify['enabled'] ) ); ?>>
5555 <?php esc_html_e( 'New users must verify their email before logging in', 'vigilante' ); ?>
5556 </label>
5557 </td>
5558 </tr>
5559 <tr>
5560 <th scope="row"><label for="vigilante-f-user-security-email-verification-token-expiry-hours"><?php esc_html_e( 'Link Expiration', 'vigilante' ); ?></label></th>
5561 <td>
5562 <input id="vigilante-f-user-security-email-verification-token-expiry-hours" type="number" name="user_security[email_verification][token_expiry_hours]" value="<?php echo esc_attr( $email_verify['token_expiry_hours'] ?? 24 ); ?>" min="1" max="168" class="small-text">
5563 <?php esc_html_e( 'hours', 'vigilante' ); ?>
5564 </td>
5565 </tr>
5566 <tr>
5567 <th scope="row"><?php esc_html_e( 'Allow Resend', 'vigilante' ); ?></th>
5568 <td>
5569 <label>
5570 <input type="checkbox" name="user_security[email_verification][allow_resend]" value="1" <?php checked( ! empty( $email_verify['allow_resend'] ) ); ?>>
5571 <?php esc_html_e( 'Allow users to request a new verification email', 'vigilante' ); ?>
5572 </label>
5573 </td>
5574 </tr>
5575 <tr>
5576 <th scope="row"><label for="vigilante-f-user-security-email-verification-auto-delete-days"><?php esc_html_e( 'Auto-delete Unverified', 'vigilante' ); ?></label></th>
5577 <td>
5578 <input id="vigilante-f-user-security-email-verification-auto-delete-days" type="number" name="user_security[email_verification][auto_delete_days]" value="<?php echo esc_attr( $email_verify['auto_delete_days'] ?? 7 ); ?>" min="0" max="365" class="small-text">
5579 <?php esc_html_e( 'days (0 = never)', 'vigilante' ); ?>
5580 <p class="description"><?php esc_html_e( 'Automatically delete users who never verify their email.', 'vigilante' ); ?></p>
5581 </td>
5582 </tr>
5583 </table>
5584 </div>
5585
5586 <p class="submit vigilante-submit-buttons">
5587 <button type="submit" class="button button-primary vigilante-save-btn" data-original-text="<?php esc_attr_e( 'Save Settings', 'vigilante' ); ?>">
5588 <?php esc_html_e( 'Save Settings', 'vigilante' ); ?>
5589 </button>
5590 <button type="button" class="button vigilante-reset-section-btn" data-original-text="<?php esc_attr_e( 'Reset to Defaults', 'vigilante' ); ?>">
5591 <?php esc_html_e( 'Reset to Defaults', 'vigilante' ); ?>
5592 </button>
5593 </p>
5594 </form>
5595
5596 <!-- ============================================================
5597 TOOLS SECTION - Actions and utilities (no save button)
5598 ============================================================ -->
5599 <div class="vigilante-tools-section">
5600 <h2 class="vigilante-tools-header">
5601 <?php esc_html_e( 'User security tools', 'vigilante' ); ?>
5602 </h2>
5603
5604 <?php $this->render_user_actions_notice(); ?>
5605 <?php if ( ! $this->user_actions_locked() ) : ?>
5606
5607 <!-- Force Password Reset -->
5608 <div class="vigilante-tool-box">
5609 <h3><?php esc_html_e( 'Force password reset', 'vigilante' ); ?></h3>
5610 <p class="description"><?php esc_html_e( 'Force users to reset their password. Useful after a security incident. Users will receive an email with a reset link.', 'vigilante' ); ?></p>
5611
5612 <!-- Reset Specific Users -->
5613 <div class="vigilante-password-reset-box">
5614 <h4><?php esc_html_e( 'Reset specific users', 'vigilante' ); ?></h4>
5615
5616 <div class="vigilante-user-search-wrapper">
5617 <input type="text" id="vigilante-password-reset-search" class="regular-text" placeholder="<?php esc_attr_e( 'Search by username, email, or display name...', 'vigilante' ); ?>">
5618 <div id="vigilante-password-reset-results" class="vigilante-user-search-results" style="display: none;"></div>
5619 </div>
5620
5621 <div id="vigilante-password-reset-selected" class="vigilante-selected-users" style="display: none;">
5622 <strong><?php esc_html_e( 'Selected Users:', 'vigilante' ); ?></strong>
5623 <ul class="vigilante-selected-users-list"></ul>
5624 </div>
5625
5626 <p class="description" style="margin-top: 15px;">
5627 <span class="dashicons dashicons-email-alt" style="color: #2271b1;"></span>
5628 <?php esc_html_e( 'Selected users will receive an email with a password reset link.', 'vigilante' ); ?>
5629 </p>
5630
5631 <p class="submit">
5632 <button type="button" id="vigilante-reset-selected-users" class="button button-primary" disabled>
5633 <?php esc_html_e( 'Force Reset for Selected Users', 'vigilante' ); ?>
5634 </button>
5635 </p>
5636 </div>
5637
5638 <!-- Reset by Role -->
5639 <div class="vigilante-password-reset-box" style="margin-top: 20px; padding-top: 20px; border-top: 1px solid #ddd;">
5640 <h4><?php esc_html_e( 'Reset by role', 'vigilante' ); ?></h4>
5641 <p class="description"><?php esc_html_e( 'Select one or more roles to force a password reset for all users with those roles. Ideal for security incidents where you need to reset access quickly.', 'vigilante' ); ?></p>
5642
5643 <?php
5644 $wp_roles = wp_roles();
5645 $user_counts = count_users();
5646 $avail_roles = $user_counts['avail_roles'] ?? array();
5647 $current_user = wp_get_current_user();
5648 $current_roles = $current_user->roles;
5649 ?>
5650
5651 <fieldset class="vigilante-role-checkboxes" style="margin-top: 10px;">
5652 <?php foreach ( $wp_roles->roles as $role_slug => $role_data ) :
5653 $count = $avail_roles[ $role_slug ] ?? 0;
5654 if ( 0 === $count ) {
5655 continue;
5656 }
5657 $role_name = translate_user_role( $role_data['name'] );
5658 ?>
5659 <label style="display: block; margin-bottom: 6px;">
5660 <input type="checkbox"
5661 class="vigilante-reset-role-checkbox"
5662 value="<?php echo esc_attr( $role_slug ); ?>"
5663 data-count="<?php echo absint( $count ); ?>">
5664 <?php
5665 printf(
5666 /* translators: 1: Role name, 2: Number of users */
5667 '%1$s <span class="description">(%2$d)</span>',
5668 esc_html( $role_name ),
5669 absint( $count )
5670 );
5671 ?>
5672 <?php if ( in_array( $role_slug, $current_roles, true ) ) : ?>
5673 <em class="description"><?php esc_html_e( '(includes you)', 'vigilante' ); ?></em>
5674 <?php endif; ?>
5675 </label>
5676 <?php endforeach; ?>
5677 </fieldset>
5678
5679 <div id="vigilante-reset-role-summary" style="display: none; margin-top: 10px;">
5680 <p>
5681 <span class="dashicons dashicons-groups" style="color: #2271b1;"></span>
5682 <strong id="vigilante-reset-role-count">0</strong>
5683 <?php esc_html_e( 'user(s) will be affected.', 'vigilante' ); ?>
5684 </p>
5685 </div>
5686
5687 <div class="vigilante-password-reset-options" id="vigilante-reset-role-self-option" style="display: none; margin-top: 10px;">
5688 <label>
5689 <input type="checkbox" id="vigilante-reset-role-include-self" value="1">
5690 <?php esc_html_e( 'Include myself (your current session will end)', 'vigilante' ); ?>
5691 </label>
5692 </div>
5693
5694 <p class="submit">
5695 <button type="button" id="vigilante-reset-by-role" class="button button-primary" disabled>
5696 <?php esc_html_e( 'Force Reset for Selected Roles', 'vigilante' ); ?>
5697 </button>
5698 </p>
5699 </div>
5700
5701 <!-- Reset All Users -->
5702 <div class="vigilante-password-reset-box" style="margin-top: 20px; padding-top: 20px; border-top: 1px solid #ddd;">
5703 <h4><?php esc_html_e( 'Reset all users', 'vigilante' ); ?></h4>
5704
5705 <?php
5706 $total_users = count_users();
5707 $total_count = $total_users['total_users'];
5708 ?>
5709 <p>
5710 <?php
5711 printf(
5712 /* translators: %d: Number of users */
5713 esc_html__( 'This will affect %d user(s).', 'vigilante' ),
5714 absint( $total_count )
5715 );
5716 ?>
5717 </p>
5718
5719 <p class="description" style="color: #d63638;">
5720 <span class="dashicons dashicons-warning"></span>
5721 <?php esc_html_e( 'Warning: All users will receive a password reset email. On sites with many users, this could overwhelm your mail server.', 'vigilante' ); ?>
5722 </p>
5723
5724 <div class="vigilante-password-reset-options" style="margin-top: 10px;">
5725 <label>
5726 <input type="checkbox" id="vigilante-reset-all-include-self" value="1">
5727 <?php esc_html_e( 'Include myself (your current session will end)', 'vigilante' ); ?>
5728 </label>
5729 </div>
5730
5731 <p class="submit">
5732 <button type="button" id="vigilante-reset-all-users" class="button" style="color: #d63638; border-color: #d63638;">
5733 <?php esc_html_e( 'Force Reset for ALL Users', 'vigilante' ); ?>
5734 </button>
5735 </p>
5736 </div>
5737 </div>
5738
5739 <!-- Pending Registrations -->
5740 <?php
5741 // Enforcement-only: this instance exists to read the queue, and the
5742 // flag keeps it from registering the module's own hooks a second
5743 // time. It is not inert, and saying it was would be a false comment:
5744 // init_enforcement_hooks() does add its three filters again, on top
5745 // of the ones already registered. They are idempotent (the same
5746 // methods of an equivalent instance, deciding on the same user meta),
5747 // so running them twice in an admin request changes nothing, which is
5748 // why this is accepted rather than worked around.
5749 $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log, true );
5750 $pending_users = $user_security->get_pending_users();
5751 ?>
5752 <div id="vigilante-section-users-pending" class="vigilante-tool-box vigilante-pending-users-section">
5753 <h3>
5754 <?php esc_html_e( 'Pending registrations', 'vigilante' ); ?>
5755 <?php if ( count( $pending_users ) > 0 ) : ?>
5756 <span class="vigilante-badge vigilante-badge-warning"><?php echo esc_html( count( $pending_users ) ); ?></span>
5757 <?php endif; ?>
5758 </h3>
5759
5760 <?php
5761 /*
5762 * The queue is shown whenever there is somebody in it, even with
5763 * the feature off. Since 2.11.10 an account already waiting stays
5764 * blocked when the feature is switched off, which is the point:
5765 * turning a setting off must not quietly let in people an
5766 * administrator decided not to approve. But hiding the table then
5767 * left them locked out with no button anywhere to approve or
5768 * reject them. Found by the cross review of 2.11.10.
5769 */
5770 ?>
5771 <?php if ( empty( $registration['enabled'] ) && empty( $pending_users ) ) : ?>
5772 <p class="description">
5773 <span class="dashicons dashicons-info" style="color: #72aee6;"></span>
5774 <?php esc_html_e( 'Registration approval is disabled. Enable it in the settings above to require manual approval for new users.', 'vigilante' ); ?>
5775 </p>
5776 <?php elseif ( empty( $pending_users ) ) : ?>
5777 <div class="vigilante-no-lockouts">
5778 <span class="dashicons dashicons-yes-alt"></span>
5779 <p><?php esc_html_e( 'No pending registrations.', 'vigilante' ); ?></p>
5780 </div>
5781 <?php else : ?>
5782 <?php $this->render_user_actions_notice(); ?>
5783 <table class="wp-list-table widefat fixed striped vigilante-pending-users-table">
5784 <thead>
5785 <tr>
5786 <th><?php esc_html_e( 'User', 'vigilante' ); ?></th>
5787 <th><?php esc_html_e( 'Email', 'vigilante' ); ?></th>
5788 <th><?php esc_html_e( 'Registered', 'vigilante' ); ?></th>
5789 <th><?php esc_html_e( 'Actions', 'vigilante' ); ?></th>
5790 </tr>
5791 </thead>
5792 <tbody>
5793 <?php foreach ( $pending_users as $pending_user ) :
5794 $pending_since = get_user_meta( $pending_user->ID, Vigilante_User_Security::site_user_meta_key( 'vigilante_pending_since' ), true );
5795 ?>
5796 <tr data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>">
5797 <td>
5798 <?php echo get_avatar( $pending_user->ID, 32 ); ?>
5799 <strong><?php echo esc_html( $pending_user->user_login ); ?></strong>
5800 </td>
5801 <td><?php echo esc_html( $pending_user->user_email ); ?></td>
5802 <td>
5803 <?php
5804 if ( $pending_since ) {
5805 /* translators: %s: Time ago */
5806 printf( esc_html__( '%s ago', 'vigilante' ), esc_html( human_time_diff( $pending_since ) ) );
5807 } else {
5808 echo esc_html( $pending_user->user_registered );
5809 }
5810 ?>
5811 </td>
5812 <td>
5813 <button type="button" class="button button-small vigilante-approve-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>" <?php disabled( $this->user_actions_locked() ); ?>>
5814 <?php esc_html_e( 'Approve', 'vigilante' ); ?>
5815 </button>
5816 <button type="button" class="button button-small vigilante-reject-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>" style="color: #d63638;" <?php disabled( $this->user_actions_locked() ); ?>>
5817 <?php esc_html_e( 'Reject', 'vigilante' ); ?>
5818 </button>
5819 </td>
5820 </tr>
5821 <?php endforeach; ?>
5822 </tbody>
5823 </table>
5824 <?php endif; ?>
5825 </div>
5826
5827 <!-- Active Sessions Management -->
5828 <div class="vigilante-tool-box vigilante-session-management-section">
5829 <h3><?php esc_html_e( 'Active sessions', 'vigilante' ); ?></h3>
5830 <p class="description"><?php esc_html_e( 'View and manage active login sessions. You can revoke sessions to force users to log in again.', 'vigilante' ); ?></p>
5831
5832 <!-- Current user sessions -->
5833 <h4><?php esc_html_e( 'Your sessions', 'vigilante' ); ?></h4>
5834 <?php
5835 $current_user_id = get_current_user_id();
5836 $my_sessions = $user_security->get_user_sessions( $current_user_id );
5837 $has_corrupted = $user_security->has_corrupted_sessions( $current_user_id );
5838 $raw_count = $user_security->get_raw_session_count( $current_user_id );
5839 ?>
5840
5841 <?php if ( $has_corrupted && $raw_count > 0 ) : ?>
5842 <div class="notice notice-warning inline" style="margin: 10px 0;">
5843 <p>
5844 <span class="dashicons dashicons-warning" style="color: #dba617;"></span>
5845 <?php esc_html_e( 'Some session data is corrupted and cannot be displayed. Use "Revoke All Other Sessions" to clean up, then log out and log in again to fix this.', 'vigilante' ); ?>
5846 </p>
5847 </div>
5848 <?php endif; ?>
5849
5850 <?php if ( empty( $my_sessions ) ) : ?>
5851 <p class="description"><?php esc_html_e( 'No active sessions found.', 'vigilante' ); ?></p>
5852 <?php if ( $has_corrupted ) : ?>
5853 <p style="margin-top: 10px;">
5854 <button type="button" class="button vigilante-revoke-other-sessions" data-user-id="<?php echo esc_attr( $current_user_id ); ?>">
5855 <?php esc_html_e( 'Clean Up Corrupted Sessions', 'vigilante' ); ?>
5856 </button>
5857 </p>
5858 <?php endif; ?>
5859 <?php else : ?>
5860 <div class="vigilante-paginated-section">
5861 <div class="vigilante-fi-pagination-wrap"></div>
5862 <table class="wp-list-table widefat fixed striped vigilante-sessions-table vigilante-fi-paginated">
5863 <thead>
5864 <tr>
5865 <th><?php esc_html_e( 'Browser', 'vigilante' ); ?></th>
5866 <th><?php esc_html_e( 'IP Address', 'vigilante' ); ?></th>
5867 <th><?php esc_html_e( 'Login Time', 'vigilante' ); ?></th>
5868 <th><?php esc_html_e( 'Actions', 'vigilante' ); ?></th>
5869 </tr>
5870 </thead>
5871 <tbody>
5872 <?php foreach ( $my_sessions as $session ) : ?>
5873 <tr data-token="<?php echo esc_attr( $session['token_hash'] ); ?>">
5874 <td><?php echo esc_html( $session['browser'] ); ?></td>
5875 <td><code><?php echo esc_html( $session['ip'] ); ?></code></td>
5876 <td>
5877 <?php
5878 if ( $session['login'] ) {
5879 /* translators: %s: Time ago */
5880 printf( esc_html__( '%s ago', 'vigilante' ), esc_html( human_time_diff( $session['login'] ) ) );
5881 } else {
5882 esc_html_e( 'Unknown', 'vigilante' );
5883 }
5884 ?>
5885 </td>
5886 <td>
5887 <?php if ( ! $session['is_current'] ) : ?>
5888 <button type="button" class="button button-small vigilante-revoke-session" data-user-id="<?php echo esc_attr( $current_user_id ); ?>" data-token="<?php echo esc_attr( $session['token_hash'] ); ?>">
5889 <?php esc_html_e( 'Revoke', 'vigilante' ); ?>
5890 </button>
5891 <?php else : ?>
5892 <span class="description"><?php esc_html_e( 'Current session', 'vigilante' ); ?></span>
5893 <?php endif; ?>
5894 </td>
5895 </tr>
5896 <?php endforeach; ?>
5897 </tbody>
5898 </table>
5899 </div>
5900
5901 <?php if ( count( $my_sessions ) > 1 || $has_corrupted ) : ?>
5902 <p style="margin-top: 10px;">
5903 <button type="button" class="button vigilante-revoke-other-sessions" data-user-id="<?php echo esc_attr( $current_user_id ); ?>">
5904 <?php esc_html_e( 'Revoke All Other Sessions', 'vigilante' ); ?>
5905 </button>
5906 </p>
5907 <?php endif; ?>
5908 <?php endif; ?>
5909
5910 <!-- Search user sessions (admin only) -->
5911 <h4 style="margin-top: 30px;"><?php esc_html_e( 'Manage user sessions', 'vigilante' ); ?></h4>
5912 <p class="description"><?php esc_html_e( 'Search for a user to view and manage their sessions.', 'vigilante' ); ?></p>
5913
5914 <div class="vigilante-user-search-wrapper" style="margin-top: 10px;">
5915 <input type="text" id="vigilante-session-user-search" class="regular-text" placeholder="<?php esc_attr_e( 'Search by username or email...', 'vigilante' ); ?>">
5916 <div id="vigilante-session-search-results" class="vigilante-user-search-results" style="display: none;"></div>
5917 </div>
5918
5919 <div id="vigilante-user-sessions-container" style="display: none; margin-top: 20px;">
5920 <h4 id="vigilante-sessions-user-name"></h4>
5921 <table class="wp-list-table widefat fixed striped vigilante-sessions-table">
5922 <thead>
5923 <tr>
5924 <th><?php esc_html_e( 'Browser', 'vigilante' ); ?></th>
5925 <th><?php esc_html_e( 'IP Address', 'vigilante' ); ?></th>
5926 <th><?php esc_html_e( 'Login Time', 'vigilante' ); ?></th>
5927 <th><?php esc_html_e( 'Actions', 'vigilante' ); ?></th>
5928 </tr>
5929 </thead>
5930 <tbody id="vigilante-user-sessions-list">
5931 </tbody>
5932 </table>
5933 <p style="margin-top: 10px;">
5934 <button type="button" class="button vigilante-revoke-all-user-sessions" style="color: #d63638;">
5935 <?php esc_html_e( 'Revoke All Sessions', 'vigilante' ); ?>
5936 </button>
5937 </p>
5938 </div>
5939 </div>
5940
5941 <?php endif; ?>
5942 </div>
5943 <?php
5944 }
5945
5946 /**
5947 * Render WordPress Hardening tab
5948 */
5949 private function render_tab_wp_hardening() {
5950 $is_disabled = $this->render_module_disabled_notice( 'wp_hardening' );
5951 $options = $this->settings->get_section( 'wp_hardening' );
5952 ?>
5953 <form class="vigilante-settings-form <?php echo $is_disabled ? 'vigilante-form-disabled' : ''; ?>" data-section="wp_hardening" <?php echo $is_disabled ? 'inert' : ''; ?>>
5954 <!-- Database Hardening (outside form save flow - uses its own AJAX action) -->
5955 <?php $vg_shared_locked = $this->shared_files_locked(); ?>
5956 <?php $this->render_shared_files_notice(); ?>
5957 <div id="vigilante-section-hardening-database" class="vigilante-settings-section <?php echo $vg_shared_locked ? 'vigilante-form-disabled' : ''; ?>" <?php echo $vg_shared_locked ? 'inert' : ''; ?>>
5958 <h2>
5959 <?php esc_html_e( 'Database Hardening', 'vigilante' ); ?>
5960 <span class="vigilante-method-badge database"><?php esc_html_e( 'Database', 'vigilante' ); ?></span>
5961 <span class="vigilante-method-badge config"><?php esc_html_e( 'WP-CONFIG', 'vigilante' ); ?></span>
5962 </h2>
5963 <p><?php esc_html_e( 'Change the database table prefix to prevent SQL injection attacks that target default WordPress tables.', 'vigilante' ); ?></p>
5964
5965 <?php
5966 $db_prefix = new Vigilante_Database_Prefix();
5967 $current_prefix = $db_prefix->get_current_prefix();
5968 $is_default = $db_prefix->is_default_prefix();
5969 ?>
5970
5971 <?php if ( is_multisite() && ! $vg_shared_locked ) : ?>
5972 <div class="notice notice-warning inline" style="margin:10px 0 16px;padding:8px 12px;">
5973 <p style="margin:0;"><?php esc_html_e( 'Network-wide operation: it renames the tables of every site in the network and rewrites the wp-config.php they all share. Back up the whole database first, not just the main site.', 'vigilante' ); ?></p>
5974 </div>
5975 <?php endif; ?>
5976
5977 <table class="form-table">
5978 <tr>
5979 <th scope="row"><?php esc_html_e( 'Current prefix', 'vigilante' ); ?></th>
5980 <td>
5981 <code class="vigilante-db-current-prefix"><?php echo esc_html( $current_prefix ); ?></code>
5982 <?php if ( $is_default ) : ?>
5983 <span class="vigilante-inline-warning">
5984 <span class="dashicons dashicons-warning"></span>
5985 <?php esc_html_e( 'Default prefix detected. Changing it adds a layer of protection against automated SQL injection attacks.', 'vigilante' ); ?>
5986 </span>
5987 <?php else : ?>
5988 <span class="vigilante-inline-ok">
5989 <span class="dashicons dashicons-yes-alt"></span>
5990 <?php esc_html_e( 'Custom prefix in use.', 'vigilante' ); ?>
5991 </span>
5992 <?php endif; ?>
5993 </td>
5994 </tr>
5995 <tr>
5996 <th scope="row"><?php esc_html_e( 'New prefix', 'vigilante' ); ?></th>
5997 <td>
5998 <div class="vigilante-db-prefix-row">
5999 <code class="vigilante-db-new-prefix" id="vigilante-new-prefix"><?php echo esc_html( $db_prefix->generate_prefix() ); ?></code>
6000 <button type="button" class="button button-small vigilante-db-regenerate-prefix" title="<?php esc_attr_e( 'Generate new prefix', 'vigilante' ); ?>">
6001 <span class="dashicons dashicons-update"></span>
6002 </button>
6003 </div>
6004 </td>
6005 </tr>
6006 <tr>
6007 <th scope="row"></th>
6008 <td>
6009 <div class="vigilante-db-prefix-confirm">
6010 <label>
6011 <input type="checkbox" id="vigilante-prefix-backup-confirm">
6012 <?php esc_html_e( 'I understand this operation is irreversible and I have a current database backup', 'vigilante' ); ?>
6013 </label>
6014 <p class="description">
6015 <?php
6016 printf(
6017 /* translators: %s: Link to tools tab */
6018 esc_html__( 'Need a backup? %s first.', 'vigilante' ),
6019 '<a href="' . esc_url( admin_url( 'admin.php?page=vigilante&tab=tools' ) ) . '">' . esc_html__( 'Download a database backup', 'vigilante' ) . '</a>'
6020 );
6021 ?>
6022 </p>
6023 </div>
6024 <button type="button" class="button button-primary vigilante-db-change-prefix" disabled data-original-text="<?php esc_attr_e( 'Change Database Prefix', 'vigilante' ); ?>">
6025 <?php esc_html_e( 'Change Database Prefix', 'vigilante' ); ?>
6026 </button>
6027 </td>
6028 </tr>
6029 </table>
6030 </div>
6031
6032 <!-- wp-config Security -->
6033 <?php
6034 $vg_shared_locked = $this->shared_files_locked();
6035 // Paint what is actually in force, not this site's unused copy.
6036 $vg_local_options = $options;
6037 $options = $this->get_section_for_display( 'wp_hardening' );
6038 ?>
6039 <?php $this->render_shared_files_notice(); ?>
6040 <div id="vigilante-section-hardening-wpconfig" class="vigilante-settings-section <?php echo $vg_shared_locked ? 'vigilante-form-disabled' : ''; ?>" <?php echo $vg_shared_locked ? 'inert' : ''; ?>>
6041 <h2>
6042 <?php esc_html_e( 'wp-config.php Security', 'vigilante' ); ?>
6043 <span class="vigilante-method-badge config"><?php esc_html_e( 'WP-CONFIG', 'vigilante' ); ?></span>
6044 </h2>
6045 <p><?php esc_html_e( 'Security constants added directly to wp-config.php file.', 'vigilante' ); ?></p>
6046
6047 <table class="form-table">
6048 <tr>
6049 <th scope="row"><?php esc_html_e( 'Disable File Editor', 'vigilante' ); ?></th>
6050 <td>
6051 <label>
6052 <input type="checkbox" name="wp_hardening[disallow_file_edit]" value="1" <?php checked( ! empty( $options['disallow_file_edit'] ) ); ?>>
6053 <?php esc_html_e( 'Disable plugin and theme editor in admin (DISALLOW_FILE_EDIT)', 'vigilante' ); ?>
6054 </label>
6055 </td>
6056 </tr>
6057 <tr>
6058 <th scope="row"><?php esc_html_e( 'Disable File Modifications', 'vigilante' ); ?></th>
6059 <td>
6060 <label>
6061 <input type="checkbox" name="wp_hardening[disallow_file_mods]" value="1" <?php checked( ! empty( $options['disallow_file_mods'] ) ); ?>>
6062 <?php esc_html_e( 'Disable all file modifications including updates (DISALLOW_FILE_MODS)', 'vigilante' ); ?>
6063 </label>
6064 <p class="description"><?php esc_html_e( '&#9888; Warning: This prevents automatic updates.', 'vigilante' ); ?></p>
6065 </td>
6066 </tr>
6067 <tr id="field-force-ssl-admin">
6068 <th scope="row"><?php esc_html_e( 'Force SSL Admin', 'vigilante' ); ?></th>
6069 <td>
6070 <label>
6071 <input type="checkbox" name="wp_hardening[force_ssl_admin]" value="1" <?php checked( ! empty( $options['force_ssl_admin'] ) ); ?>>
6072 <?php esc_html_e( 'Force HTTPS for admin area (FORCE_SSL_ADMIN)', 'vigilante' ); ?>
6073 </label>
6074 <p class="description"><?php esc_html_e( '&#9888; Warning: Only enable if your site fully supports HTTPS.', 'vigilante' ); ?></p>
6075 </td>
6076 </tr>
6077 <tr id="field-wp-debug">
6078 <th scope="row"><?php esc_html_e( 'Hide PHP errors from visitors', 'vigilante' ); ?></th>
6079 <td>
6080 <label>
6081 <input type="checkbox" name="wp_hardening[wp_debug]" value="1" <?php checked( ! empty( $options['wp_debug'] ) ); ?>>
6082 <?php esc_html_e( 'Prevents PHP errors and warnings from being displayed publicly. Also avoids exposing a debug.log file in wp-content/ that could leak paths and code. Uncheck only on development or staging sites.', 'vigilante' ); ?>
6083 </label>
6084 </td>
6085 </tr>
6086 <tr id="field-disable-wp-cron">
6087 <th scope="row"><?php esc_html_e( 'Disable WP Cron', 'vigilante' ); ?></th>
6088 <td>
6089 <label>
6090 <input type="checkbox" name="wp_hardening[disable_wp_cron]" value="1" <?php checked( ! empty( $options['disable_wp_cron'] ) ); ?>>
6091 <?php esc_html_e( 'Disable WordPress\'s page-view cron trigger (DISABLE_WP_CRON)', 'vigilante' ); ?>
6092 </label>
6093 <p class="description"><?php
6094 printf(
6095 /* translators: 1: opening <strong>, 2: closing </strong>, 3: opening <code>, 4: closing </code> */
6096 esc_html__( '%1$sWarning:%2$s Only enable if your host runs a real server-side cron job calling wp-cron.php. Otherwise scheduled tasks stop running. This constant only stops the page-view auto-spawn — to also block external HTTP abuse, enable %3$sProtect wp-cron.php%4$s in Firewall &rarr; File Protection.', 'vigilante' ),
6097 '<strong>',
6098 '</strong>',
6099 '<code>',
6100 '</code>'
6101 ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML tags are hardcoded.
6102 ?></p>
6103 </td>
6104 </tr>
6105 </table>
6106 </div>
6107 <?php $options = $vg_local_options; ?>
6108
6109 <!-- Comment Security -->
6110 <div id="vigilante-section-hardening-xmlrpc" class="vigilante-settings-section">
6111 <h2>
6112 <?php esc_html_e( 'XML-RPC', 'vigilante' ); ?>
6113 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
6114 </h2>
6115 <p><?php esc_html_e( 'The legacy remote interface. It is what the WordPress mobile app, Jetpack and remote managers talk to, and also the way pingback amplification and password guessing reach a site.', 'vigilante' ); ?></p>
6116
6117 <table class="form-table">
6118 <tr id="field-disable-xmlrpc">
6119 <th scope="row"><label for="vigilante-f-wp-hardening-xmlrpc-mode"><?php esc_html_e( 'XML-RPC access', 'vigilante' ); ?></label></th>
6120 <td>
6121 <?php $vig_xmlrpc_mode = Vigilante_Comment_Security::resolve_xmlrpc_mode( $this->settings ); ?>
6122 <select id="vigilante-f-wp-hardening-xmlrpc-mode" name="wp_hardening[xmlrpc_mode]">
6123 <option value="none" <?php selected( $vig_xmlrpc_mode, 'none' ); ?>>
6124 <?php esc_html_e( 'Leave XML-RPC enabled', 'vigilante' ); ?>
6125 </option>
6126 <option value="pingback" <?php selected( $vig_xmlrpc_mode, 'pingback' ); ?>>
6127 <?php esc_html_e( 'Block the pingback methods only', 'vigilante' ); ?>
6128 </option>
6129 <option value="full" <?php selected( $vig_xmlrpc_mode, 'full' ); ?>>
6130 <?php esc_html_e( 'Disable XML-RPC completely (recommended)', 'vigilante' ); ?>
6131 </option>
6132 </select>
6133 <p class="description"><?php esc_html_e( 'Disable it completely unless something still needs it, such as the WordPress mobile app, Jetpack or a remote manager; in that case block only the pingback methods, which closes the amplification vector and leaves the rest working. Pingbacks are also covered by the Comment Security setting just below, which additionally closes them for comments.', 'vigilante' ); ?></p>
6134 </td>
6135 </tr>
6136 </table>
6137 </div>
6138
6139 <div id="vigilante-section-hardening-comments" class="vigilante-settings-section">
6140 <h2>
6141 <?php esc_html_e( 'Comment Security', 'vigilante' ); ?>
6142 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
6143 <span class="vigilante-method-badge settings"><?php esc_html_e( 'Settings', 'vigilante' ); ?></span>
6144 </h2>
6145 <p><?php esc_html_e( 'Comment protection using WordPress settings and PHP hooks.', 'vigilante' ); ?></p>
6146
6147 <table class="form-table">
6148 <tr>
6149 <th scope="row"><?php esc_html_e( 'Disable Pingbacks', 'vigilante' ); ?></th>
6150 <td>
6151 <label>
6152 <input type="checkbox" name="wp_hardening[disable_pingbacks]" value="1" <?php checked( ! empty( $options['disable_pingbacks'] ) ); ?>>
6153 <?php esc_html_e( 'Disable pingbacks (commonly exploited for DDoS)', 'vigilante' ); ?>
6154 </label>
6155 </td>
6156 </tr>
6157 <tr>
6158 <th scope="row"><?php esc_html_e( 'Disable Trackbacks', 'vigilante' ); ?></th>
6159 <td>
6160 <label>
6161 <input type="checkbox" name="wp_hardening[disable_trackbacks]" value="1" <?php checked( ! empty( $options['disable_trackbacks'] ) ); ?>>
6162 <?php esc_html_e( 'Disable trackbacks (rarely used legitimately)', 'vigilante' ); ?>
6163 </label>
6164 </td>
6165 </tr>
6166 <tr>
6167 <th scope="row"><?php esc_html_e( 'Require Moderation', 'vigilante' ); ?></th>
6168 <td>
6169 <label>
6170 <input type="checkbox" name="wp_hardening[require_comment_moderation]" value="1" <?php checked( ! empty( $options['require_comment_moderation'] ) ); ?>>
6171 <?php esc_html_e( 'All comments must be manually approved', 'vigilante' ); ?>
6172 </label>
6173 </td>
6174 </tr>
6175 <tr>
6176 <th scope="row"><?php esc_html_e( 'Close Old Comments', 'vigilante' ); ?></th>
6177 <td>
6178 <label>
6179 <input type="checkbox" name="wp_hardening[close_old_comments]" value="1" <?php checked( ! empty( $options['close_old_comments'] ) ); ?>>
6180 <?php esc_html_e( 'Automatically close comments on old posts after', 'vigilante' ); ?>
6181 </label>
6182 <input type="number" id="vigilante-f-wp-hardening-close-comments-after-days" name="wp_hardening[close_comments_after_days]" value="<?php echo esc_attr( $options['close_comments_after_days'] ?? 30 ); ?>" min="1" max="365" class="small-text">
6183 <label for="vigilante-f-wp-hardening-close-comments-after-days"><?php esc_html_e( 'days', 'vigilante' ); ?></label>
6184 </td>
6185 </tr>
6186 <tr>
6187 <th scope="row"><?php esc_html_e( 'Honeypot Protection', 'vigilante' ); ?></th>
6188 <td>
6189 <label>
6190 <input type="checkbox" name="wp_hardening[honeypot_comments]" value="1" <?php checked( ! empty( $options['honeypot_comments'] ) ); ?>>
6191 <?php esc_html_e( 'Add hidden honeypot field to catch bots', 'vigilante' ); ?>
6192 </label>
6193 </td>
6194 </tr>
6195 </table>
6196 </div>
6197
6198 <!-- Head Cleaner -->
6199 <div id="vigilante-section-hardening-headers" class="vigilante-settings-section">
6200 <h2>
6201 <?php esc_html_e( 'Header Cleanup', 'vigilante' ); ?>
6202 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
6203 </h2>
6204 <p><?php esc_html_e( 'Remove meta tags from HTML head.', 'vigilante' ); ?></p>
6205
6206 <table class="form-table">
6207 <tr>
6208 <th scope="row"><?php esc_html_e( 'Remove Generator', 'vigilante' ); ?></th>
6209 <td>
6210 <label>
6211 <input type="checkbox" name="wp_hardening[remove_wp_generator]" value="1" <?php checked( ! empty( $options['remove_wp_generator'] ) ); ?>>
6212 <?php esc_html_e( 'Remove WordPress version from HTML head', 'vigilante' ); ?>
6213 </label>
6214 </td>
6215 </tr>
6216 <tr id="field-remove-wp-version-assets">
6217 <th scope="row"><?php esc_html_e( 'Remove version from assets', 'vigilante' ); ?></th>
6218 <td>
6219 <label>
6220 <input type="checkbox" name="wp_hardening[remove_wp_version_assets]" value="1" <?php checked( ! empty( $options['remove_wp_version_assets'] ) ); ?>>
6221 <?php esc_html_e( 'Remove WordPress version from script/style URLs (?ver=)', 'vigilante' ); ?>
6222 </label>
6223 <p class="description"><?php esc_html_e( 'Hides the exact WordPress version that would otherwise leak in every enqueued asset URL. Versions added by plugins or themes are kept untouched.', 'vigilante' ); ?></p>
6224 </td>
6225 </tr>
6226 <tr>
6227 <th scope="row"><?php esc_html_e( 'Remove RSD Link', 'vigilante' ); ?></th>
6228 <td>
6229 <label>
6230 <input type="checkbox" name="wp_hardening[remove_rsd_link]" value="1" <?php checked( ! empty( $options['remove_rsd_link'] ) ); ?>>
6231 <?php esc_html_e( 'Remove Really Simple Discovery link', 'vigilante' ); ?>
6232 </label>
6233 </td>
6234 </tr>
6235 <tr>
6236 <th scope="row"><?php esc_html_e( 'Remove WLW Manifest', 'vigilante' ); ?></th>
6237 <td>
6238 <label>
6239 <input type="checkbox" name="wp_hardening[remove_wlw_manifest]" value="1" <?php checked( ! empty( $options['remove_wlw_manifest'] ) ); ?>>
6240 <?php esc_html_e( 'Remove Windows Live Writer manifest link', 'vigilante' ); ?>
6241 </label>
6242 </td>
6243 </tr>
6244 <tr>
6245 <th scope="row"><?php esc_html_e( 'Remove Shortlink', 'vigilante' ); ?></th>
6246 <td>
6247 <label>
6248 <input type="checkbox" name="wp_hardening[remove_shortlink]" value="1" <?php checked( ! empty( $options['remove_shortlink'] ) ); ?>>
6249 <?php esc_html_e( 'Remove shortlink tag from header', 'vigilante' ); ?>
6250 </label>
6251 </td>
6252 </tr>
6253 <tr>
6254 <th scope="row"><?php esc_html_e( 'Remove REST API Link', 'vigilante' ); ?></th>
6255 <td>
6256 <label>
6257 <input type="checkbox" name="wp_hardening[remove_rest_api_link]" value="1" <?php checked( ! empty( $options['remove_rest_api_link'] ) ); ?>>
6258 <?php esc_html_e( 'Remove REST API discovery link from header', 'vigilante' ); ?>
6259 </label>
6260 <p class="description"><?php esc_html_e( '&#9888; Notice: Some plugins may need this link.', 'vigilante' ); ?></p>
6261 </td>
6262 </tr>
6263 </table>
6264 </div>
6265
6266 <!-- Feed Manager -->
6267 <div id="vigilante-section-hardening-rss" class="vigilante-settings-section">
6268 <h2>
6269 <?php esc_html_e( 'RSS Feed Settings', 'vigilante' ); ?>
6270 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
6271 </h2>
6272 <p><?php esc_html_e( 'Control RSS/Atom feeds.', 'vigilante' ); ?></p>
6273
6274 <table class="form-table">
6275 <tr>
6276 <th scope="row"><?php esc_html_e( 'Disable Feeds', 'vigilante' ); ?></th>
6277 <td>
6278 <label>
6279 <input type="checkbox" name="wp_hardening[disable_feeds]" value="1" <?php checked( ! empty( $options['disable_feeds'] ) ); ?>>
6280 <?php esc_html_e( 'Completely disable RSS/Atom feeds', 'vigilante' ); ?>
6281 </label>
6282 </td>
6283 </tr>
6284 <tr>
6285 <th scope="row"><?php esc_html_e( 'Disable If No Content', 'vigilante' ); ?></th>
6286 <td>
6287 <label>
6288 <input type="checkbox" name="wp_hardening[disable_if_no_content]" value="1" <?php checked( ! empty( $options['disable_if_no_content'] ) ); ?>>
6289 <?php esc_html_e( 'Only disable feeds if site has no published posts', 'vigilante' ); ?>
6290 </label>
6291 </td>
6292 </tr>
6293 <tr>
6294 <th scope="row"><?php esc_html_e( 'Remove Feed Version', 'vigilante' ); ?></th>
6295 <td>
6296 <label>
6297 <input type="checkbox" name="wp_hardening[remove_feed_version]" value="1" <?php checked( ! empty( $options['remove_feed_version'] ) ); ?>>
6298 <?php esc_html_e( 'Remove WordPress version from feed generator tag', 'vigilante' ); ?>
6299 </label>
6300 </td>
6301 </tr>
6302 </table>
6303 </div>
6304
6305 <p class="submit vigilante-submit-buttons">
6306 <button type="submit" class="button button-primary vigilante-save-btn" data-original-text="<?php esc_attr_e( 'Save Settings', 'vigilante' ); ?>">
6307 <?php esc_html_e( 'Save Settings', 'vigilante' ); ?>
6308 </button>
6309 <button type="button" class="button vigilante-reset-section-btn" data-original-text="<?php esc_attr_e( 'Reset to Defaults', 'vigilante' ); ?>">
6310 <?php esc_html_e( 'Reset to Defaults', 'vigilante' ); ?>
6311 </button>
6312 </p>
6313 </form>
6314 <?php
6315 }
6316
6317 /**
6318 * Render activity log tab
6319 */
6320 private function render_tab_activity_log() {
6321 $is_disabled = $this->render_module_disabled_notice( 'activity_log' );
6322 $options = $this->settings->get_section( 'activity_log' );
6323 ?>
6324 <form class="vigilante-settings-form <?php echo $is_disabled ? 'vigilante-form-disabled' : ''; ?>" data-section="activity_log" <?php echo $is_disabled ? 'inert' : ''; ?>>
6325 <div id="vigilante-section-audit-settings" class="vigilante-settings-section">
6326 <h2>
6327 <?php esc_html_e( 'Security Audit Settings', 'vigilante' ); ?>
6328 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
6329 <span class="vigilante-method-badge database"><?php esc_html_e( 'Database', 'vigilante' ); ?></span>
6330 </h2>
6331 <p><?php esc_html_e( 'Security event logging and auditing.', 'vigilante' ); ?></p>
6332
6333 <table class="form-table">
6334 <tr>
6335 <th scope="row"><?php esc_html_e( 'Retention', 'vigilante' ); ?></th>
6336 <td>
6337 <input type="number" id="vigilante-f-activity-log-retention-days" name="activity_log[retention_days]" value="<?php echo esc_attr( $options['retention_days'] ?? 30 ); ?>" min="7" max="365" class="small-text">
6338 <label for="vigilante-f-activity-log-retention-days"><?php esc_html_e( 'days', 'vigilante' ); ?></label>
6339 &nbsp;&nbsp;
6340 <input type="number" id="vigilante-f-activity-log-max-entries" name="activity_log[max_entries]" value="<?php echo esc_attr( $options['max_entries'] ?? 10000 ); ?>" min="100" max="100000" step="100" class="small-text">
6341 <label for="vigilante-f-activity-log-max-entries"><?php esc_html_e( 'max entries', 'vigilante' ); ?></label>
6342 <p class="description"><?php esc_html_e( 'Whichever limit is reached first takes effect. Changes apply immediately on save; daily maintenance also enforces these limits automatically.', 'vigilante' ); ?></p>
6343 </td>
6344 </tr>
6345 <tr>
6346 <th scope="row"><?php esc_html_e( 'Events to Log', 'vigilante' ); ?></th>
6347 <td>
6348 <fieldset style="display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:6px 24px; max-width:600px;">
6349 <label><input type="checkbox" name="activity_log[log_logins]" value="1" <?php checked( ! empty( $options['log_logins'] ) ); ?>> <?php esc_html_e( 'Successful logins', 'vigilante' ); ?></label>
6350 <label><input type="checkbox" name="activity_log[log_failed_logins]" value="1" <?php checked( ! empty( $options['log_failed_logins'] ) ); ?>> <?php esc_html_e( 'Failed login attempts', 'vigilante' ); ?></label>
6351 <label><input type="checkbox" name="activity_log[log_user_changes]" value="1" <?php checked( ! empty( $options['log_user_changes'] ) ); ?>> <?php esc_html_e( 'User changes', 'vigilante' ); ?></label>
6352 <label><input type="checkbox" name="activity_log[log_post_changes]" value="1" <?php checked( ! empty( $options['log_post_changes'] ) ); ?>> <?php esc_html_e( 'Content changes', 'vigilante' ); ?></label>
6353 <label><input type="checkbox" name="activity_log[log_plugin_changes]" value="1" <?php checked( ! empty( $options['log_plugin_changes'] ) ); ?>> <?php esc_html_e( 'Plugin changes', 'vigilante' ); ?></label>
6354 <label><input type="checkbox" name="activity_log[log_theme_changes]" value="1" <?php checked( ! empty( $options['log_theme_changes'] ) ); ?>> <?php esc_html_e( 'Theme changes', 'vigilante' ); ?></label>
6355 <label><input type="checkbox" name="activity_log[log_comments]" value="1" <?php checked( ! empty( $options['log_comments'] ) ); ?>> <?php esc_html_e( 'Comment changes', 'vigilante' ); ?></label>
6356 <label><input type="checkbox" name="activity_log[log_media]" value="1" <?php checked( ! empty( $options['log_media'] ) ); ?>> <?php esc_html_e( 'Media uploads/deletions', 'vigilante' ); ?></label>
6357 <label><input type="checkbox" name="activity_log[log_file_changes]" value="1" <?php checked( ! empty( $options['log_file_changes'] ) ); ?>> <?php esc_html_e( 'File integrity events', 'vigilante' ); ?></label>
6358 <label><input type="checkbox" name="activity_log[log_option_changes]" value="1" <?php checked( ! empty( $options['log_option_changes'] ) ); ?>> <?php esc_html_e( 'WordPress option changes', 'vigilante' ); ?></label>
6359 </fieldset>
6360 <div class="notice notice-info inline" style="margin:10px 0 0;padding:8px 12px;">
6361 <p style="margin:0;">
6362 <?php esc_html_e( 'Firewall blocks, security events, and Vigilant settings changes are always logged regardless of the above selections.', 'vigilante' ); ?>
6363 </p>
6364 </div>
6365 </td>
6366 </tr>
6367 <tr>
6368 <th scope="row"><label for="vigilante-f-activity-log-tracked-options"><?php esc_html_e( 'Option Tracking', 'vigilante' ); ?></label></th>
6369 <td>
6370 <p class="description" style="margin-top:0;"><?php esc_html_e( 'When "WordPress option changes" is enabled, Vigilant tracks ~30 core WordPress settings (site URL, admin email, registration, active plugins, theme, comments, privacy, etc.). Use the field below to track additional options from other plugins.', 'vigilante' ); ?></p>
6371 <br>
6372 <label><?php esc_html_e( 'Additional options to track:', 'vigilante' ); ?></label><br>
6373 <textarea id="vigilante-f-activity-log-tracked-options" name="activity_log[tracked_options]" rows="3" cols="50" class="regular-text code" placeholder="woocommerce_&#10;seopress_&#10;wpforms_"><?php echo esc_textarea( implode( "\n", $options['tracked_options'] ?? array() ) ); ?></textarea>
6374 <p class="description"><?php esc_html_e( 'One option name per line. Use a trailing underscore to match all options with that prefix (e.g. "woocommerce_" tracks all WooCommerce settings).', 'vigilante' ); ?></p>
6375 </td>
6376 </tr>
6377 <tr>
6378 <th scope="row"><?php esc_html_e( 'Exclusions', 'vigilante' ); ?></th>
6379 <td>
6380 <div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:16px; max-width:600px;">
6381 <div>
6382 <label for="vigilante-f-activity-log-excluded-users"><?php esc_html_e( 'Excluded user IDs:', 'vigilante' ); ?></label><br>
6383 <textarea id="vigilante-f-activity-log-excluded-users" name="activity_log[excluded_users]" rows="3" cols="25"><?php echo esc_textarea( implode( "\n", $options['excluded_users'] ?? array() ) ); ?></textarea>
6384 <p class="description"><?php esc_html_e( 'One user ID per line. Actions by these users will not be logged.', 'vigilante' ); ?></p>
6385 </div>
6386 <div>
6387 <label for="vigilante-f-activity-log-excluded-ips"><?php esc_html_e( 'Excluded IPs:', 'vigilante' ); ?></label><br>
6388 <textarea id="vigilante-f-activity-log-excluded-ips" name="activity_log[excluded_ips]" rows="3" cols="25"><?php echo esc_textarea( implode( "\n", $options['excluded_ips'] ?? array() ) ); ?></textarea>
6389 <p class="description"><?php esc_html_e( 'One IP per line. Requests from these IPs will not be logged.', 'vigilante' ); ?></p>
6390 </div>
6391 </div>
6392 </td>
6393 </tr>
6394 </table>
6395 </div>
6396
6397 <p class="submit vigilante-submit-buttons">
6398 <button type="submit" class="button button-primary vigilante-save-btn" data-original-text="<?php esc_attr_e( 'Save Settings', 'vigilante' ); ?>">
6399 <?php esc_html_e( 'Save Settings', 'vigilante' ); ?>
6400 </button>
6401 <button type="button" class="button vigilante-reset-section-btn" data-original-text="<?php esc_attr_e( 'Reset to Defaults', 'vigilante' ); ?>">
6402 <?php esc_html_e( 'Reset to Defaults', 'vigilante' ); ?>
6403 </button>
6404 </p>
6405 </form>
6406
6407 <?php
6408 // Audit Alerts — alerting layer on top of Security Audit. Its own
6409 // settings section and form, rendered right after the logging settings
6410 // so the tab reads as "log this, exclude that, and alert me about this".
6411 $alerts = $this->settings->get_section( 'audit_alerts' );
6412 $immediate = isset( $alerts['immediate'] ) ? $alerts['immediate'] : array();
6413 $threshold = isset( $alerts['threshold'] ) ? $alerts['threshold'] : array();
6414 $alert_severity = isset( $immediate['min_severity'] ) ? $immediate['min_severity'] : 'critical';
6415 $alert_window = isset( $threshold['window'] ) ? $threshold['window'] : '1h';
6416 $threshold_cats = isset( $threshold['categories'] ) ? (array) $threshold['categories'] : array();
6417 $cat_labels = Vigilante_Audit_Alerts::category_labels();
6418 ?>
6419 <form class="vigilante-settings-form <?php echo $is_disabled ? 'vigilante-form-disabled' : ''; ?>" data-section="audit_alerts" <?php echo $is_disabled ? 'inert' : ''; ?>>
6420 <div id="vigilante-section-audit-alerts" class="vigilante-settings-section">
6421 <h2>
6422 <?php esc_html_e( 'Audit Alerts', 'vigilante' ); ?>
6423 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
6424 </h2>
6425 <p><?php esc_html_e( 'Get an email when the events above point to something worth your attention. Both alert types are off by default.', 'vigilante' ); ?></p>
6426
6427 <table class="form-table">
6428 <tr id="field-audit-alerts-immediate">
6429 <th scope="row"><?php esc_html_e( 'Immediate alerts', 'vigilante' ); ?></th>
6430 <td>
6431 <label>
6432 <input type="checkbox" name="audit_alerts[immediate][enabled]" value="1" <?php checked( ! empty( $immediate['enabled'] ) ); ?>>
6433 <?php esc_html_e( 'Email me as soon as a serious event is logged', 'vigilante' ); ?>
6434 </label>
6435 <p class="description"><?php esc_html_e( 'Sends one email per event type, then waits for the cooldown below before repeating, so a burst of the same event is a single notice.', 'vigilante' ); ?></p>
6436 </td>
6437 </tr>
6438 <tr>
6439 <th scope="row"><label for="vigilante-f-audit-alerts-immediate-min-severity"><?php esc_html_e( 'Alert on severity', 'vigilante' ); ?></label></th>
6440 <td>
6441 <select id="vigilante-f-audit-alerts-immediate-min-severity" name="audit_alerts[immediate][min_severity]">
6442 <option value="critical" <?php selected( $alert_severity, 'critical' ); ?>><?php esc_html_e( 'Critical only (recommended)', 'vigilante' ); ?></option>
6443 <option value="warning" <?php selected( $alert_severity, 'warning' ); ?>><?php esc_html_e( 'Warning and Critical', 'vigilante' ); ?></option>
6444 </select>
6445 <p class="description"><?php esc_html_e( 'A closed plugin is logged as Critical, so "Critical only" covers it. A new administrator and a change of role are logged as Warning: they have their own emails, in Users > Admin monitoring, and reach this alert only with "Warning and Critical".', 'vigilante' ); ?></p>
6446 </td>
6447 </tr>
6448 <tr id="field-audit-alerts-threshold">
6449 <th scope="row"><?php esc_html_e( 'Threshold alerts', 'vigilante' ); ?></th>
6450 <td>
6451 <label>
6452 <input type="checkbox" name="audit_alerts[threshold][enabled]" value="1" <?php checked( ! empty( $threshold['enabled'] ) ); ?>>
6453 <?php esc_html_e( 'Email me when a category spikes within a time window', 'vigilante' ); ?>
6454 </label>
6455 <p class="description"><?php esc_html_e( 'Catches an attack in progress, e.g. hundreds of firewall blocks or login failures in an hour.', 'vigilante' ); ?></p>
6456 </td>
6457 </tr>
6458 <tr>
6459 <th scope="row"><label for="vigilante-f-audit-alerts-threshold-window"><?php esc_html_e( 'Time window', 'vigilante' ); ?></label></th>
6460 <td>
6461 <select id="vigilante-f-audit-alerts-threshold-window" name="audit_alerts[threshold][window]">
6462 <option value="30m" <?php selected( $alert_window, '30m' ); ?>><?php esc_html_e( '30 minutes', 'vigilante' ); ?></option>
6463 <option value="1h" <?php selected( $alert_window, '1h' ); ?>><?php esc_html_e( '1 hour', 'vigilante' ); ?></option>
6464 <option value="6h" <?php selected( $alert_window, '6h' ); ?>><?php esc_html_e( '6 hours', 'vigilante' ); ?></option>
6465 <option value="24h" <?php selected( $alert_window, '24h' ); ?>><?php esc_html_e( '24 hours', 'vigilante' ); ?></option>
6466 </select>
6467 <p class="description"><?php esc_html_e( 'How far back Vigilant looks when counting events. For example, "1 hour" means "more than the number below within the last hour".', 'vigilante' ); ?></p>
6468 </td>
6469 </tr>
6470 <tr>
6471 <th scope="row"><?php esc_html_e( 'Thresholds per category', 'vigilante' ); ?></th>
6472 <td>
6473 <fieldset style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:8px 24px; max-width:760px;">
6474 <?php
6475 foreach ( $cat_labels as $cat_slug => $cat_label ) :
6476 $cat_value = isset( $threshold_cats[ $cat_slug ] ) ? (int) $threshold_cats[ $cat_slug ] : 0;
6477 ?>
6478 <label style="display:flex;align-items:center;gap:8px;justify-content:space-between;">
6479 <span><?php echo esc_html( $cat_label ); ?></span>
6480 <input type="number" name="audit_alerts[threshold][categories][<?php echo esc_attr( $cat_slug ); ?>]" value="<?php echo esc_attr( $cat_value ); ?>" min="0" max="100000" step="1" class="small-text">
6481 </label>
6482 <?php endforeach; ?>
6483 </fieldset>
6484 <p class="description"><?php esc_html_e( 'Number of warning/critical events in the window that triggers an alert. 0 disables that category. Routine info-level activity (normal logins, edits) is not counted.', 'vigilante' ); ?></p>
6485 </td>
6486 </tr>
6487 <tr>
6488 <th scope="row"><?php esc_html_e( "Don't repeat alerts", 'vigilante' ); ?></th>
6489 <td>
6490 <input type="number" id="vigilante-f-audit-alerts-cooldown-minutes" name="audit_alerts[cooldown_minutes]" value="<?php echo esc_attr( isset( $alerts['cooldown_minutes'] ) ? (int) $alerts['cooldown_minutes'] : 60 ); ?>" min="0" max="1440" class="small-text">
6491 <label for="vigilante-f-audit-alerts-cooldown-minutes"><?php esc_html_e( 'minutes', 'vigilante' ); ?></label>
6492 <p class="description"><?php esc_html_e( 'After an alert, Vigilant waits this long before sending another about the same thing: the same event type for immediate alerts, or the same category for threshold alerts. This prevents a flood during a sustained attack. Applies to both alert types above.', 'vigilante' ); ?></p>
6493 </td>
6494 </tr>
6495
6496 <tr>
6497 <th scope="row"><?php esc_html_e( 'Recipients', 'vigilante' ); ?></th>
6498 <td>
6499 <p class="description" style="margin-top:0;">
6500 <?php
6501 printf(
6502 /* translators: %s: Link to notification settings */
6503 esc_html__( 'Alerts go to the recipients configured in %s.', 'vigilante' ),
6504 '<a href="' . esc_url( admin_url( 'admin.php?page=vigilante&tab=tools' ) ) . '">' . esc_html__( 'Settings & Tools', 'vigilante' ) . '</a>'
6505 );
6506 ?>
6507 </p>
6508 <p style="margin:8px 0 0;">
6509 <button type="button" class="button vigilante-test-email-btn" data-original-text="<?php esc_attr_e( 'Send test email', 'vigilante' ); ?>">
6510 <?php esc_html_e( 'Send test email', 'vigilante' ); ?>
6511 </button>
6512 <span class="vigilante-test-email-result" style="margin-left:8px;"></span>
6513 </p>
6514 <p class="description"><?php esc_html_e( 'Heads up: some events (a new admin, a closed plugin) already send their own email from other modules. Enabling alerts for them here too may produce two notices until notifications are unified.', 'vigilante' ); ?></p>
6515 </td>
6516 </tr>
6517 </table>
6518 </div>
6519
6520 <p class="submit vigilante-submit-buttons">
6521 <button type="submit" class="button button-primary vigilante-save-btn" data-original-text="<?php esc_attr_e( 'Save Settings', 'vigilante' ); ?>">
6522 <?php esc_html_e( 'Save Settings', 'vigilante' ); ?>
6523 </button>
6524 <button type="button" class="button vigilante-reset-section-btn" data-original-text="<?php esc_attr_e( 'Reset to Defaults', 'vigilante' ); ?>">
6525 <?php esc_html_e( 'Reset to Defaults', 'vigilante' ); ?>
6526 </button>
6527 </p>
6528 </form>
6529
6530 <div id="vigilante-section-audit-recent" class="vigilante-settings-section">
6531 <h2><?php esc_html_e( 'Recent Activity', 'vigilante' ); ?></h2>
6532
6533 <?php
6534 $logs = $this->activity_log->get_logs( array( 'per_page' => 20 ) );
6535 $total_logs = $this->activity_log->get_logs_count();
6536
6537 // Label maps for translated display
6538 $type_labels = array(
6539 'login' => __( 'Login', 'vigilante' ),
6540 '2fa' => __( 'Two-factor', 'vigilante' ),
6541 'user' => __( 'User', 'vigilante' ),
6542 'content' => __( 'Content', 'vigilante' ),
6543 'plugin' => __( 'Plugin', 'vigilante' ),
6544 'theme' => __( 'Theme', 'vigilante' ),
6545 'settings' => __( 'Settings', 'vigilante' ),
6546 'comment' => __( 'Comment', 'vigilante' ),
6547 'media' => __( 'Media', 'vigilante' ),
6548 'firewall' => __( 'Firewall', 'vigilante' ),
6549 'file' => __( 'File', 'vigilante' ),
6550 'security' => __( 'Security', 'vigilante' ),
6551 'system' => __( 'System', 'vigilante' ),
6552 );
6553 $severity_labels = array(
6554 'info' => __( 'Info', 'vigilante' ),
6555 'warning' => __( 'Warning', 'vigilante' ),
6556 'critical' => __( 'Critical', 'vigilante' ),
6557 );
6558
6559 $firewall_options = $this->settings->get_section( 'firewall' );
6560 $ip_whitelist = $firewall_options['ip_whitelist'] ?? array();
6561 $ip_blacklist = $firewall_options['ip_blacklist'] ?? array();
6562 $ua_whitelist = $firewall_options['ua_whitelist'] ?? array();
6563 $ua_blacklist = $firewall_options['ua_blacklist'] ?? array();
6564 ?>
6565
6566 <div class="vigilante-log-filters">
6567 <input type="text" id="vigilante-log-search" aria-label="<?php esc_attr_e( 'Search the activity log', 'vigilante' ); ?>" size="1" placeholder="<?php esc_attr_e( 'Search logs (min. 3 characters)...', 'vigilante' ); ?>" class="vigilante-log-search-input">
6568 <select id="vigilante-log-type-filter" aria-label="<?php esc_attr_e( 'Filter the log by event type', 'vigilante' ); ?>">
6569 <option value=""><?php esc_html_e( 'All Types', 'vigilante' ); ?></option>
6570 <option value="login"><?php esc_html_e( 'Login', 'vigilante' ); ?></option>
6571 <option value="2fa"><?php esc_html_e( 'Two-factor', 'vigilante' ); ?></option>
6572 <option value="user"><?php esc_html_e( 'User', 'vigilante' ); ?></option>
6573 <option value="content"><?php esc_html_e( 'Content', 'vigilante' ); ?></option>
6574 <option value="plugin"><?php esc_html_e( 'Plugin', 'vigilante' ); ?></option>
6575 <option value="theme"><?php esc_html_e( 'Theme', 'vigilante' ); ?></option>
6576 <option value="settings"><?php esc_html_e( 'Settings', 'vigilante' ); ?></option>
6577 <option value="comment"><?php esc_html_e( 'Comment', 'vigilante' ); ?></option>
6578 <option value="media"><?php esc_html_e( 'Media', 'vigilante' ); ?></option>
6579 <option value="firewall"><?php esc_html_e( 'Firewall', 'vigilante' ); ?></option>
6580 <option value="file"><?php esc_html_e( 'File', 'vigilante' ); ?></option>
6581 <option value="security"><?php esc_html_e( 'Security', 'vigilante' ); ?></option>
6582 <option value="system"><?php esc_html_e( 'System', 'vigilante' ); ?></option>
6583 </select>
6584 <select id="vigilante-log-severity-filter" aria-label="<?php esc_attr_e( 'Filter the log by severity', 'vigilante' ); ?>">
6585 <option value=""><?php esc_html_e( 'All Severities', 'vigilante' ); ?></option>
6586 <option value="info"><?php esc_html_e( 'Info', 'vigilante' ); ?></option>
6587 <option value="warning"><?php esc_html_e( 'Warning', 'vigilante' ); ?></option>
6588 <option value="critical"><?php esc_html_e( 'Critical', 'vigilante' ); ?></option>
6589 </select>
6590 <select id="vigilante-log-method-filter" aria-label="<?php esc_attr_e( 'Filter the log by HTTP method', 'vigilante' ); ?>">
6591 <option value=""><?php esc_html_e( 'All Methods', 'vigilante' ); ?></option>
6592 <option value="GET">GET</option>
6593 <option value="POST">POST</option>
6594 <option value="PUT">PUT</option>
6595 <option value="DELETE">DELETE</option>
6596 <option value="PATCH">PATCH</option>
6597 <option value="OPTIONS">OPTIONS</option>
6598 <option value="HEAD">HEAD</option>
6599 </select>
6600 <button type="button" id="vigilante-log-refresh" class="button"><?php esc_html_e( 'Refresh', 'vigilante' ); ?></button>
6601 <span class="vigilante-pagination" id="vigilante-log-pagination" data-total="<?php echo esc_attr( $total_logs ); ?>" data-per-page="20" data-page="1">
6602 <?php if ( $total_logs > 20 ) : ?>
6603 <button type="button" class="vigilante-page-first" title="<?php esc_attr_e( 'First page', 'vigilante' ); ?>" disabled>&laquo;</button>
6604 <button type="button" class="vigilante-page-prev" title="<?php esc_attr_e( 'Previous page', 'vigilante' ); ?>" disabled>&lsaquo;</button>
6605 <?php endif; ?>
6606 <span class="vigilante-page-info">
6607 <?php
6608 $showing = min( 20, $total_logs );
6609 printf(
6610 /* translators: 1: first item, 2: last item, 3: total items */
6611 esc_html__( '%1$d–%2$d of %3$d', 'vigilante' ),
6612 $total_logs > 0 ? 1 : 0,
6613 absint( $showing ),
6614 absint( $total_logs )
6615 );
6616 ?>
6617 </span>
6618 <?php if ( $total_logs > 20 ) : ?>
6619 <button type="button" class="vigilante-page-next" title="<?php esc_attr_e( 'Next page', 'vigilante' ); ?>">&rsaquo;</button>
6620 <button type="button" class="vigilante-page-last" title="<?php esc_attr_e( 'Last page', 'vigilante' ); ?>">&raquo;</button>
6621 <?php endif; ?>
6622 </span>
6623 </div>
6624
6625 <div class="vigilante-log-table-wrap">
6626 <table id="vigilante-activity-log-table" class="wp-list-table widefat striped">
6627 <thead>
6628 <tr>
6629 <th class="column-date"><?php esc_html_e( 'Date', 'vigilante' ); ?></th>
6630 <th class="column-type"><?php esc_html_e( 'Type', 'vigilante' ); ?></th>
6631 <th class="column-method"><?php esc_html_e( 'Method', 'vigilante' ); ?></th>
6632 <th class="column-severity"><?php esc_html_e( 'Severity', 'vigilante' ); ?></th>
6633 <th class="column-message"><?php esc_html_e( 'Message', 'vigilante' ); ?></th>
6634 <th class="column-user"><?php esc_html_e( 'User', 'vigilante' ); ?></th>
6635 <th class="column-ip"><?php esc_html_e( 'IP', 'vigilante' ); ?></th>
6636 <th class="column-details"><?php esc_html_e( 'Details', 'vigilante' ); ?></th>
6637 </tr>
6638 </thead>
6639 <tbody>
6640 <?php
6641 if ( empty( $logs ) ) :
6642 ?>
6643 <tr><td colspan="8"><?php esc_html_e( 'No log entries found.', 'vigilante' ); ?></td></tr>
6644 <?php else : ?>
6645 <?php foreach ( $logs as $log ) :
6646 $request_method = isset( $log->request_method ) ? $log->request_method : '';
6647 // Prepare details as a simple object
6648 $ip_val = (string) ( $log->ip_address ?? '' );
6649 $ua_val = (string) ( $log->user_agent ?? '' );
6650 $details = array(
6651 'id' => (int) $log->id,
6652 'type' => (string) ( $log->event_type ?? '' ),
6653 'action' => (string) ( $log->event_action ?? '' ),
6654 'message' => (string) ( $log->event_message ?? '' ),
6655 'user' => (string) ( $log->user_login ?? '' ),
6656 'ip' => $ip_val,
6657 'user_agent' => $ua_val,
6658 'request_method' => (string) $request_method,
6659 'request_uri' => Vigilante_Activity_Log::extract_request_uri( $log->extra_data ?? '' ),
6660 'date' => (string) ( $log->created_at ?? '' ),
6661 'severity' => (string) ( $log->severity ?? 'info' ),
6662 'is_ip_whitelisted' => ( '' !== $ip_val && in_array( $ip_val, $ip_whitelist, true ) ),
6663 'is_ip_blacklisted' => ( '' !== $ip_val && in_array( $ip_val, $ip_blacklist, true ) ),
6664 'is_ua_whitelisted' => ( '' !== $ua_val && in_array( $ua_val, $ua_whitelist, true ) ),
6665 'is_ua_blacklisted' => ( '' !== $ua_val && in_array( $ua_val, $ua_blacklist, true ) ),
6666 );
6667 // Self-protection entries carry what happened, what
6668 // it means and what to do, from the same catalogue
6669 // the File Integrity box uses.
6670 $vg_self_event = Vigilante_Self_Integrity_Guidance::for_log_event(
6671 (string) ( $log->event_action ?? '' ),
6672 $log->extra_data ?? '',
6673 (string) ( $log->severity ?? 'info' )
6674 );
6675 if ( null !== $vg_self_event ) {
6676 $details['self'] = $vg_self_event;
6677 }
6678 $display_type = isset( $type_labels[ $log->event_type ] ) ? $type_labels[ $log->event_type ] : $log->event_type;
6679 $display_severity = isset( $severity_labels[ $log->severity ] ) ? $severity_labels[ $log->severity ] : $log->severity;
6680 ?>
6681 <tr class="vigilante-severity-<?php echo esc_attr( $log->severity ); ?>">
6682 <td><?php echo esc_html( $log->created_at ); ?></td>
6683 <td><?php echo esc_html( $display_type ); ?></td>
6684 <td><?php if ( ! empty( $request_method ) ) : ?><span class="vigilante-method-label vigilante-method-<?php echo esc_attr( strtolower( $request_method ) ); ?>"><?php echo esc_html( $request_method ); ?></span><?php else : ?>-<?php endif; ?></td>
6685 <td><span class="vigilante-badge vigilante-badge-<?php echo esc_attr( $log->severity ); ?>"><?php echo esc_html( $display_severity ); ?></span></td>
6686 <td><?php echo esc_html( $log->event_message ); ?></td>
6687 <td><?php echo esc_html( $log->user_login ?? '-' ); ?></td>
6688 <td><code><?php echo esc_html( $log->ip_address ); ?></code></td>
6689 <td>
6690 <button type="button" class="button button-small vigilante-view-log-details"
6691 data-details='<?php echo esc_attr( wp_json_encode( $details, JSON_HEX_APOS | JSON_HEX_QUOT ) ); ?>'>
6692 <?php esc_html_e( 'View', 'vigilante' ); ?>
6693 </button>
6694 </td>
6695 </tr>
6696 <?php endforeach; ?>
6697 <?php endif; ?>
6698 </tbody>
6699 </table>
6700 </div>
6701
6702 <!-- Log Details Modal -->
6703 <div id="vigilante-log-details-modal" class="vigilante-modal" style="display: none;">
6704 <div class="vigilante-modal-content">
6705 <span class="vigilante-modal-close">&times;</span>
6706 <h3><?php esc_html_e( 'Log Entry Details', 'vigilante' ); ?></h3>
6707 <div id="vigilante-log-details-content"></div>
6708 </div>
6709 </div>
6710
6711 <p>
6712 <button type="button" class="button vigilante-export-logs"><?php esc_html_e( 'Export Audit Log', 'vigilante' ); ?></button>
6713 <button type="button" class="button vigilante-clear-logs" style="color: #a00;"><?php esc_html_e( 'Clear All Logs', 'vigilante' ); ?></button>
6714 </p>
6715 </div>
6716 <?php
6717 }
6718
6719 /**
6720 * Render File Integrity tab
6721 */
6722 /**
6723 * Findings of the self-check grouped by the case that explains them, worst
6724 * first: ten modified files are one case with ten paths, not ten copies of
6725 * the same explanation.
6726 *
6727 * @param array $findings Findings from the state.
6728 * @return array
6729 */
6730 private function self_findings_by_case( $findings ) {
6731 $groups = array();
6732 foreach ( (array) $findings as $finding ) {
6733 if ( ! is_array( $finding ) || 'info' === ( $finding['severity'] ?? '' ) ) {
6734 continue;
6735 }
6736 $guidance = Vigilante_Self_Integrity_Guidance::for_finding( $finding );
6737 $key = $guidance['key'];
6738 if ( ! isset( $groups[ $key ] ) ) {
6739 $guidance['files'] = array();
6740 $guidance['severity'] = 'warning';
6741 $groups[ $key ] = $guidance;
6742 }
6743 $file = isset( $finding['file'] ) ? (string) $finding['file'] : '';
6744 if ( '' !== $file && ! in_array( $file, $groups[ $key ]['files'], true ) ) {
6745 $groups[ $key ]['files'][] = $file;
6746 }
6747 if ( 'critical' === ( $finding['severity'] ?? '' ) ) {
6748 $groups[ $key ]['severity'] = 'critical';
6749 }
6750 }
6751 uasort(
6752 $groups,
6753 function ( $a, $b ) {
6754 $rank = array( 'critical' => 0, 'warning' => 1 );
6755 $ra = isset( $rank[ $a['severity'] ] ) ? $rank[ $a['severity'] ] : 2;
6756 $rb = isset( $rank[ $b['severity'] ] ) ? $rank[ $b['severity'] ] : 2;
6757 return $ra - $rb;
6758 }
6759 );
6760 return $groups;
6761 }
6762
6763 /**
6764 * Human label for the context that ran the last self-check.
6765 *
6766 * @param string $context Stored context.
6767 * @return string
6768 */
6769 private function self_context_label( $context ) {
6770 switch ( (string) $context ) {
6771 case 'scan':
6772 return __( 'during a file integrity scan', 'vigilante' );
6773 case 'upgrader':
6774 return __( 'right after updating Vigilant', 'vigilante' );
6775 case 'version_change':
6776 return __( 'after a version change made outside the updater', 'vigilante' );
6777 case 'migration':
6778 return __( 'while updating to this version', 'vigilante' );
6779 case 'activation':
6780 return __( 'when Vigilant was activated', 'vigilante' );
6781 case 'watchdog':
6782 return __( 'from the scheduled task watchdog', 'vigilante' );
6783 }
6784 return '';
6785 }
6786
6787 /**
6788 * Short line for the badge of the box, the notices and the Dashboard strip.
6789 *
6790 * @param string $tone Tone from Vigilante_Self_Integrity::tone().
6791 * @param array $state State.
6792 * @return string
6793 */
6794 private function self_integrity_headline( $tone, $state ) {
6795 $files = isset( $state['files_checked'] ) ? (int) $state['files_checked'] : 0;
6796 $anchors = ( isset( $state['anchors'] ) && is_array( $state['anchors'] ) ) ? $state['anchors'] : array();
6797 switch ( $tone ) {
6798 case 'critical':
6799 $kind = Vigilante_Self_Integrity::alarm_kind( $state, Vigilante_Self_Integrity::is_on() );
6800 if ( 'tasks' === $kind ) {
6801 return __( 'Vigilant scheduled tasks keep being removed', 'vigilante' );
6802 }
6803 if ( 'hooks' === $kind ) {
6804 return __( 'Something removed the hooks of Vigilant self-protection', 'vigilante' );
6805 }
6806 return __( 'Changes detected in Vigilant own files', 'vigilante' );
6807 case 'warning':
6808 return ( $files < 1 )
6809 ? __( 'Vigilant could not check its own files', 'vigilante' )
6810 : __( 'Vigilant self-protection needs your attention', 'vigilante' );
6811 case 'off':
6812 return __( 'Self-protection is switched off by code', 'vigilante' );
6813 case 'none':
6814 return __( 'Vigilant has not checked its own files yet', 'vigilante' );
6815 }
6816 return sprintf(
6817 /* translators: 1: number of files verified, 2: number of references available, out of three */
6818 __( 'Verified: %1$d files, %2$d of 3 references', 'vigilante' ),
6819 $files,
6820 count( array_filter( $anchors ) )
6821 );
6822 }
6823
6824 /**
6825 * Vigilant self-protection box: the first block of the File Integrity
6826 * results, with its own colour by severity, what each finding means and
6827 * how to fix it.
6828 *
6829 * It renders whether or not a scan has been stored, because the self-check
6830 * also runs after every update and once a day: before 3.0.0 this lived in
6831 * a line above the numeric cards of the last scan, where it was invisible
6832 * and, without a stored scan, absent.
6833 *
6834 * @param array $fi_options File Integrity settings section.
6835 */
6836 /**
6837 * Everything the screens need to say about self-protection, read once:
6838 * the state, its findings grouped by case, and the tone that colours the
6839 * box, the menu counter and the notices.
6840 *
6841 * @param array|null $fi_options File Integrity settings, read if not given.
6842 * @return array { state, findings, groups, tone, enabled }
6843 */
6844 private function self_integrity_summary( $fi_options = null ) {
6845 // Four to six screens ask for this in the same page load (menu, notice,
6846 // box, strip, both scores). The option is cached by the core options
6847 // layer, but the grouping and the tone are not: memoize per request.
6848 static $cached = null;
6849 if ( null !== $cached && ! is_array( $fi_options ) ) {
6850 return $cached;
6851 }
6852 if ( ! is_array( $fi_options ) ) {
6853 $fi_options = (array) $this->settings->get_section( 'file_integrity' );
6854 }
6855 $enabled = Vigilante_Self_Integrity::is_on();
6856 $state = Vigilante_Self_Integrity::display_state();
6857 $findings = Vigilante_Self_Integrity::state_findings( $state, $enabled );
6858 $groups = $this->self_findings_by_case( $findings );
6859 // Whether any case on screen is one a clean copy of the plugin fixes,
6860 // which is what the guidance marks as repair: the notice only sends
6861 // to whoever can repair then. Tasks, removed hooks, a filter and a
6862 // check that has not run for days are not.
6863 $about_files = false;
6864 foreach ( $groups as $group ) {
6865 if ( ! empty( $group['repair'] ) ) {
6866 $about_files = true;
6867 break;
6868 }
6869 }
6870 $summary = array(
6871 'state' => $state,
6872 'findings' => $findings,
6873 'groups' => $groups,
6874 'about_files' => $about_files,
6875 'tone' => Vigilante_Self_Integrity::tone( $state, $enabled ),
6876 'enabled' => $enabled,
6877 // What the alarm is about (files, off, hooks or tasks), so no
6878 // text says that files changed when that is not what was found.
6879 'kind' => Vigilante_Self_Integrity::alarm_kind( $state, $enabled ),
6880 'tasks' => Vigilante_Self_Integrity::alarm_is_about_tasks( $state, $enabled ),
6881 );
6882 $cached = $summary;
6883 return $summary;
6884 }
6885
6886 private function render_self_protection_box( $fi_options ) {
6887 $summary = $this->self_integrity_summary( $fi_options );
6888 $enabled = $summary['enabled'];
6889 $state = $summary['state'];
6890 $findings = $summary['findings'];
6891 $groups = $summary['groups'];
6892 $tone = $summary['tone'];
6893 $total = isset( $state['last_findings_total'] ) ? (int) $state['last_findings_total'] : count( $findings );
6894 $status = array(
6895 'status' => isset( $state['last_status'] ) ? (string) $state['last_status'] : '',
6896 'files' => isset( $state['files_checked'] ) ? (int) $state['files_checked'] : 0,
6897 'anchors' => ( isset( $state['anchors'] ) && is_array( $state['anchors'] ) ) ? $state['anchors'] : array(),
6898 'enabled' => $enabled,
6899 'has_run' => ! empty( $state['last_check'] ),
6900 );
6901
6902 /*
6903 * Nothing to do, nothing to open: one line. A card with a big icon and
6904 * folded sections for "everything is fine" is furniture, and furniture
6905 * is what made the previous version of this invisible.
6906 */
6907 if ( empty( $groups ) ) {
6908 $this->render_self_line( $tone, $state, $status );
6909 return;
6910 }
6911
6912 $datetime_format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' );
6913 $context_label = $this->self_context_label( isset( $state['last_context'] ) ? $state['last_context'] : '' );
6914 $icon = ( 'critical' === $tone ) ? 'dashicons-shield' : 'dashicons-warning';
6915 ?>
6916 <div id="vigilante-section-fi-self" class="vigilante-settings-section vigilante-self-box vigilante-self-box--<?php echo esc_attr( $tone ); ?>">
6917 <div class="vigilante-self-card">
6918 <div class="vigilante-self-head">
6919 <span class="vigilante-self-icon"><span class="dashicons <?php echo esc_attr( $icon ); ?>" aria-hidden="true"></span></span>
6920 <div class="vigilante-self-head-text">
6921 <h2><?php echo esc_html( $this->self_integrity_headline( $tone, $state ) ); ?></h2>
6922 <p class="vigilante-self-meta">
6923 <?php
6924 printf(
6925 /* translators: %s: date and time of the last self-check */
6926 esc_html__( 'Checked on %s', 'vigilante' ),
6927 esc_html( wp_date( $datetime_format, (int) $state['last_check'] ) )
6928 );
6929 if ( '' !== $context_label ) {
6930 echo ', ' . esc_html( $context_label );
6931 }
6932 if ( $status['files'] > 0 ) {
6933 echo ', ';
6934 printf(
6935 /* translators: 1: number of files checked, 2: number of references available, out of three */
6936 esc_html__( '%1$d files against %2$d of 3 references', 'vigilante' ),
6937 (int) $status['files'],
6938 (int) count( array_filter( $status['anchors'] ) )
6939 );
6940 }
6941 ?>
6942 </p>
6943 </div>
6944 </div>
6945
6946 <div class="vigilante-self-cases">
6947 <?php
6948 $first = true;
6949 /*
6950 * Reinstalling fixes files, not a filter someone wrote, a hook
6951 * someone removed or a scheduled task that went missing: the
6952 * button only appears when at least one of the cases on screen
6953 * is one a clean copy solves. Offering it next to "switched off
6954 * by code" was telling the person to fix something else. And
6955 * when every case is about scheduled tasks there is nothing to
6956 * repair, so the note about repairing by hand stays out too.
6957 */
6958 $vg_offer_repair = false;
6959 $vg_scan_helps = false;
6960 foreach ( $groups as $group ) {
6961 $vg_is_task = ( 0 === strpos( (string) $group['key'], 'cron_' ) );
6962 $vg_unit = 'file';
6963 if ( $vg_is_task ) {
6964 $vg_unit = 'task';
6965 } elseif ( 'hooks_removed' === $group['key'] ) {
6966 $vg_unit = 'hook';
6967 }
6968 $this->render_self_case( $group, $group['files'], $group['severity'], $first, $vg_unit );
6969 $first = false;
6970 if ( ! empty( $group['repair'] ) ) {
6971 $vg_offer_repair = true;
6972 }
6973 // A scan checks files: it clears nothing about a task, which
6974 // closes on its own, nor about removed hooks, which the next
6975 // admin page looks for again.
6976 if ( ! $vg_is_task && 'hooks_removed' !== $group['key'] ) {
6977 $vg_scan_helps = true;
6978 }
6979 }
6980
6981 // How to replace the files by hand is for a case a clean copy fixes.
6982 if ( $vg_offer_repair && in_array( $tone, array( 'critical', 'warning' ), true ) ) {
6983 $can_repair = class_exists( 'Vigilante_Self_Repair' ) && Vigilante_Self_Repair::can_repair();
6984 if ( ! $can_repair ) {
6985 if ( class_exists( 'Vigilante_Self_Repair' ) && ! Vigilante_Self_Repair::folder_is_the_distributed_one() ) {
6986 $vg_reason = 'renamed';
6987 } elseif ( ! wp_is_file_mod_allowed( 'capability_update_core' ) ) {
6988 // With DISALLOW_FILE_MODS nobody can do it from the admin,
6989 // not even a network administrator: telling a subsite admin
6990 // to ask theirs would send them to someone equally blocked.
6991 $vg_reason = 'no_caps';
6992 } elseif ( is_multisite() && ! current_user_can( 'update_plugins' ) ) {
6993 $vg_reason = 'network';
6994 } else {
6995 $vg_reason = 'no_caps';
6996 }
6997 $this->render_self_case(
6998 array(
6999 'title' => __( 'How to repair it here', 'vigilante' ),
7000 'meaning' => __( 'This site does not let Vigilant replace its own files from this screen.', 'vigilante' ),
7001 'steps' => Vigilante_Self_Integrity_Guidance::manual_steps( $vg_reason ),
7002 ),
7003 array(),
7004 '',
7005 false
7006 );
7007 }
7008 }
7009 ?>
7010 </div>
7011
7012 <?php // Switched off, the box only holds that case: the stored total is of the last check that ran. ?>
7013 <?php if ( $enabled && $total > count( $findings ) ) : ?>
7014 <p class="description">
7015 <?php
7016 printf(
7017 /* translators: 1: findings shown, 2: findings found in total */
7018 esc_html__( 'Showing %1$d of %2$d findings: the rest are of the same kind.', 'vigilante' ),
7019 (int) count( $findings ),
7020 (int) $total
7021 );
7022 ?>
7023 </p>
7024 <?php endif; ?>
7025
7026 <p class="vigilante-self-actions">
7027 <?php if ( $vg_offer_repair && class_exists( 'Vigilante_Self_Repair' ) && Vigilante_Self_Repair::can_repair() ) : ?>
7028 <a class="button button-primary" href="<?php echo esc_url( Vigilante_Self_Repair::action_url() ); ?>">
7029 <?php esc_html_e( 'Repair Vigilant', 'vigilante' ); ?>
7030 </a>
7031 <span class="description">
7032 <?php esc_html_e( 'It downloads a clean copy from WordPress.org, asks before changing anything, and keeps your settings and log.', 'vigilante' ); ?>
7033 </span>
7034 <?php elseif ( $vg_scan_helps ) : ?>
7035 <span class="description">
7036 <?php esc_html_e( 'After fixing it, run a new scan with the Run Scan Now button above.', 'vigilante' ); ?>
7037 </span>
7038 <?php endif; ?>
7039 </p>
7040 </div>
7041 </div>
7042 <?php
7043 }
7044
7045 /**
7046 * The same state when there is nothing to act on: one line inside the same
7047 * card, so the first block of the results is always the same object.
7048 *
7049 * @param string $tone Tone from Vigilante_Self_Integrity::tone().
7050 * @param array $state State.
7051 * @param array $status Arguments for the guidance catalogue.
7052 */
7053 private function render_self_line( $tone, $state, $status ) {
7054 $guidance = Vigilante_Self_Integrity_Guidance::for_status( $status );
7055 $icon = ( 'ok' === $tone ) ? 'dashicons-yes-alt' : 'dashicons-shield';
7056 ?>
7057 <div id="vigilante-section-fi-self" class="vigilante-settings-section vigilante-self-box vigilante-self-box--<?php echo esc_attr( $tone ); ?> vigilante-self-box--quiet">
7058 <div class="vigilante-self-card vigilante-self-card--quiet">
7059 <p class="vigilante-self-line">
7060 <span class="dashicons <?php echo esc_attr( $icon ); ?>" aria-hidden="true"></span>
7061 <strong><?php esc_html_e( 'Vigilant self-protection:', 'vigilante' ); ?></strong>
7062 <span class="vigilante-self-line-state"><?php echo esc_html( $this->self_integrity_headline( $tone, $state ) ); ?></span>
7063 <?php if ( ! empty( $state['last_check'] ) ) : ?>
7064 <span class="vigilante-self-line-meta">
7065 <?php
7066 printf(
7067 /* translators: %s: human time difference, like "2 hours" */
7068 esc_html__( 'checked %s ago', 'vigilante' ),
7069 esc_html( human_time_diff( (int) $state['last_check'], time() ) )
7070 );
7071 ?>
7072 </span>
7073 <?php endif; ?>
7074 </p>
7075 <?php if ( ! empty( $guidance['steps'] ) && 'none' === $tone ) : ?>
7076 <p class="description vigilante-self-line-help"><?php echo esc_html( $guidance['steps'][0] ); ?></p>
7077 <?php endif; ?>
7078 </div>
7079 </div>
7080 <?php
7081 }
7082
7083 /**
7084 * One case of the self-protection box, as a row that opens: what it is, how
7085 * many files, its severity, and inside, the files, what it means and what to
7086 * do. Plain HTML details, so there is no JavaScript between a finding about
7087 * the security plugin and the person reading it.
7088 *
7089 * @param array $guidance Guidance entry (title, meaning, steps).
7090 * @param array $files Paths this case was found in.
7091 * @param string $severity critical|warning, empty for the how-to-repair row.
7092 * @param bool $open Whether the row starts open.
7093 * @param string $unit What is being counted, file, task or hook: a
7094 * scheduled task and a removed hook were counted
7095 * as "1 file" until 3.0.4.
7096 */
7097 private function render_self_case( $guidance, $files = array(), $severity = '', $open = false, $unit = 'file' ) {
7098 $shown = array_slice( (array) $files, 0, 20 );
7099 $hidden = count( (array) $files ) - count( $shown );
7100 ?>
7101 <details class="vigilante-self-case vigilante-self-case--<?php echo esc_attr( '' !== $severity ? $severity : 'plain' ); ?>"<?php echo $open ? ' open' : ''; ?>>
7102 <summary>
7103 <span class="vigilante-self-case-title"><?php echo esc_html( $guidance['title'] ); ?></span>
7104 <?php if ( ! empty( $files ) ) : ?>
7105 <span class="vigilante-self-case-count">
7106 <?php
7107 if ( 'task' === $unit ) {
7108 printf(
7109 /* translators: %d: number of scheduled tasks of this finding */
7110 esc_html( _n( '%d task', '%d tasks', count( (array) $files ), 'vigilante' ) ),
7111 (int) count( (array) $files )
7112 );
7113 } elseif ( 'hook' === $unit ) {
7114 printf(
7115 /* translators: %d: number of removed hooks of this finding */
7116 esc_html( _n( '%d hook', '%d hooks', count( (array) $files ), 'vigilante' ) ),
7117 (int) count( (array) $files )
7118 );
7119 } else {
7120 printf(
7121 /* translators: %d: number of files of this finding */
7122 esc_html( _n( '%d file', '%d files', count( (array) $files ), 'vigilante' ) ),
7123 (int) count( (array) $files )
7124 );
7125 }
7126 ?>
7127 </span>
7128 <?php endif; ?>
7129 <?php if ( '' !== $severity ) : ?>
7130 <span class="vigilante-self-case-severity">
7131 <?php echo esc_html( 'critical' === $severity ? __( 'critical', 'vigilante' ) : __( 'warning', 'vigilante' ) ); ?>
7132 </span>
7133 <?php endif; ?>
7134 </summary>
7135 <div class="vigilante-self-case-body">
7136 <?php if ( ! empty( $shown ) ) : ?>
7137 <ul class="vigilante-self-paths">
7138 <?php foreach ( $shown as $file ) : ?>
7139 <li><code><?php echo esc_html( $file ); ?></code></li>
7140 <?php endforeach; ?>
7141 <?php if ( $hidden > 0 ) : ?>
7142 <li>
7143 <?php
7144 printf(
7145 /* translators: %d: number of additional files */
7146 esc_html__( 'and %d more', 'vigilante' ),
7147 (int) $hidden
7148 );
7149 ?>
7150 </li>
7151 <?php endif; ?>
7152 </ul>
7153 <?php endif; ?>
7154 <p class="vigilante-self-meaning">
7155 <strong><?php esc_html_e( 'What it means:', 'vigilante' ); ?></strong>
7156 <?php echo esc_html( $guidance['meaning'] ); ?>
7157 </p>
7158 <?php if ( ! empty( $guidance['steps'] ) ) : ?>
7159 <p class="vigilante-self-todo"><strong><?php esc_html_e( 'What to do:', 'vigilante' ); ?></strong></p>
7160 <ol class="vigilante-self-steps">
7161 <?php foreach ( $guidance['steps'] as $step ) : ?>
7162 <li><?php echo esc_html( $step ); ?></li>
7163 <?php endforeach; ?>
7164 </ol>
7165 <?php endif; ?>
7166 </div>
7167 </details>
7168 <?php
7169 }
7170
7171 /**
7172 * Print what each kind of finding in a list usually is and what to do
7173 *
7174 * One line per kind actually present in the list, from the single source
7175 * of that text, Vigilante_File_Integrity::finding_guidance().
7176 *
7177 * @param array $items Findings of one section of the last scan.
7178 */
7179 private function render_fi_guidance( $items ) {
7180 $seen = array();
7181 $guides = array();
7182
7183 foreach ( (array) $items as $item ) {
7184 $type = is_array( $item ) ? (string) ( $item['type'] ?? '' ) : '';
7185
7186 if ( '' === $type || 'vigilante_self' === $type || isset( $seen[ $type ] ) ) {
7187 continue;
7188 }
7189 $seen[ $type ] = true;
7190
7191 $guide = Vigilante_File_Integrity::finding_guidance( $type );
7192
7193 if ( '' !== $guide['advice'] ) {
7194 // A plugin and a theme share their text: one line for both,
7195 // under the two names.
7196 $guides[ $guide['advice'] ][] = $guide['label'];
7197 }
7198 }
7199
7200 if ( empty( $guides ) ) {
7201 return;
7202 }
7203 ?>
7204 <ul class="vigilante-fi-guidance" style="margin: 8px 0 12px 18px; list-style: disc;">
7205 <?php foreach ( $guides as $advice => $labels ) : ?>
7206 <li><strong><?php echo esc_html( implode( ', ', $labels ) ); ?>:</strong> <?php echo esc_html( $advice ); ?></li>
7207 <?php endforeach; ?>
7208 </ul>
7209 <?php
7210 }
7211
7212 private function render_tab_file_integrity() {
7213 $is_disabled = $this->render_module_disabled_notice( 'file_integrity' );
7214 $options = $this->settings->get_section( 'file_integrity' );
7215 // On the main site of a network the critical-file scan is the network's
7216 // canary for a change to wp-config.php or the root .htaccess, so a
7217 // main-site admin without network rights cannot turn it off. Since
7218 // 2.11.8; see Vigilante_Settings::get_main_site_file_settings().
7219 $vg_main_locked = $this->main_site_files_locked();
7220 $last_scan = get_option( 'vigilante_last_integrity_scan' );
7221 $last_results = get_option( 'vigilante_last_integrity_results' );
7222 $ignored_files = get_option( 'vigilante_ignored_files', array() );
7223
7224 /*
7225 * Vigilant own findings are shown in their own box, the first of the
7226 * results, with what each one means and how to fix it. They are taken
7227 * out of the generic tables here (and out of the counters above them)
7228 * so the same finding is not reported twice and so no row of the
7229 * security plugin's own files offers an Ignore button. The stored
7230 * results keep them: the scan email reads its own section from there.
7231 */
7232 if ( is_array( $last_results ) ) {
7233 foreach ( array( 'modified', 'suspicious', 'extra', 'missing' ) as $vg_bucket ) {
7234 if ( empty( $last_results[ $vg_bucket ] ) || ! is_array( $last_results[ $vg_bucket ] ) ) {
7235 continue;
7236 }
7237 $last_results[ $vg_bucket ] = array_values(
7238 array_filter(
7239 $last_results[ $vg_bucket ],
7240 function ( $vg_item ) {
7241 return ! ( is_array( $vg_item ) && 'vigilante_self' === ( $vg_item['type'] ?? '' ) );
7242 }
7243 )
7244 );
7245 }
7246 }
7247
7248 // Backward compat: convert old notify_on_changes to notify_level
7249 $notify_level = $options['notify_level'] ?? '';
7250 if ( empty( $notify_level ) ) {
7251 $notify_level = ! empty( $options['notify_on_changes'] ) ? 'all' : 'disabled';
7252 }
7253
7254 // Closed + Removed plugins data. Surfaced inside Last Scan Results so the
7255 // user sees file findings and plugin closures together (same tier of risk,
7256 // same UI), and as the trigger to keep Last Scan Results open even when no
7257 // file scan has run yet (the daily cron may have populated this section).
7258 //
7259 // Gating by last_check_time > 0 is how "Clear Previous Results" visually
7260 // resets this block: the option vigilante_plugin_status_last_check is
7261 // deleted on Clear, but the state map and the ignored list survive so the
7262 // next scan reconstructs without degrading a 'removed' slug. While
7263 // last_check is 0, we treat the plugin_status data as if it didn't exist.
7264 if ( ! class_exists( 'Vigilante_Plugin_Status' ) ) {
7265 require_once VIGILANTE_INCLUDES_DIR . 'class-plugin-status.php';
7266 }
7267 $closed_checker = new Vigilante_Plugin_Status( $this->settings, $this->activity_log );
7268 $closed_last_check = $closed_checker->get_last_check_time();
7269 if ( $closed_last_check > 0 ) {
7270 $closed_plugins = $closed_checker->get_closed_plugins();
7271 $ignored_closed_plugins = $closed_checker->get_ignored_closed_plugins();
7272 } else {
7273 $closed_plugins = array();
7274 $ignored_closed_plugins = array();
7275 }
7276 $has_closed = ! empty( $closed_plugins );
7277 $datetime_format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' );
7278 ?>
7279 <form class="vigilante-settings-form <?php echo $is_disabled ? 'vigilante-form-disabled' : ''; ?>" data-section="file_integrity" <?php echo $is_disabled ? 'inert' : ''; ?>>
7280 <div id="vigilante-section-fi-monitoring" class="vigilante-settings-section">
7281 <h2>
7282 <?php esc_html_e( 'File Integrity Monitoring', 'vigilante' ); ?>
7283 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
7284 </h2>
7285 <p><?php esc_html_e( 'Compares WordPress, and the plugins and themes that come from WordPress.org, with the official copy of each file, looks for code where there should be none (uploads and the site root) and watches wp-config.php and .htaccess for changes.', 'vigilante' ); ?></p>
7286 <p class="description"><?php esc_html_e( 'What it cannot compare: plugins and themes that are not on WordPress.org, must-use plugins, drop-ins (advanced-cache.php, object-cache.php and the like) and the other folders of wp-content have no official copy. They are searched for hidden code, which finds malware that disguises itself and misses most code written in the open. The results name what could not be compared.', 'vigilante' ); ?></p>
7287
7288 <table class="form-table">
7289 <tr>
7290 <th scope="row"><?php esc_html_e( 'Automatic Scans', 'vigilante' ); ?></th>
7291 <td>
7292 <label>
7293 <input type="checkbox" name="file_integrity[auto_scan]" value="1" <?php checked( ! empty( $options['auto_scan'] ) ); ?>>
7294 <?php esc_html_e( 'Enable scheduled file integrity scans', 'vigilante' ); ?>
7295 </label>
7296 </td>
7297 </tr>
7298 <tr>
7299 <th scope="row"><label for="vigilante-f-file-integrity-scan-frequency"><?php esc_html_e( 'Scan Frequency', 'vigilante' ); ?></label></th>
7300 <td>
7301 <select id="vigilante-f-file-integrity-scan-frequency" name="file_integrity[scan_frequency]">
7302 <option value="daily" <?php selected( $options['scan_frequency'] ?? 'daily', 'daily' ); ?>><?php esc_html_e( 'Daily', 'vigilante' ); ?></option>
7303 <option value="weekly" <?php selected( $options['scan_frequency'] ?? 'daily', 'weekly' ); ?>><?php esc_html_e( 'Weekly', 'vigilante' ); ?></option>
7304 </select>
7305 </td>
7306 </tr>
7307 <tr>
7308 <th scope="row"><label for="vigilante-f-file-integrity-notify-level"><?php esc_html_e( 'Email Notifications', 'vigilante' ); ?></label></th>
7309 <td>
7310 <select id="vigilante-f-file-integrity-notify-level" name="file_integrity[notify_level]">
7311 <option value="all" <?php selected( $notify_level, 'all' ); ?>><?php esc_html_e( 'Every finding, modified files included', 'vigilante' ); ?></option>
7312 <option value="suspicious_only" <?php selected( $notify_level, 'suspicious_only' ); ?>><?php esc_html_e( 'Serious findings only', 'vigilante' ); ?></option>
7313 <option value="disabled" <?php selected( $notify_level, 'disabled' ); ?>><?php esc_html_e( 'Disabled', 'vigilante' ); ?></option>
7314 </select>
7315 <p class="description"><?php esc_html_e( 'Serious findings are suspicious and extra files, changes in wp-config.php or .htaccess and closed plugins. That is the recommended choice: a file that is only modified is rarely more than an update that did not finish. The email goes out when a scan finds something new: a finding you were already told about is not sent again, unless the file changes once more.', 'vigilante' ); ?></p>
7316 </td>
7317 </tr>
7318 <tr>
7319 <th scope="row"><?php esc_html_e( 'Instant Alert', 'vigilante' ); ?></th>
7320 <td>
7321 <label>
7322 <input type="checkbox" name="file_integrity[instant_alert]" value="1" <?php checked( ! empty( $options['instant_alert'] ) ); ?>>
7323 <?php esc_html_e( 'Email every finding when a scan ends, modified files included, and email a closed plugin as soon as the daily check finds it', 'vigilante' ); ?>
7324 </label>
7325 <p class="description"><?php esc_html_e( 'This takes precedence over the choice above, Disabled included.', 'vigilante' ); ?></p>
7326 <p class="description">
7327 <?php
7328 printf(
7329 /* translators: %s: Link to notification settings */
7330 esc_html__( '&#9432; Notifications are sent to the recipients configured in %s.', 'vigilante' ),
7331 '<a href="' . esc_url( admin_url( 'admin.php?page=vigilante&tab=tools' ) ) . '">' . esc_html__( 'Settings & Tools', 'vigilante' ) . '</a>'
7332 );
7333 ?>
7334 </p>
7335 </td>
7336 </tr>
7337 <tr>
7338 <th scope="row"><?php esc_html_e( 'Test email', 'vigilante' ); ?></th>
7339 <td>
7340 <button type="button" class="button vigilante-test-email-btn" data-original-text="<?php esc_attr_e( 'Send test email', 'vigilante' ); ?>">
7341 <?php esc_html_e( 'Send test email', 'vigilante' ); ?>
7342 </button>
7343 <span class="vigilante-test-email-result" style="margin-left:8px;"></span>
7344 <p class="description"><?php esc_html_e( 'Sends a test message to the configured recipients to confirm email delivery works.', 'vigilante' ); ?></p>
7345 </td>
7346 </tr>
7347 <tr>
7348 <th scope="row"><?php esc_html_e( 'Scan Scope', 'vigilante' ); ?></th>
7349 <td>
7350 <fieldset>
7351 <label>
7352 <input type="checkbox" name="file_integrity[scan_core]" value="1" <?php checked( $options['scan_core'] ?? true ); ?>>
7353 <?php esc_html_e( 'WordPress core (compared with WordPress.org) and the other files in the site root', 'vigilante' ); ?>
7354 </label>
7355 <br>
7356 <label>
7357 <input type="checkbox" name="file_integrity[scan_plugins]" value="1" <?php checked( $options['scan_plugins'] ?? true ); ?>>
7358 <?php esc_html_e( 'Plugins (compared with WordPress.org; the rest, and the other folders of wp-content, are only searched for hidden code)', 'vigilante' ); ?>
7359 </label>
7360 <br>
7361 <label>
7362 <input type="checkbox" name="file_integrity[scan_themes]" value="1" <?php checked( $options['scan_themes'] ?? true ); ?>>
7363 <?php esc_html_e( 'Themes (compared with WordPress.org; the rest are only searched for hidden code)', 'vigilante' ); ?>
7364 </label>
7365 <br>
7366 <label>
7367 <input type="checkbox" name="file_integrity[scan_uploads]" value="1" <?php checked( $options['scan_uploads'] ?? true ); ?>>
7368 <?php esc_html_e( 'Uploads directory (PHP and other scripts, double extensions, .htaccess and PHP configuration files)', 'vigilante' ); ?>
7369 </label>
7370 <br>
7371 <label>
7372 <input type="checkbox" name="file_integrity[scan_critical_config]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( $options['scan_critical_config'] ?? true ); ?>>
7373 <?php esc_html_e( 'wp-config.php and .htaccess (any change since you last approved them)', 'vigilante' ); ?>
7374 <?php if ( $vg_main_locked ) : ?>
7375 <span class="description" style="display:block;margin-left:24px;"><?php echo esc_html( Vigilante_Settings::get_shared_files_notice() ); ?></span>
7376 <?php endif; ?>
7377 </label>
7378 <br>
7379 <label>
7380 <input type="checkbox" name="file_integrity[check_closed_plugins]" value="1" <?php checked( $options['check_closed_plugins'] ?? true ); ?>>
7381 <?php esc_html_e( 'Closed plugins (daily check against the WordPress.org repository)', 'vigilante' ); ?>
7382 </label>
7383 </fieldset>
7384 </td>
7385 </tr>
7386 <tr>
7387 <th scope="row"><label for="vigilante-f-file-integrity-excluded-paths"><?php esc_html_e( 'Excluded Paths', 'vigilante' ); ?></label></th>
7388 <td>
7389 <textarea id="vigilante-f-file-integrity-excluded-paths" name="file_integrity[excluded_paths]" rows="4" class="large-text code" placeholder="wp-content/backups"><?php echo esc_textarea( implode( "\n", $options['excluded_paths'] ?? array() ) ); ?></textarea>
7390 <p class="description"><?php esc_html_e( 'One path per line, relative to the WordPress root. A path such as wp-content/backups excludes exactly that folder and everything under it. A name on its own, such as backups, excludes any folder called exactly that, wherever it is. Nothing in an excluded folder is looked at, so keep the list short.', 'vigilante' ); ?></p>
7391 </td>
7392 </tr>
7393 <tr>
7394 <th scope="row"><label for="vigilante-f-file-integrity-excluded-extensions"><?php esc_html_e( 'Excluded Extensions', 'vigilante' ); ?></label></th>
7395 <td>
7396 <textarea id="vigilante-f-file-integrity-excluded-extensions" name="file_integrity[excluded_extensions]" rows="3" class="large-text code" placeholder=".log&#10;.po&#10;.mo&#10;.pot"><?php echo esc_textarea( implode( "\n", $options['excluded_extensions'] ?? array() ) ); ?></textarea>
7397 <p class="description">
7398 <?php esc_html_e( 'One extension per line (e.g. .log, .po, .mo). Files with these extensions will be skipped. Useful to avoid false positives from translation or log files.', 'vigilante' ); ?>
7399 <br>
7400 <?php
7401 printf(
7402 /* translators: 1: opening <code>, 2: closing </code>. Placeholders wrap the scoped-extension example. */
7403 esc_html__( 'An extension on its own applies to the whole site. To limit it to one folder, write it as %1$swp-content/languages/*.json%2$s, which leaves the same extension watched everywhere else.', 'vigilante' ),
7404 '<code>',
7405 '</code>'
7406 ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML tags are hardcoded.
7407 ?>
7408 </p>
7409 </td>
7410 </tr>
7411 </table>
7412 </div>
7413
7414 <p class="submit vigilante-submit-buttons">
7415 <button type="submit" class="button button-primary vigilante-save-btn" data-original-text="<?php esc_attr_e( 'Save Settings', 'vigilante' ); ?>">
7416 <?php esc_html_e( 'Save Settings', 'vigilante' ); ?>
7417 </button>
7418 <button type="button" class="button vigilante-reset-section-btn" data-original-text="<?php esc_attr_e( 'Reset to Defaults', 'vigilante' ); ?>">
7419 <?php esc_html_e( 'Reset to Defaults', 'vigilante' ); ?>
7420 </button>
7421 <span class="vigilante-buttons-separator"></span>
7422 <button type="button" class="button button-primary vigilante-run-scan">
7423 <?php esc_html_e( 'Run Scan Now', 'vigilante' ); ?>
7424 </button>
7425 <button type="button" class="button vigilante-clear-scan-btn vigilante-clear-scan">
7426 <?php esc_html_e( 'Clear Previous Results', 'vigilante' ); ?>
7427 </button>
7428 </p>
7429 </form>
7430
7431 <div id="vigilante-scan-results" class="vigilante-settings-section" style="display:none;"></div>
7432
7433 <?php $this->render_self_protection_box( $options ); ?>
7434
7435 <?php
7436 // Whole folders of code left out of every scan. A partial scan tells
7437 // how to check the rest by excluding what it did check, and those
7438 // lines have to come out afterwards: with them in place a scan comes
7439 // out complete and clean without having looked there. Said for as
7440 // long as they are in the list, whoever put them there and why.
7441 $vg_fi_tool = new Vigilante_File_Integrity( $this->settings, $this->database );
7442 $vg_excluded_code = $vg_fi_tool->excluded_code_folders();
7443 $vg_folders_to_skip = $vg_fi_tool->folders_to_exclude( $last_results );
7444 ?>
7445 <?php if ( ! empty( $vg_excluded_code ) ) : ?>
7446 <div id="vigilante-fi-excluded-code" class="notice notice-warning inline">
7447 <p>
7448 <strong>
7449 <?php
7450 printf(
7451 /* translators: %s: Comma separated list of folders */
7452 esc_html__( 'Excluded Paths holds whole folders of plugins or themes, or the uploads folder: %s.', 'vigilante' ),
7453 esc_html( implode( ', ', $vg_excluded_code ) )
7454 );
7455 ?>
7456 </strong>
7457 <?php esc_html_e( 'No scan looks inside them, the scheduled one included, and a scan can come out complete and clean without having checked them. If you added them to check the rest of a large site, take them out once that scan is done.', 'vigilante' ); ?>
7458 </p>
7459 </div>
7460 <?php endif; ?>
7461
7462 <?php if ( $last_scan || $has_closed || $closed_last_check > 0 ) : ?>
7463 <div id="vigilante-section-fi-last-scan" class="vigilante-settings-section">
7464 <h2><?php esc_html_e( 'Last Scan Results', 'vigilante' ); ?></h2>
7465 <?php
7466 // A scan the server stops halfway stores nothing, and neither
7467 // does one WordPress never starts: the tab kept showing the
7468 // last result that did finish, with nothing to say that scans
7469 // had stopped. Its date is the only sign.
7470 if ( $last_scan && $this->settings->is_module_enabled( 'file_integrity' ) && Vigilante_File_Integrity::is_scan_overdue( $last_scan, $options['scan_frequency'] ?? 'daily' ) ) :
7471 ?>
7472 <div class="notice notice-warning inline">
7473 <p><strong><?php esc_html_e( 'Scans are scheduled, and none has finished since the date below.', 'vigilante' ); ?></strong> <?php esc_html_e( 'Either WordPress is not running its scheduled tasks, or the server stops the scan before it ends, and a scan that is stopped stores no result. What you see here is old. Run a scan now: if it finishes, the scheduled tasks are what is failing. If it does not, the site is too large for the time the server gives a request, and adding large folders that do not need checking to Excluded Paths leaves more time for the rest.', 'vigilante' ); ?></p>
7474 </div>
7475 <?php endif; ?>
7476 <?php if ( $last_scan ) : ?>
7477 <p>
7478 <?php
7479 // Build the "X files scanned" hint inline with the date so it doesn't
7480 // need its own stat box (keeps the row compact when closed plugins are
7481 // present).
7482 $scanned_total = 0;
7483 if ( $last_results ) {
7484 $scanned_total = (int) ( $last_results['ok'] ?? 0 )
7485 + count( $last_results['modified'] ?? array() )
7486 + count( $last_results['suspicious'] ?? array() )
7487 + count( $last_results['extra'] ?? array() )
7488 + count( $ignored_files );
7489 }
7490 if ( $scanned_total > 0 ) {
7491 printf(
7492 /* translators: 1: date and time of last scan, 2: formatted file count */
7493 esc_html__( 'Last scan: %1$s (%2$s files scanned)', 'vigilante' ),
7494 esc_html( wp_date( $datetime_format, $last_scan ) ),
7495 esc_html( number_format_i18n( $scanned_total ) )
7496 );
7497 } else {
7498 printf(
7499 /* translators: %s: date and time of last scan */
7500 esc_html__( 'Last scan: %s', 'vigilante' ),
7501 esc_html( wp_date( $datetime_format, $last_scan ) )
7502 );
7503 }
7504 if ( $closed_last_check > 0 && $closed_last_check !== (int) $last_scan ) {
7505 echo ' &middot; ';
7506 printf(
7507 /* translators: %s: date and time of last closed plugins check */
7508 esc_html__( 'Closed plugins last checked: %s', 'vigilante' ),
7509 esc_html( wp_date( $datetime_format, $closed_last_check ) )
7510 );
7511 }
7512 ?>
7513 </p>
7514 <?php elseif ( $closed_last_check > 0 ) : ?>
7515 <p>
7516 <?php
7517 printf(
7518 /* translators: %s: date and time of last closed plugins check */
7519 esc_html__( 'Closed plugins last checked: %s &middot; the daily cron is running, no full integrity scan yet.', 'vigilante' ),
7520 esc_html( wp_date( $datetime_format, $closed_last_check ) )
7521 );
7522 ?>
7523 </p>
7524 <?php endif; ?>
7525 <div id="vigilante-last-scan-results">
7526 <?php if ( $last_results || $has_closed ) : ?>
7527 <div class="vigilante-scan-summary">
7528 <?php if ( $last_results ) : ?>
7529 <div class="vigilante-scan-stat vigilante-stat-ok">
7530 <span class="vigilante-stat-number"><?php echo esc_html( $last_results['ok'] ?? 0 ); ?></span>
7531 <span class="vigilante-stat-label"><?php esc_html_e( 'OK', 'vigilante' ); ?></span>
7532 </div>
7533 <div class="vigilante-scan-stat vigilante-stat-modified">
7534 <span class="vigilante-stat-number"><?php echo esc_html( count( $last_results['modified'] ?? array() ) ); ?></span>
7535 <span class="vigilante-stat-label"><?php esc_html_e( 'Modified', 'vigilante' ); ?></span>
7536 </div>
7537 <div class="vigilante-scan-stat vigilante-stat-suspicious">
7538 <span class="vigilante-stat-number"><?php echo esc_html( count( $last_results['suspicious'] ?? array() ) ); ?></span>
7539 <span class="vigilante-stat-label"><?php esc_html_e( 'Suspicious', 'vigilante' ); ?></span>
7540 </div>
7541 <div class="vigilante-scan-stat vigilante-stat-extra">
7542 <span class="vigilante-stat-number"><?php echo esc_html( count( $last_results['extra'] ?? array() ) ); ?></span>
7543 <span class="vigilante-stat-label"><?php esc_html_e( 'Extra', 'vigilante' ); ?></span>
7544 </div>
7545 <?php endif; ?>
7546 <?php if ( $has_closed ) : ?>
7547 <div class="vigilante-scan-stat vigilante-stat-suspicious">
7548 <span class="vigilante-stat-number" style="color: #d63638;"><?php echo (int) count( $closed_plugins ); ?></span>
7549 <span class="vigilante-stat-label"><?php esc_html_e( 'Closed/Removed', 'vigilante' ); ?></span>
7550 </div>
7551 <?php endif; ?>
7552 <?php if ( ! empty( $ignored_files ) ) : ?>
7553 <div class="vigilante-scan-stat vigilante-stat-ignored">
7554 <span class="vigilante-stat-number"><?php echo esc_html( count( $ignored_files ) ); ?></span>
7555 <span class="vigilante-stat-label"><?php esc_html_e( 'Ignored', 'vigilante' ); ?></span>
7556 </div>
7557 <?php endif; ?>
7558 </div>
7559
7560 <?php
7561 // A scan that could not finish, or could not get the official
7562 // copy to compare with, checked less than it seems. Until now
7563 // its errors were stored and never shown, and the tab could
7564 // say that every file had passed.
7565 $vg_scan_errors = ( $last_results && ! empty( $last_results['errors'] ) && is_array( $last_results['errors'] ) ) ? $last_results['errors'] : array();
7566 $vg_scan_partial = ! empty( $vg_scan_errors ) || ( $last_results && ! empty( $last_results['incomplete'] ) );
7567
7568 // Files of the official copies that are not on the server. Core
7569 // files under wp-content are left out: results stored before
7570 // the bundled themes and plugins got their own turn list
7571 // there the ones people delete on purpose.
7572 $vg_missing_core = array();
7573 if ( $last_results && ! empty( $last_results['missing'] ) && is_array( $last_results['missing'] ) ) {
7574 foreach ( $last_results['missing'] as $item ) {
7575 if ( ! is_array( $item ) || ! isset( $item['file'] ) ) {
7576 continue;
7577 }
7578
7579 $vg_missing_type = (string) ( $item['type'] ?? '' );
7580
7581 if ( in_array( $vg_missing_type, array( 'plugin', 'theme' ), true )
7582 || ( 'core' === $vg_missing_type && 0 !== strpos( (string) $item['file'], 'wp-content/' ) ) ) {
7583 $vg_missing_core[] = (string) $item['file'];
7584 }
7585 }
7586 }
7587 ?>
7588
7589 <?php if ( $last_results && ! empty( $last_results['unverified'] ) && is_array( $last_results['unverified'] ) ) : ?>
7590 <p class="description">
7591 <?php
7592 printf(
7593 /* translators: %s: Comma separated list of plugin and theme names */
7594 esc_html__( 'No official copy to compare with, so only searched for hidden code: %s.', 'vigilante' ),
7595 esc_html( implode( ', ', array_unique( array_map( 'strval', $last_results['unverified'] ) ) ) )
7596 );
7597 ?>
7598 </p>
7599 <?php endif; ?>
7600
7601 <?php
7602 // What the scan did not get to, by name. A scan starts from
7603 // the beginning every time, so on a site too large for it
7604 // these are the same on every scan, and whoever reads the
7605 // result has to know that nothing here was looked at.
7606 $vg_scan_notes = array_merge( $vg_scan_errors, Vigilante_File_Integrity::not_checked_lines( $last_results ) );
7607 ?>
7608 <?php if ( $vg_scan_partial ) : ?>
7609 <div class="notice notice-warning inline">
7610 <p><strong><?php esc_html_e( 'The last scan could not check everything, so its result is partial.', 'vigilante' ); ?></strong></p>
7611 <?php if ( ! empty( $vg_scan_notes ) ) : ?>
7612 <ul style="margin: 0 0 8px 18px; list-style: disc;">
7613 <?php foreach ( $vg_scan_notes as $vg_scan_note ) : ?>
7614 <li><?php echo esc_html( (string) $vg_scan_note ); ?></li>
7615 <?php endforeach; ?>
7616 </ul>
7617 <?php endif; ?>
7618 <p>
7619 <?php
7620 printf(
7621 /* translators: %d: Seconds a scan runs for at most */
7622 esc_html__( 'Run the scan again: a second scan is faster, because what it downloads from WordPress.org is kept for a while. If the result is still partial, either the server cannot reach WordPress.org or the site is larger than a scan can check in its %d seconds, and then every scan leaves the same part unchecked. Adding large folders that do not need checking to Excluded Paths leaves more time for the rest.', 'vigilante' ),
7623 (int) Vigilante_File_Integrity::MAX_SCAN_SECONDS
7624 );
7625 ?>
7626 </p>
7627 <?php if ( ! empty( $vg_folders_to_skip ) ) : ?>
7628 <p><strong><?php esc_html_e( 'To check what was left out:', 'vigilante' ); ?></strong></p>
7629 <ol style="margin: 0 0 8px 18px;">
7630 <li><?php esc_html_e( 'Deal first with what this scan found in the plugins and themes it did check: the next result will not list it. Then add the lines below, which are their folders, to Excluded Paths, higher up on this page, and save.', 'vigilante' ); ?></li>
7631 <li><?php esc_html_e( 'Press Run Scan Now. The scan spends its time on the rest. If it comes out partial again, repeat with the lines it gives then.', 'vigilante' ); ?></li>
7632 <li><?php esc_html_e( 'Take those lines out of Excluded Paths. While they are there no scan looks inside those folders, the scheduled one included.', 'vigilante' ); ?></li>
7633 </ol>
7634 <textarea id="vigilante-fi-folders-to-exclude" readonly rows="<?php echo (int) min( 12, max( 3, count( $vg_folders_to_skip ) ) ); ?>" class="large-text code" style="margin-bottom: 8px;"><?php echo esc_textarea( implode( "\n", $vg_folders_to_skip ) ); ?></textarea>
7635 <?php endif; ?>
7636 </div>
7637 <?php endif; ?>
7638
7639 <?php if ( ! empty( $vg_missing_core ) ) : ?>
7640 <div class="vigilante-file-list">
7641 <h3><?php esc_html_e( 'Missing Files', 'vigilante' ); ?></h3>
7642 <p class="description"><?php esc_html_e( 'These files are in the official copy and not on the server. Reinstall what they belong to (WordPress from Dashboard > Updates, a plugin or a theme from its own screen) and scan again.', 'vigilante' ); ?></p>
7643 <ul style="margin: 8px 0 12px 18px; list-style: disc;">
7644 <?php foreach ( array_slice( $vg_missing_core, 0, 20 ) as $vg_missing_file ) : ?>
7645 <li><code><?php echo esc_html( $vg_missing_file ); ?></code></li>
7646 <?php endforeach; ?>
7647 </ul>
7648 <?php if ( count( $vg_missing_core ) > 20 ) : ?>
7649 <p class="description">
7650 <?php
7651 /* translators: %d: Number of additional files */
7652 printf( esc_html__( '... and %d more', 'vigilante' ), (int) ( count( $vg_missing_core ) - 20 ) );
7653 ?>
7654 </p>
7655 <?php endif; ?>
7656 </div>
7657 <?php endif; ?>
7658
7659 <?php if ( ! empty( $last_results['suspicious'] ) ) : ?>
7660 <div class="vigilante-file-list vigilante-suspicious-files vigilante-paginated-section" data-bulk-mode="ignore">
7661 <h3 style="color: #d63638;"><?php esc_html_e( 'Suspicious Files', 'vigilante' ); ?></h3>
7662 <p class="description" style="color: #d63638;"><?php esc_html_e( '&#9888; Files that can run code where none is expected, or that contain code written the way malware hides itself. Real infections show up here, and so do harmless files that plugins generate. Check the path and the reason before acting.', 'vigilante' ); ?></p>
7663 <?php $this->render_fi_guidance( $last_results['suspicious'] ); ?>
7664 <div class="vigilante-fi-bulk-bar">
7665 <button type="button" class="button vigilante-bulk-ignore" disabled><?php esc_html_e( 'Ignore selected', 'vigilante' ); ?></button>
7666 <span class="vigilante-fi-bulk-count" aria-live="polite"></span>
7667 </div>
7668 <div class="vigilante-fi-pagination-wrap"></div>
7669 <table class="wp-list-table widefat fixed striped vigilante-fi-paginated">
7670 <thead>
7671 <tr>
7672 <td class="manage-column column-cb check-column"><input type="checkbox" class="vigilante-fi-cb-all" aria-label="<?php esc_attr_e( 'Select all', 'vigilante' ); ?>"></td>
7673 <th><?php esc_html_e( 'File', 'vigilante' ); ?></th>
7674 <th style="width: 250px;"><?php esc_html_e( 'Reason', 'vigilante' ); ?></th>
7675 <th style="width: 120px;"><?php esc_html_e( 'Type', 'vigilante' ); ?></th>
7676 <th style="width: 80px;"><?php esc_html_e( 'Actions', 'vigilante' ); ?></th>
7677 </tr>
7678 </thead>
7679 <tbody>
7680 <?php
7681 foreach ( $last_results['suspicious'] as $item ) {
7682 $file_path = '';
7683 $file_reason = __( 'Unknown', 'vigilante' );
7684 $file_type = 'unknown';
7685
7686 if ( is_array( $item ) ) {
7687 if ( isset( $item['file'] ) ) {
7688 $file_path = $item['file'];
7689 }
7690 if ( isset( $item['reason'] ) ) {
7691 $file_reason = $item['reason'];
7692 }
7693 if ( isset( $item['type'] ) ) {
7694 $file_type = $item['type'];
7695 }
7696 } else {
7697 $file_path = (string) $item;
7698 }
7699 if ( 'vigilante_self' === $file_type ) {
7700 $file_type = __( 'Vigilant (self)', 'vigilante' );
7701 } else {
7702 // A name a person can read, instead of the internal slug.
7703 $file_type = Vigilante_File_Integrity::finding_guidance( $file_type )['label'];
7704 }
7705 ?>
7706 <tr>
7707 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
7708 <td><code style="color: #d63638;"><?php echo esc_html( $file_path ); ?></code></td>
7709 <td><?php echo esc_html( $file_reason ); ?></td>
7710 <td><?php echo esc_html( $file_type ); ?></td>
7711 <td><button type="button" class="button button-small vigilante-ignore-file" data-file="<?php echo esc_attr( $file_path ); ?>"><?php esc_html_e( 'Ignore', 'vigilante' ); ?></button></td>
7712 </tr>
7713 <?php
7714 }
7715 ?>
7716 </tbody>
7717 </table>
7718 </div>
7719 <?php endif; ?>
7720
7721 <?php if ( ! empty( $last_results['extra'] ) ) : ?>
7722 <div class="vigilante-file-list vigilante-extra-files vigilante-paginated-section" data-bulk-mode="ignore">
7723 <h3 style="color: #b32d2e;"><?php esc_html_e( 'Extra Files', 'vigilante' ); ?></h3>
7724 <p class="description"><?php esc_html_e( 'Files that are not part of WordPress, or of the plugin or theme they sit in. Many are legitimate, and an injected file looks the same from here, so confirm where each one comes from.', 'vigilante' ); ?></p>
7725 <?php $this->render_fi_guidance( $last_results['extra'] ); ?>
7726 <div class="vigilante-fi-bulk-bar">
7727 <button type="button" class="button vigilante-bulk-ignore" disabled><?php esc_html_e( 'Ignore selected', 'vigilante' ); ?></button>
7728 <span class="vigilante-fi-bulk-count" aria-live="polite"></span>
7729 </div>
7730 <div class="vigilante-fi-pagination-wrap"></div>
7731 <table class="wp-list-table widefat fixed striped vigilante-fi-paginated">
7732 <thead>
7733 <tr>
7734 <td class="manage-column column-cb check-column"><input type="checkbox" class="vigilante-fi-cb-all" aria-label="<?php esc_attr_e( 'Select all', 'vigilante' ); ?>"></td>
7735 <th><?php esc_html_e( 'File', 'vigilante' ); ?></th>
7736 <th style="width: 250px;"><?php esc_html_e( 'Reason', 'vigilante' ); ?></th>
7737 <th style="width: 120px;"><?php esc_html_e( 'Type', 'vigilante' ); ?></th>
7738 <th style="width: 80px;"><?php esc_html_e( 'Actions', 'vigilante' ); ?></th>
7739 </tr>
7740 </thead>
7741 <tbody>
7742 <?php
7743 foreach ( $last_results['extra'] as $item ) {
7744 $file_path = is_array( $item ) ? ( $item['file'] ?? '' ) : (string) $item;
7745 $file_reason = is_array( $item ) ? ( $item['reason'] ?? __( 'Unknown', 'vigilante' ) ) : __( 'Unknown', 'vigilante' );
7746 $file_type = is_array( $item ) ? ( $item['type'] ?? 'unknown' ) : 'unknown';
7747 if ( 'vigilante_self' === $file_type ) {
7748 $file_type = __( 'Vigilant (self)', 'vigilante' );
7749 } else {
7750 // A name a person can read, instead of the internal slug.
7751 $file_type = Vigilante_File_Integrity::finding_guidance( $file_type )['label'];
7752 }
7753 ?>
7754 <tr>
7755 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
7756 <td><code style="color: #b32d2e;"><?php echo esc_html( $file_path ); ?></code></td>
7757 <td><?php echo esc_html( $file_reason ); ?></td>
7758 <td><?php echo esc_html( $file_type ); ?></td>
7759 <td><button type="button" class="button button-small vigilante-ignore-file" data-file="<?php echo esc_attr( $file_path ); ?>"><?php esc_html_e( 'Ignore', 'vigilante' ); ?></button></td>
7760 </tr>
7761 <?php
7762 }
7763 ?>
7764 </tbody>
7765 </table>
7766 </div>
7767 <?php endif; ?>
7768
7769 <?php
7770 // Split critical config files from regular modified files.
7771 // Computed unconditionally so the three sub-sections that consume
7772 // these arrays (Critical Config, Closed + Removed, Modified Files)
7773 // can render independently and in the order the team picked.
7774 $critical_modified = array();
7775 $regular_modified = array();
7776 if ( $last_results && ! empty( $last_results['modified'] ) ) {
7777 foreach ( $last_results['modified'] as $item ) {
7778 if ( is_array( $item ) && isset( $item['type'] ) && 'critical_config' === $item['type'] ) {
7779 $critical_modified[] = $item;
7780 } else {
7781 $regular_modified[] = $item;
7782 }
7783 }
7784 }
7785 // A missing file of Vigilant is critical, and the missing
7786 // files of the generic scan have no table: it is listed with
7787 // the modified files, or the tab said "All files passed" with
7788 // a module deleted.
7789 if ( $last_results && ! empty( $last_results['missing'] ) && is_array( $last_results['missing'] ) ) {
7790 foreach ( $last_results['missing'] as $item ) {
7791 if ( is_array( $item ) && 'vigilante_self' === ( $item['type'] ?? '' ) ) {
7792 $regular_modified[] = $item;
7793 }
7794 }
7795 }
7796 ?>
7797
7798 <?php if ( ! empty( $critical_modified ) ) : ?>
7799 <div class="vigilante-file-list vigilante-critical-config-files">
7800 <h3 style="color: #e36210;"><?php esc_html_e( 'Critical config files modified', 'vigilante' ); ?></h3>
7801 <p class="description">
7802 <?php esc_html_e( 'These files changed since you last approved them. They are a common target for code injection, and they are also rewritten by cache, security and redirection plugins, often on every update. Review the changes and approve them if they belong to something you use: approving records the current content as the reference and does not touch the file. Vigilant\'s own blocks are excluded from this check, and Vigilant never restores these files by itself.', 'vigilante' ); ?>
7803 </p>
7804 <table class="wp-list-table widefat fixed striped">
7805 <thead>
7806 <tr>
7807 <th><?php esc_html_e( 'File', 'vigilante' ); ?></th>
7808 <th style="width: 200px;"><?php esc_html_e( 'Changes', 'vigilante' ); ?></th>
7809 <th style="width: 220px;"><?php esc_html_e( 'Actions', 'vigilante' ); ?></th>
7810 </tr>
7811 </thead>
7812 <tbody>
7813 <?php foreach ( $critical_modified as $crit_item ) :
7814 $crit_file = $crit_item['file'] ?? '';
7815 $crit_baseline_size = $crit_item['baseline_size'] ?? 0;
7816 $crit_current_size = $crit_item['current_size'] ?? 0;
7817 $crit_diff = $crit_item['diff'] ?? array();
7818 $crit_id = sanitize_html_class( $crit_file );
7819 $added_count = is_array( $crit_diff ) ? count( $crit_diff['added'] ?? array() ) : 0;
7820 $removed_count = is_array( $crit_diff ) ? count( $crit_diff['removed'] ?? array() ) : 0;
7821 // The lines of a shared file are for whoever approves it. Results
7822 // stored before 2.11.8 on the main site still carry them, so the
7823 // screen asks too, not only the scan that wrote them.
7824 $diff_network = ( is_array( $crit_diff ) && ! empty( $crit_diff['network'] ) ) || $this->critical_approval_locked();
7825 $diff_rescan = is_array( $crit_diff ) && ! empty( $crit_diff['rescan'] );
7826 $diff_redaction = is_array( $crit_diff ) && ! empty( $crit_diff['redaction'] );
7827 $diff_unavailable = $diff_network || ( is_array( $crit_diff ) && ! empty( $crit_diff['unavailable'] ) );
7828 ?>
7829 <tr>
7830 <td><code style="color: #e36210;"><?php echo esc_html( $crit_file ); ?></code></td>
7831 <td>
7832 <?php if ( ! $diff_unavailable ) : ?>
7833 <span style="color: #007017;">+<?php echo (int) $added_count; ?></span>
7834 <span style="color: #b32d2e;">-<?php echo (int) $removed_count; ?></span>
7835 <?php esc_html_e( 'lines', 'vigilante' ); ?><br>
7836 <?php endif; ?>
7837 <small style="color: #50575e;">
7838 <?php
7839 printf(
7840 /* translators: 1: baseline size, 2: current size */
7841 esc_html__( '%1$s &rarr; %2$s bytes', 'vigilante' ),
7842 esc_html( number_format_i18n( $crit_baseline_size ) ),
7843 esc_html( number_format_i18n( $crit_current_size ) )
7844 );
7845 ?>
7846 </small>
7847 </td>
7848 <td>
7849 <button type="button" class="button button-small vigilante-toggle-critical-content" data-target="vigilante-critical-content-<?php echo esc_attr( $crit_id ); ?>" data-label-show="<?php esc_attr_e( 'Review changes', 'vigilante' ); ?>" data-label-hide="<?php esc_attr_e( 'Hide changes', 'vigilante' ); ?>">
7850 <?php esc_html_e( 'Review changes', 'vigilante' ); ?>
7851 </button>
7852 <?php if ( $this->critical_approval_locked() ) : ?>
7853 <span class="description" style="display:block;margin-top:4px;">
7854 <?php echo esc_html( $this->critical_approval_notice() ); ?>
7855 </span>
7856 <?php else : ?>
7857 <button type="button" class="button button-small button-primary vigilante-approve-critical-file" data-file="<?php echo esc_attr( $crit_file ); ?>">
7858 <?php esc_html_e( 'Approve', 'vigilante' ); ?>
7859 </button>
7860 <?php endif; ?>
7861 </td>
7862 </tr>
7863 <tr id="vigilante-critical-content-<?php echo esc_attr( $crit_id ); ?>" class="vigilante-critical-content-row" style="display:none;">
7864 <td colspan="3" style="padding: 0;">
7865 <div class="vigilante-critical-content" style="max-height: 400px; overflow: auto; background: #fff; padding: 10px; font-size: 12px; line-height: 1.5; font-family: Consolas, Monaco, monospace; border-top: 1px solid #c3c4c7;">
7866 <?php if ( $diff_network ) : ?>
7867 <p style="color: #50575e; font-style: italic; margin: 0;">
7868 <?php esc_html_e( 'This file belongs to the whole network, so its line changes are only shown to network administrators, on the main site.', 'vigilante' ); ?>
7869 </p>
7870 <?php elseif ( $diff_rescan ) : ?>
7871 <p style="color: #50575e; font-style: italic; margin: 0;">
7872 <?php esc_html_e( 'Run a new scan to see the line changes of this file.', 'vigilante' ); ?>
7873 </p>
7874 <?php elseif ( $diff_redaction ) : ?>
7875 <p style="color: #50575e; font-style: italic; margin: 0;">
7876 <?php esc_html_e( 'The line changes of this file are not shown because a value in it could not be hidden safely. The change itself is still detected.', 'vigilante' ); ?>
7877 </p>
7878 <?php elseif ( $diff_unavailable ) : ?>
7879 <p style="color: #50575e; font-style: italic; margin: 0;">
7880 <?php esc_html_e( 'Diff not available for this file (baseline was created before diff tracking was added). Approve to enable diff on future changes.', 'vigilante' ); ?>
7881 </p>
7882 <?php elseif ( empty( $crit_diff['added'] ) && empty( $crit_diff['removed'] ) ) : ?>
7883 <p style="color: #50575e; font-style: italic; margin: 0;">
7884 <?php esc_html_e( 'No line-level changes detected (may be whitespace or reordering).', 'vigilante' ); ?>
7885 </p>
7886 <?php else : ?>
7887 <?php if ( ! empty( $crit_diff['removed'] ) ) : ?>
7888 <?php foreach ( $crit_diff['removed'] as $rline ) : ?>
7889 <div style="background: #fbeaea; color: #b32d2e; padding: 1px 4px; white-space: pre-wrap; word-wrap: break-word;"><span style="display: inline-block; width: 50px; color: #999; user-select: none;"><?php echo (int) $rline['line']; ?></span>- <?php echo esc_html( $rline['content'] ); ?></div>
7890 <?php endforeach; ?>
7891 <?php endif; ?>
7892 <?php if ( ! empty( $crit_diff['added'] ) ) : ?>
7893 <?php foreach ( $crit_diff['added'] as $aline ) : ?>
7894 <div style="background: #e6f4e9; color: #007017; padding: 1px 4px; white-space: pre-wrap; word-wrap: break-word;"><span style="display: inline-block; width: 50px; color: #999; user-select: none;"><?php echo (int) $aline['line']; ?></span>+ <?php echo esc_html( $aline['content'] ); ?></div>
7895 <?php endforeach; ?>
7896 <?php endif; ?>
7897 <?php endif; ?>
7898 </div>
7899 </td>
7900 </tr>
7901 <?php endforeach; ?>
7902 </tbody>
7903 </table>
7904 </div>
7905 <?php endif; ?>
7906
7907 <?php if ( $has_closed ) : ?>
7908 <div class="vigilante-file-list vigilante-closed-plugins">
7909 <h3 id="vigilante-section-fi-closed-plugins" style="color: #d63638;"><?php esc_html_e( 'Closed + Removed Plugins', 'vigilante' ); ?></h3>
7910 <p class="description" style="color: #d63638;">
7911 <?php esc_html_e( '&#9888; Warning: These plugins have been closed in the WordPress.org repository. Closures usually indicate malware, security issues, guideline violations, or supply chain attacks. Uninstall and replace as soon as possible.', 'vigilante' ); ?>
7912 </p>
7913 <table class="wp-list-table widefat striped">
7914 <thead>
7915 <tr>
7916 <th><?php esc_html_e( 'Plugin', 'vigilante' ); ?></th>
7917 <th style="width: 70px;"><?php esc_html_e( 'Version', 'vigilante' ); ?></th>
7918 <th style="width: 90px;"><?php esc_html_e( 'State', 'vigilante' ); ?></th>
7919 <th style="width: 110px;"><?php esc_html_e( 'Closed date', 'vigilante' ); ?></th>
7920 <th><?php esc_html_e( 'Reason', 'vigilante' ); ?></th>
7921 <th style="width: 130px;"><?php esc_html_e( 'Detected', 'vigilante' ); ?></th>
7922 <th style="width: 90px;"><?php esc_html_e( 'Actions', 'vigilante' ); ?></th>
7923 </tr>
7924 </thead>
7925 <tbody>
7926 <?php foreach ( $closed_plugins as $cp_slug => $cp_entry ) :
7927 $cp_state = $cp_entry['state'] ?? '';
7928 $cp_state_label = 'closed' === $cp_state ? __( 'Closed', 'vigilante' ) : __( 'Removed', 'vigilante' );
7929 $cp_state_color = 'closed' === $cp_state ? '#d63638' : '#b32d2e';
7930 $cp_reason = '';
7931 if ( ! empty( $cp_entry['closed_reason_text'] ) ) {
7932 $cp_reason = $cp_entry['closed_reason_text'];
7933 } elseif ( 'removed' === $cp_state ) {
7934 $cp_reason = __( 'Removed from repository (metadata hidden, typical of Security Issue closures)', 'vigilante' );
7935 }
7936 $cp_detected = isset( $cp_entry['first_detected'] ) ? (int) $cp_entry['first_detected'] : 0;
7937 ?>
7938 <tr>
7939 <td>
7940 <strong><?php echo esc_html( $cp_entry['name'] ?? $cp_slug ); ?></strong><br>
7941 <a href="<?php echo esc_url( 'https://wordpress.org/plugins/' . $cp_slug . '/' ); ?>" target="_blank" rel="noopener noreferrer"><code style="color: #50575e;"><?php echo esc_html( $cp_slug ); ?></code></a>
7942 </td>
7943 <td><?php echo esc_html( $cp_entry['version'] ?? '' ); ?></td>
7944 <td><span style="color: <?php echo esc_attr( $cp_state_color ); ?>; font-weight: 600;"><?php echo esc_html( $cp_state_label ); ?></span></td>
7945 <td><?php echo esc_html( $cp_entry['closed_date'] ?? '' ); ?></td>
7946 <td><?php echo esc_html( $cp_reason ); ?></td>
7947 <td>
7948 <?php echo $cp_detected > 0 ? esc_html( wp_date( $datetime_format, $cp_detected ) ) : '&mdash;'; ?>
7949 </td>
7950 <td>
7951 <button type="button" class="button button-small vigilante-ignore-closed-plugin" data-slug="<?php echo esc_attr( $cp_slug ); ?>">
7952 <?php esc_html_e( 'Ignore', 'vigilante' ); ?>
7953 </button>
7954 </td>
7955 </tr>
7956 <?php endforeach; ?>
7957 </tbody>
7958 </table>
7959 </div>
7960 <?php endif; ?>
7961
7962 <?php if ( ! empty( $regular_modified ) ) : ?>
7963 <div class="vigilante-file-list vigilante-paginated-section" data-bulk-mode="ignore">
7964 <h3><?php esc_html_e( 'Modified Files', 'vigilante' ); ?></h3>
7965 <p class="description"><?php esc_html_e( 'These files do not match the copy WordPress.org publishes for the installed version.', 'vigilante' ); ?></p>
7966 <?php $this->render_fi_guidance( $regular_modified ); ?>
7967 <div class="vigilante-fi-bulk-bar">
7968 <button type="button" class="button vigilante-bulk-ignore" disabled><?php esc_html_e( 'Ignore selected', 'vigilante' ); ?></button>
7969 <span class="vigilante-fi-bulk-count" aria-live="polite"></span>
7970 </div>
7971 <div class="vigilante-fi-pagination-wrap"></div>
7972 <table class="wp-list-table widefat fixed striped vigilante-fi-paginated">
7973 <thead>
7974 <tr>
7975 <td class="manage-column column-cb check-column"><input type="checkbox" class="vigilante-fi-cb-all" aria-label="<?php esc_attr_e( 'Select all', 'vigilante' ); ?>"></td>
7976 <th><?php esc_html_e( 'File', 'vigilante' ); ?></th>
7977 <th style="width: 100px;"><?php esc_html_e( 'Type', 'vigilante' ); ?></th>
7978 <th style="width: 80px;"><?php esc_html_e( 'Actions', 'vigilante' ); ?></th>
7979 </tr>
7980 </thead>
7981 <tbody>
7982 <?php
7983 foreach ( $regular_modified as $item ) {
7984 $file_path = '';
7985 $file_type = 'unknown';
7986
7987 if ( is_array( $item ) ) {
7988 if ( isset( $item['file'] ) ) {
7989 $file_path = $item['file'];
7990 }
7991 if ( isset( $item['type'] ) ) {
7992 $file_type = $item['type'];
7993 }
7994 } else {
7995 $file_path = (string) $item;
7996 }
7997 if ( 'vigilante_self' === $file_type ) {
7998 $file_type = __( 'Vigilant (self)', 'vigilante' );
7999 } else {
8000 // A name a person can read, instead of the internal slug.
8001 $file_type = Vigilante_File_Integrity::finding_guidance( $file_type )['label'];
8002 }
8003 ?>
8004 <tr>
8005 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file_path ); ?>"></th>
8006 <td><code><?php echo esc_html( $file_path ); ?></code></td>
8007 <td><?php echo esc_html( $file_type ); ?></td>
8008 <td><button type="button" class="button button-small vigilante-ignore-file" data-file="<?php echo esc_attr( $file_path ); ?>"><?php esc_html_e( 'Ignore', 'vigilante' ); ?></button></td>
8009 </tr>
8010 <?php
8011 }
8012 ?>
8013 </tbody>
8014 </table>
8015 </div>
8016 <?php endif; ?>
8017
8018 <?php
8019 // "All files passed" is about every file, and Vigilant's own
8020 // are files too: with the self-protection box in red or amber
8021 // above, this line contradicted it (its findings no longer
8022 // travel in the tables below).
8023 $vg_self_alarm = in_array( $this->self_integrity_summary( $options )['tone'], array( 'critical', 'off', 'warning' ), true );
8024 ?>
8025 <?php if ( $last_results && ! $vg_self_alarm && empty( $critical_modified ) && empty( $regular_modified ) && empty( $last_results['suspicious'] ) && empty( $last_results['extra'] ) && ! $has_closed && ! $vg_scan_partial && empty( $vg_missing_core ) ) : ?>
8026 <p class="vigilante-all-clear" style="color: #00a32a; font-weight: bold;">
8027 <?php esc_html_e( 'Good job! Nothing to report in the files that were checked.', 'vigilante' ); ?>
8028 </p>
8029 <?php endif; ?>
8030 <?php endif; ?>
8031 </div>
8032 </div>
8033 <?php endif; ?>
8034
8035 <?php if ( ! empty( $ignored_closed_plugins ) ) : ?>
8036 <div id="vigilante-section-fi-ignored-closed" class="vigilante-settings-section">
8037 <h2><?php esc_html_e( 'Ignored Closed + Removed Plugins', 'vigilante' ); ?></h2>
8038 <p class="description"><?php esc_html_e( 'These plugins remain closed/removed in WordPress.org but you have chosen to hide them from the main list and from email alerts. They are still installed on the site and still running their code &mdash; the silencing is purely cosmetic.', 'vigilante' ); ?></p>
8039 <table class="wp-list-table widefat fixed striped">
8040 <thead>
8041 <tr>
8042 <th><?php esc_html_e( 'Plugin', 'vigilante' ); ?></th>
8043 <th style="width: 110px;"><?php esc_html_e( 'State', 'vigilante' ); ?></th>
8044 <th style="width: 120px;"><?php esc_html_e( 'Closed date', 'vigilante' ); ?></th>
8045 <th style="width: 130px;"><?php esc_html_e( 'Actions', 'vigilante' ); ?></th>
8046 </tr>
8047 </thead>
8048 <tbody>
8049 <?php foreach ( $ignored_closed_plugins as $icp_slug => $icp_entry ) :
8050 $icp_state = $icp_entry['state'] ?? '';
8051 $icp_state_label = 'closed' === $icp_state ? __( 'Closed', 'vigilante' ) : __( 'Removed', 'vigilante' );
8052 ?>
8053 <tr>
8054 <td>
8055 <strong><?php echo esc_html( $icp_entry['name'] ?? $icp_slug ); ?></strong><br>
8056 <a href="<?php echo esc_url( 'https://wordpress.org/plugins/' . $icp_slug . '/' ); ?>" target="_blank" rel="noopener noreferrer"><code style="color: #50575e;"><?php echo esc_html( $icp_slug ); ?></code></a>
8057 </td>
8058 <td><?php echo esc_html( $icp_state_label ); ?></td>
8059 <td><?php echo esc_html( $icp_entry['closed_date'] ?? '' ); ?></td>
8060 <td>
8061 <button type="button" class="button button-small vigilante-unignore-closed-plugin" data-slug="<?php echo esc_attr( $icp_slug ); ?>">
8062 <?php esc_html_e( 'Stop ignoring', 'vigilante' ); ?>
8063 </button>
8064 </td>
8065 </tr>
8066 <?php endforeach; ?>
8067 </tbody>
8068 </table>
8069 <p style="margin-top: 10px;">
8070 <button type="button" class="button vigilante-clear-ignored-closed-plugins"><?php esc_html_e( 'Clear All Ignored Closed + Removed Plugins', 'vigilante' ); ?></button>
8071 </p>
8072 </div>
8073 <?php endif; ?>
8074
8075 <?php if ( ! empty( $ignored_files ) ) : ?>
8076 <div id="vigilante-section-fi-ignored" class="vigilante-settings-section">
8077 <h2><?php esc_html_e( 'Ignored Files', 'vigilante' ); ?></h2>
8078 <p class="description"><?php esc_html_e( 'These files are excluded from scan results and email notifications. They will still be scanned but any findings will be hidden. To leave a whole folder out, add it to Excluded Paths instead.', 'vigilante' ); ?></p>
8079 <div class="vigilante-paginated-section" data-bulk-mode="unignore">
8080 <div class="vigilante-fi-bulk-bar">
8081 <button type="button" class="button vigilante-bulk-unignore" disabled><?php esc_html_e( 'Stop ignoring selected', 'vigilante' ); ?></button>
8082 <span class="vigilante-fi-bulk-count" aria-live="polite"></span>
8083 </div>
8084 <div class="vigilante-fi-pagination-wrap"></div>
8085 <table class="wp-list-table widefat fixed striped vigilante-fi-paginated">
8086 <thead>
8087 <tr>
8088 <td class="manage-column column-cb check-column"><input type="checkbox" class="vigilante-fi-cb-all" aria-label="<?php esc_attr_e( 'Select all', 'vigilante' ); ?>"></td>
8089 <th><?php esc_html_e( 'File', 'vigilante' ); ?></th>
8090 <th style="width: 120px;"><?php esc_html_e( 'Actions', 'vigilante' ); ?></th>
8091 </tr>
8092 </thead>
8093 <tbody>
8094 <?php foreach ( $ignored_files as $file ) : ?>
8095 <tr>
8096 <th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="<?php echo esc_attr( $file ); ?>"></th>
8097 <td><code><?php echo esc_html( $file ); ?></code></td>
8098 <td><button type="button" class="button button-small vigilante-unignore-file" data-file="<?php echo esc_attr( $file ); ?>"><?php esc_html_e( 'Stop ignoring', 'vigilante' ); ?></button></td>
8099 </tr>
8100 <?php endforeach; ?>
8101 </tbody>
8102 </table>
8103 </div>
8104 <p style="margin-top: 10px;">
8105 <button type="button" class="button vigilante-clear-ignored"><?php esc_html_e( 'Clear All Ignored Files', 'vigilante' ); ?></button>
8106 </p>
8107 </div>
8108 <?php endif; ?>
8109 <?php
8110 }
8111
8112 /**
8113 * Render sidebar with promotional widgets
8114 */
8115 private function render_sidebar() {
8116 $promo_banner = new Vigilante_Promo_Banner( 'vigilante' );
8117 $promo_banner->render();
8118 }
8119
8120 /**
8121 * AJAX: Download a ZIP backup of the critical config files.
8122 *
8123 * Streams wp-config.php and .htaccess (and robots.txt if present) as a
8124 * downloadable archive. Config backups are no longer left as files under the
8125 * web root, so this hands the admin the archive directly.
8126 */
8127 public function ajax_download_files_backup() {
8128 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
8129
8130 if ( ! current_user_can( 'manage_options' ) ) {
8131 wp_die( esc_html__( 'Permission denied.', 'vigilante' ), 403 );
8132 }
8133
8134 // The archive carries wp-config.php, which a whole network shares. On a
8135 // network manage_options is held by every subsite administrator, so the
8136 // same gate the writers use applies here.
8137 if ( ! Vigilante_Settings::can_write_shared_files() ) {
8138 wp_die( esc_html( Vigilante_Settings::get_shared_files_notice() ), 403 );
8139 }
8140
8141 $backup_manager = new Vigilante_Backup_Manager();
8142 $result = $backup_manager->stream_files_zip();
8143
8144 // stream_files_zip() exits on success; only a WP_Error returns here.
8145 if ( is_wp_error( $result ) ) {
8146 wp_die( esc_html( $result->get_error_message() ), 500 );
8147 }
8148 }
8149
8150 /**
8151 * AJAX: Save settings
8152 */
8153 public function ajax_save_settings() {
8154 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
8155
8156 if ( ! current_user_can( 'manage_options' ) ) {
8157 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
8158 }
8159
8160 $section = isset( $_POST['section'] ) ? sanitize_key( $_POST['section'] ) : '';
8161
8162 // Handle $_POST['data'] based on type
8163 if ( isset( $_POST['data'] ) && is_array( $_POST['data'] ) ) {
8164 $data = map_deep( wp_unslash( $_POST['data'] ), 'sanitize_text_field' );
8165 } elseif ( isset( $_POST['data'] ) ) {
8166 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
8167 $raw_data = wp_unslash( $_POST['data'] );
8168 parse_str( $raw_data, $data );
8169 $data = $this->sanitize_posted_settings( $data );
8170 } else {
8171 $data = array();
8172 }
8173
8174 if ( empty( $section ) ) {
8175 wp_send_json_error( __( 'Invalid section.', 'vigilante' ) );
8176 }
8177
8178 // Check if 2FA is being enabled or method changed (for notification sending)
8179 $send_2fa_notification = false;
8180 $send_login_url_notification = false;
8181
8182 if ( 'login_security' === $section ) {
8183 // Read old state from DB
8184 $db_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
8185 $old_2fa_enabled = ! empty( $db_options['login_security']['two_factor']['enabled'] );
8186 $old_method = $db_options['login_security']['two_factor']['method'] ?? 'email';
8187
8188 // Check new state from submitted data
8189 $new_2fa_enabled = false;
8190 $notify_on_enable = false;
8191 $new_method = isset( $data['login_security']['two_factor']['method'] )
8192 ? sanitize_key( $data['login_security']['two_factor']['method'] )
8193 : 'email';
8194
8195 if ( isset( $data['login_security']['two_factor']['enabled'] ) ) {
8196 $new_2fa_enabled = filter_var( $data['login_security']['two_factor']['enabled'], FILTER_VALIDATE_BOOLEAN );
8197 }
8198 if ( isset( $data['login_security']['two_factor']['notify_on_enable'] ) ) {
8199 $notify_on_enable = filter_var( $data['login_security']['two_factor']['notify_on_enable'], FILTER_VALIDATE_BOOLEAN );
8200 }
8201
8202 // Send notification if:
8203 // 1. 2FA is being enabled (was off, now on) OR
8204 // 2. Method changed while 2FA is enabled
8205 if ( $notify_on_enable && $new_2fa_enabled ) {
8206 if ( ! $old_2fa_enabled || ( $old_2fa_enabled && $old_method !== $new_method ) ) {
8207 $send_2fa_notification = true;
8208 }
8209 }
8210
8211 // Check if login URL changed and notification is enabled
8212 $old_login_url = $db_options['login_security']['custom_login_url'] ?? '';
8213 $new_login_url = isset( $data['login_security']['custom_login_url'] )
8214 ? sanitize_title( $data['login_security']['custom_login_url'] )
8215 : '';
8216 $notify_on_url_change = isset( $data['login_security']['notify_on_login_url_change'] )
8217 ? filter_var( $data['login_security']['notify_on_login_url_change'], FILTER_VALIDATE_BOOLEAN )
8218 : false;
8219
8220 if ( $notify_on_url_change && ! empty( $new_login_url ) && $new_login_url !== $old_login_url ) {
8221 $send_login_url_notification = true;
8222 }
8223 }
8224
8225 // Get defaults
8226 $defaults = $this->settings->get_default_options();
8227
8228 // Read ONLY saved options from database (not merged with defaults)
8229 $saved_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
8230
8231 // What is stored before this request changes anything: the shared file
8232 // settings this user may not change are put back from here (2.11.6).
8233 $stored_options = $saved_options;
8234 $locked = Vigilante_Settings::get_locked_file_settings();
8235
8236 if ( isset( $locked[ $section ] ) && true === $locked[ $section ] ) {
8237 wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
8238 }
8239
8240 // A module switch is a single key, so refusing says more than a success
8241 // that changed nothing, and the dashboard puts the toggle back.
8242 if ( 'modules' === $section && isset( $locked['modules'], $data['modules'] ) && is_array( $locked['modules'] ) && is_array( $data['modules'] ) ) {
8243 foreach ( array_keys( $data['modules'] ) as $vg_module ) {
8244 if ( in_array( sanitize_key( $vg_module ), $locked['modules'], true ) ) {
8245 wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
8246 }
8247 }
8248 }
8249
8250 $rejected_ips = array();
8251 $rejected_proxies = array();
8252
8253 // Handle modules
8254 if ( 'modules' === $section && isset( $data['modules'] ) ) {
8255 if ( ! isset( $saved_options['modules'] ) ) {
8256 $saved_options['modules'] = array();
8257 }
8258 foreach ( $data['modules'] as $module => $enabled ) {
8259 $module = sanitize_key( $module );
8260 $saved_options['modules'][ $module ] = in_array( $enabled, array( '1', 1, 'true', true ), true );
8261 }
8262 // Clear active preset when modules change
8263 update_option( 'vigilante_active_preset', '' );
8264 } else {
8265 // Process primary section
8266 if ( isset( $data[ $section ] ) && is_array( $data[ $section ] ) ) {
8267 $section_defaults = isset( $defaults[ $section ] ) ? $defaults[ $section ] : array();
8268 $current_section = isset( $saved_options[ $section ] ) ? $saved_options[ $section ] : array();
8269
8270 // Process the submitted data
8271 $processed = $this->process_section_data( $data[ $section ], $section_defaults, $current_section );
8272
8273 // The IP boxes are free text and, until 2.9.9, whatever was typed
8274 // went straight into the option. An entry the matcher can never
8275 // match still sits in a security list looking like protection,
8276 // so the ones that cannot match are dropped and reported back
8277 // instead of being stored in silence.
8278 $rejected_ips = $this->filter_ip_lists( $section, $processed, $rejected_proxies );
8279
8280 // Save the processed section
8281 $saved_options[ $section ] = $processed;
8282
8283 // Clear active preset when any section settings change
8284 update_option( 'vigilante_active_preset', '' );
8285 }
8286 }
8287
8288 // Clear cache before saving
8289 wp_cache_delete( Vigilante_Settings::OPTION_NAME, 'options' );
8290
8291 $saved_options = Vigilante_Settings::keep_locked_file_settings( $saved_options, $stored_options );
8292
8293 // Save to database
8294 update_option( Vigilante_Settings::OPTION_NAME, $saved_options );
8295
8296 // Clear the settings cache
8297 $this->settings->clear_cache();
8298
8299 // Apply changes based on section
8300 $this->apply_section_changes( $section, $saved_options );
8301
8302 // Send 2FA notifications after settings are saved
8303 $notification_result = null;
8304 if ( $send_2fa_notification ) {
8305 $notification_result = $this->send_2fa_enable_notifications();
8306 }
8307
8308 // Send login URL notifications after settings are saved
8309 $login_url_result = null;
8310 if ( $send_login_url_notification ) {
8311 $login_url_result = $this->send_login_url_notifications();
8312 }
8313
8314 // Build success message
8315 $message = __( 'Settings saved successfully.', 'vigilante' );
8316
8317 if ( $notification_result && $notification_result['sent'] > 0 ) {
8318 $message .= ' ' . sprintf(
8319 /* translators: %d: Number of emails sent */
8320 _n(
8321 '2FA notification sent to %d user.',
8322 '2FA notifications sent to %d users.',
8323 $notification_result['sent'],
8324 'vigilante'
8325 ),
8326 $notification_result['sent']
8327 );
8328 }
8329
8330 if ( $login_url_result && $login_url_result['sent'] > 0 ) {
8331 $message .= ' ' . sprintf(
8332 /* translators: %d: Number of emails sent */
8333 _n(
8334 'Login URL notification sent to %d user.',
8335 'Login URL notifications sent to %d users.',
8336 $login_url_result['sent'],
8337 'vigilante'
8338 ),
8339 $login_url_result['sent']
8340 );
8341 }
8342
8343 if ( ! empty( $rejected_ips ) ) {
8344 $message .= ' ' . sprintf(
8345 /* translators: %s: comma separated list of the entries that were not saved. */
8346 _n(
8347 'This entry is not a valid IP, CIDR range or wildcard, so it was not saved: %s',
8348 'These entries are not valid IPs, CIDR ranges or wildcards, so they were not saved: %s',
8349 count( $rejected_ips ),
8350 'vigilante'
8351 ),
8352 implode( ', ', array_map( 'esc_html', $rejected_ips ) )
8353 );
8354 }
8355
8356 if ( ! empty( $rejected_proxies ) ) {
8357 $message .= ' ' . sprintf(
8358 /* translators: %s: comma separated list of the trusted proxy entries that were not saved. */
8359 _n(
8360 'A trusted proxy must be an exact IP or a CIDR range, not a wildcard, so this entry was not saved: %s',
8361 'A trusted proxy must be an exact IP or a CIDR range, not a wildcard, so these entries were not saved: %s',
8362 count( $rejected_proxies ),
8363 'vigilante'
8364 ),
8365 implode( ', ', array_map( 'esc_html', $rejected_proxies ) )
8366 );
8367 }
8368
8369 wp_send_json_success( $message );
8370 }
8371
8372 /**
8373 * Keep only the IP patterns the matcher can actually match
8374 *
8375 * @since 2.9.9
8376 *
8377 * @param string $section Section being saved.
8378 * @param array $processed Section data, edited in place.
8379 * @return array Entries that were dropped, for the message back to the user.
8380 */
8381 private function filter_ip_lists( $section, &$processed, &$rejected_proxies = array() ) {
8382 $rejected_proxies = array();
8383
8384 // Trusted proxies feed an identity decision, so only exact addresses and
8385 // CIDR ranges belong there: a wildcard is stripped with its own message,
8386 // never stored looking effective. The matcher ignores it anyway (see
8387 // Vigilante_IP_Utils::in_list_ip_or_cidr), this stops it persisting.
8388 if ( 'firewall' === $section && isset( $processed['trusted_proxies'] ) && is_array( $processed['trusted_proxies'] ) ) {
8389 $split = Vigilante_IP_Utils::split_list_ip_or_cidr( $processed['trusted_proxies'] );
8390 $processed['trusted_proxies'] = $split['valid'];
8391 $rejected_proxies = $split['rejected'];
8392 }
8393
8394 $lists = array(
8395 'firewall' => array( 'ip_whitelist', 'ip_blacklist' ),
8396 'login_security' => array( 'ip_whitelist' ),
8397 );
8398
8399 if ( ! isset( $lists[ $section ] ) ) {
8400 return array();
8401 }
8402
8403 $rejected = array();
8404
8405 foreach ( $lists[ $section ] as $key ) {
8406 if ( ! isset( $processed[ $key ] ) || ! is_array( $processed[ $key ] ) ) {
8407 continue;
8408 }
8409
8410 $split = Vigilante_IP_Utils::split_list( $processed[ $key ] );
8411 $processed[ $key ] = $split['valid'];
8412 $rejected = array_merge( $rejected, $split['rejected'] );
8413 }
8414
8415 return array_values( array_unique( $rejected ) );
8416 }
8417
8418 /**
8419 * Send 2FA enable notifications to users
8420 *
8421 * @return array Result with 'sent' and 'failed' counts.
8422 */
8423 private function send_2fa_enable_notifications() {
8424 $result = array(
8425 'sent' => 0,
8426 'failed' => 0,
8427 );
8428
8429 // Get settings for roles and method
8430 $login_security = $this->settings->get_section( 'login_security' );
8431 $two_factor = isset( $login_security['two_factor'] ) ? $login_security['two_factor'] : array();
8432 $roles = isset( $two_factor['enforced_roles'] ) ? $two_factor['enforced_roles'] : array( 'administrator' );
8433 $method = isset( $two_factor['method'] ) ? $two_factor['method'] : 'email';
8434
8435 if ( empty( $roles ) ) {
8436 $roles = array( 'administrator' );
8437 }
8438
8439 $excluded = isset( $two_factor['excluded_users'] ) ? array_map( 'absint', $two_factor['excluded_users'] ) : array();
8440
8441 // Get users with these roles
8442 $args = array(
8443 'role__in' => $roles,
8444 );
8445 if ( ! empty( $excluded ) ) {
8446 // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- Small excluded users list from settings.
8447 $args['exclude'] = $excluded;
8448 }
8449 $users = get_users( $args );
8450
8451 if ( empty( $users ) ) {
8452 return $result;
8453 }
8454
8455 $site_name = get_bloginfo( 'name' );
8456 $from_name = ! empty( $two_factor['email_from_name'] ) ? $two_factor['email_from_name'] : $site_name;
8457
8458 if ( 'totp' === $method ) {
8459 // Use TOTP class for styled HTML emails
8460 if ( ! class_exists( 'Vigilante_Two_Factor_TOTP' ) ) {
8461 require_once VIGILANTE_INCLUDES_DIR . 'class-two-factor-totp.php';
8462 }
8463 $totp = new Vigilante_Two_Factor_TOTP( $this->settings, $this->database, $this->activity_log );
8464
8465 foreach ( $users as $user ) {
8466 // Skip users who already have TOTP configured
8467 $totp_data = $this->database->get_totp_data( $user->ID );
8468 if ( $totp_data && ! empty( $totp_data['is_configured'] ) ) {
8469 continue;
8470 }
8471
8472 $sent = $totp->send_activation_email( $user, $site_name, $from_name );
8473
8474 if ( $sent ) {
8475 $this->database->mark_2fa_notified( $user->ID );
8476 $result['sent']++;
8477 } else {
8478 $result['failed']++;
8479 }
8480 }
8481 } else {
8482 // Email method - use existing email 2FA class
8483 if ( ! class_exists( 'Vigilante_Two_Factor_Email' ) ) {
8484 require_once VIGILANTE_INCLUDES_DIR . 'class-two-factor-email.php';
8485 }
8486 $email_2fa = new Vigilante_Two_Factor_Email( $this->settings, $this->database, $this->activity_log );
8487 return $email_2fa->send_activation_notifications( false );
8488 }
8489
8490 return $result;
8491 }
8492
8493 /**
8494 * Send login URL change notifications to users with admin access
8495 *
8496 * @return array Result with 'sent' and 'failed' counts.
8497 */
8498 private function send_login_url_notifications() {
8499 $login_options = $this->settings->get_section( 'login_security' );
8500 $custom_url = ! empty( $login_options['custom_login_url'] ) ? sanitize_title( $login_options['custom_login_url'] ) : '';
8501
8502 if ( empty( $custom_url ) ) {
8503 return array( 'sent' => 0, 'failed' => 0 );
8504 }
8505
8506 $login_url = home_url( $custom_url . '/' );
8507 $site_name = get_bloginfo( 'name' );
8508
8509 $admin_roles = array( 'administrator', 'editor', 'author', 'contributor' );
8510 $users = get_users( array( 'role__in' => $admin_roles ) );
8511
8512 if ( empty( $users ) ) {
8513 return array( 'sent' => 0, 'failed' => 0 );
8514 }
8515
8516 $subject = sprintf(
8517 /* translators: %s: Site name */
8518 __( '[%s] Your login URL has changed', 'vigilante' ),
8519 $site_name
8520 );
8521
8522 $body = Vigilante_Email_Template::p( __( 'The login URL for the admin area has been changed. Please save the new URL below and use it from now on.', 'vigilante' ) );
8523 $body .= Vigilante_Email_Template::url_box( $login_url, __( 'Your new login URL:', 'vigilante' ) );
8524 $body .= Vigilante_Email_Template::alert_box( __( 'The old login address (wp-login.php) will no longer work.', 'vigilante' ) );
8525 $body .= Vigilante_Email_Template::button( $login_url, __( 'Go to login', 'vigilante' ) );
8526
8527 $sent = 0;
8528 $failed = 0;
8529
8530 foreach ( $users as $user ) {
8531 $result = Vigilante_Email_Template::send(
8532 $user->user_email,
8533 $subject,
8534 __( 'Login URL changed', 'vigilante' ),
8535 $body
8536 );
8537 if ( $result ) {
8538 $sent++;
8539 } else {
8540 $failed++;
8541 }
8542 }
8543
8544 if ( $this->activity_log ) {
8545 $this->activity_log->log(
8546 'login',
8547 'login_url_notified',
8548 sprintf(
8549 /* translators: 1: Sent count, 2: Failed count */
8550 __( 'Login URL notification sent on save: %1$d sent, %2$d failed', 'vigilante' ),
8551 $sent,
8552 $failed
8553 )
8554 );
8555 }
8556
8557 return array( 'sent' => $sent, 'failed' => $failed );
8558 }
8559
8560 /**
8561 * Sanitize the settings a tab posted, once the serialized form is parsed
8562 *
8563 * Every value goes through sanitize_textarea_field() except the lists of
8564 * file paths (Vigilante_Settings::path_list_keys()). That one deletes every
8565 * %XX octet and, when it finds one, collapses repeated spaces, so an
8566 * exclusion for a folder called foto%20x was saved as fotox and excluded
8567 * nothing (measured on 1 oct 2026 against 3.0.1). The paths are read as
8568 * paths before the generic sanitizer runs and put back after it.
8569 *
8570 * @since 3.0.2
8571 *
8572 * @param array $data Parsed form: section => key => value.
8573 * @return array
8574 */
8575 private function sanitize_posted_settings( $data ) {
8576 $paths = Vigilante_Settings::take_path_lists( $data );
8577 $data = map_deep( $data, 'sanitize_textarea_field' );
8578
8579 return Vigilante_Settings::restore_path_lists( $data, $paths );
8580 }
8581
8582 /**
8583 * Sanitize an imported settings file, once it is decoded
8584 *
8585 * Same rule as sanitize_posted_settings(): the lists of paths keep what the
8586 * export wrote, so an exclusion survives an export and an import.
8587 *
8588 * @since 3.0.2
8589 *
8590 * @param array $imported Decoded export.
8591 * @return array
8592 */
8593 private function sanitize_imported_settings( $imported ) {
8594 $paths = Vigilante_Settings::take_path_lists( $imported );
8595 $imported = map_deep( $imported, 'sanitize_text_field' );
8596
8597 return Vigilante_Settings::restore_path_lists( $imported, $paths );
8598 }
8599
8600 /**
8601 * Process section data maintaining proper types from defaults
8602 *
8603 * @param array $submitted_data Data submitted from form.
8604 * @param array $defaults Default values for this section.
8605 * @param array $current Current saved values.
8606 * @param string $section_name Section name for special handling.
8607 * @return array Processed data.
8608 */
8609 private function process_section_data( $submitted_data, $defaults, $current, $section_name = '' ) {
8610 // Start with defaults, then merge current saved values. Lists are put
8611 // back whole afterwards: array_replace_recursive() merges them index by
8612 // index, so a saved list shorter than the default kept the default's
8613 // tail (see below, and Vigilante_Settings::array_merge_deep()).
8614 $result = array_replace_recursive( $defaults, $current );
8615 $result = $this->restore_lists( $result, $current );
8616
8617 // Process each submitted value
8618 foreach ( $submitted_data as $key => $value ) {
8619 $key = sanitize_key( $key );
8620
8621 if ( is_array( $value ) && $this->is_list_field( $key, $value, $defaults, $current ) ) {
8622 /*
8623 * A list of values, not a group of settings: the roles of a
8624 * checkbox group, the excluded user ids. What the form sent IS
8625 * the list, so it replaces the stored one outright.
8626 *
8627 * Until 3.0.1 this went through the recursion below, which
8628 * writes position by position and left every stored entry the
8629 * new list was too short to overwrite. Unchecking Editor in
8630 * "Enforce for roles" saved administrator over position 0 and
8631 * kept editor in position 1, so the box came back ticked
8632 * (reported on the support forum for 3.0.0, and the same bug
8633 * merge_preset() was fixed for in 2.9.8).
8634 *
8635 * Keys are dropped on purpose: a list is data, so anything the
8636 * request tried to name is not carried over. The empty string
8637 * is dropped too, which is what the hidden field that marks an
8638 * empty group sends.
8639 */
8640 $list = array();
8641 foreach ( $value as $entry ) {
8642 if ( ! is_scalar( $entry ) ) {
8643 continue;
8644 }
8645 $entry = sanitize_text_field( (string) $entry );
8646 if ( '' === $entry ) {
8647 continue;
8648 }
8649 $list[] = $entry;
8650 }
8651 $result[ $key ] = $list;
8652 } elseif ( is_array( $value ) ) {
8653 // Nested array (like rate_limiting)
8654 $nested_defaults = isset( $defaults[ $key ] ) && is_array( $defaults[ $key ] ) ? $defaults[ $key ] : array();
8655 $nested_current = isset( $result[ $key ] ) && is_array( $result[ $key ] ) ? $result[ $key ] : array();
8656 $result[ $key ] = $this->process_section_data( $value, $nested_defaults, $nested_current, $key );
8657 } else {
8658 // Determine type from default value
8659 $default_value = isset( $defaults[ $key ] ) ? $defaults[ $key ] : null;
8660
8661 if ( null === $default_value ) {
8662 // No default, check current value type or use as string
8663 $current_value = isset( $current[ $key ] ) ? $current[ $key ] : null;
8664 if ( is_bool( $current_value ) ) {
8665 $result[ $key ] = in_array( $value, array( '1', 1, 'true', true ), true );
8666 } elseif ( is_int( $current_value ) ) {
8667 $result[ $key ] = intval( $value );
8668 } elseif ( is_array( $current_value ) ) {
8669 $result[ $key ] = is_string( $value ) ? array_filter( array_map( 'trim', explode( "\n", $value ) ) ) : (array) $value;
8670 } else {
8671 $result[ $key ] = sanitize_text_field( $value );
8672 }
8673 } elseif ( is_bool( $default_value ) ) {
8674 // Boolean: '1', 1, 'true' become true; '0', 0, '', 'false' become false
8675 $result[ $key ] = in_array( $value, array( '1', 1, 'true', true ), true );
8676 } elseif ( is_int( $default_value ) ) {
8677 // Integer - with special handling for time fields shown in minutes
8678 $int_value = intval( $value );
8679
8680 // Convert minutes to seconds for login_security duration fields
8681 // These are displayed as minutes in the form but stored as seconds
8682 if ( in_array( $key, array( 'lockout_duration', 'max_lockout_duration' ), true ) ) {
8683 $int_value = $int_value * 60;
8684 }
8685
8686 $result[ $key ] = $int_value;
8687 } elseif ( is_array( $default_value ) ) {
8688 // Array from textarea (e.g., IP lists)
8689 if ( is_string( $value ) ) {
8690 $result[ $key ] = array_filter( array_map( 'trim', explode( "\n", $value ) ) );
8691 } else {
8692 $result[ $key ] = (array) $value;
8693 }
8694 } else {
8695 // String - preserve newlines for textarea fields
8696 if ( is_string( $value ) && ( strpos( $value, "\n" ) !== false || strpos( $value, "\r" ) !== false ) ) {
8697 $result[ $key ] = sanitize_textarea_field( $value );
8698 } else {
8699 $result[ $key ] = sanitize_text_field( $value );
8700 }
8701 }
8702 }
8703 }
8704
8705 // Handle unchecked checkboxes: HTML forms don't submit unchecked boxes
8706 // If a boolean field exists in defaults but NOT in submitted_data, set it to false
8707 //
8708 // EXCEPTION: the top-level 'enabled' flag of every section is the
8709 // module's master switch and is controlled by the Dashboard module
8710 // toggle, NOT by a checkbox inside the section's form. Treating it
8711 // like a regular checkbox here would silently switch the module off
8712 // every time the user saves the tab — see the REST API enabled=false
8713 // regression. We only skip it at the top level (when section_name is
8714 // empty); nested 'enabled' fields like security_headers.csp.enabled
8715 // are real checkboxes and must keep the auto-unset behaviour.
8716 $preserve_top_level = array( 'enabled' );
8717
8718 foreach ( $defaults as $key => $default_value ) {
8719 if ( '' === $section_name && in_array( $key, $preserve_top_level, true ) ) {
8720 continue;
8721 }
8722 if ( is_bool( $default_value ) && ! array_key_exists( $key, $submitted_data ) ) {
8723 $result[ $key ] = false;
8724 } elseif ( is_array( $default_value ) && ! isset( $submitted_data[ $key ] ) ) {
8725 // Check if this is a flat value list (like excluded_users, enforced_roles, ip_whitelist)
8726 // vs a nested settings group (like rate_limiting, two_factor)
8727 // Flat lists: default is empty array OR all values are scalar
8728 $is_value_list = empty( $default_value );
8729 if ( ! $is_value_list ) {
8730 $is_value_list = true;
8731 foreach ( $default_value as $dv ) {
8732 if ( ! is_scalar( $dv ) ) {
8733 $is_value_list = false;
8734 break;
8735 }
8736 }
8737 }
8738
8739 if ( $is_value_list ) {
8740 /*
8741 * A list whose key did not travel is left as it was. Until
8742 * 3.0.1 it was blanked here, which read "the user unticked
8743 * everything" into something the form never asked about:
8744 * saving the Firewall tab blanked allowed_http_methods,
8745 * which has no field on any screen, and saving Users blanked
8746 * insecure_usernames. Both survived only because reading the
8747 * option put the defaults back on top, and that is exactly
8748 * the merge being removed in this release: with lists
8749 * replaced whole, blanking here would have left a site
8750 * answering 403 to every HTTP method.
8751 *
8752 * Emptying a group is still possible, and is now said out
8753 * loud: each checkbox group prints a hidden field with an
8754 * empty value, so an empty group arrives as an empty list
8755 * instead of as silence. See list_sentinel().
8756 */
8757 continue;
8758 } else {
8759 // Nested settings group - handle boolean children
8760 foreach ( $default_value as $nested_key => $nested_default ) {
8761 if ( is_bool( $nested_default ) && isset( $result[ $key ] ) && is_array( $result[ $key ] ) ) {
8762 $nested_submitted = isset( $submitted_data[ $key ] ) && is_array( $submitted_data[ $key ] )
8763 ? $submitted_data[ $key ]
8764 : array();
8765 if ( ! array_key_exists( $nested_key, $nested_submitted ) ) {
8766 $result[ $key ][ $nested_key ] = false;
8767 }
8768 }
8769 }
8770 }
8771 }
8772 }
8773
8774 return $result;
8775 }
8776
8777 /**
8778 * Whether a submitted value is a list of values rather than a group of settings
8779 *
8780 * The difference decides whether what arrived replaces the stored value or
8781 * is merged into it key by key, so it is answered from what the setting is
8782 * (its default, or what is stored today), not from the shape of the
8783 * request. A request that sends named keys where a list belongs gets them
8784 * dropped, which is one way this is narrower than what it replaces.
8785 *
8786 * @since 3.0.1
8787 *
8788 * @param string $key Setting key, already through sanitize_key().
8789 * @param array $value Value as submitted.
8790 * @param array $defaults Defaults of the section being processed.
8791 * @param array $current Stored values of the section being processed.
8792 * @return bool
8793 */
8794 private function is_list_field( $key, $value, $defaults, $current ) {
8795 $default_known = array_key_exists( $key, $defaults ) && is_array( $defaults[ $key ] );
8796
8797 // The default has the first and the last word, the same rule that
8798 // array_merge_deep() and restore_lists() follow. Letting the stored
8799 // value decide, as this did until the fourth cross review of 3.0.1,
8800 // disagrees with them where it matters: an option corrupted into
8801 // two_factor = array( 'a', 'b' ) would have been treated as a list and
8802 // saving the tab would have replaced the whole group of settings with
8803 // the two values that arrived.
8804 if ( $default_known && array() !== $defaults[ $key ] ) {
8805 return Vigilante_Settings::is_list( $defaults[ $key ] );
8806 }
8807
8808 // Nothing declares it either way (an empty default, or a key with no
8809 // default at all), so what is stored, and then what was submitted, is
8810 // all there is to go on.
8811 if ( isset( $current[ $key ] ) && is_array( $current[ $key ] ) && array() !== $current[ $key ] && Vigilante_Settings::is_list( $current[ $key ] ) ) {
8812 return true;
8813 }
8814
8815 return Vigilante_Settings::is_list( $value );
8816 }
8817
8818 /**
8819 * Put stored lists back after array_replace_recursive()
8820 *
8821 * array_replace_recursive() merges a list by position, so a stored list
8822 * shorter than the default comes out with the default's tail attached:
8823 * ['administrator'] over ['administrator','editor'] gives both back. Every
8824 * list that is stored is therefore restored whole before anything else is
8825 * processed, and only associative arrays are walked into.
8826 *
8827 * @since 3.0.1
8828 *
8829 * @param array $result Defaults with the stored values merged over them.
8830 * @param array $current Stored values.
8831 * @return array
8832 */
8833 private function restore_lists( $result, $current ) {
8834 if ( ! is_array( $current ) ) {
8835 return $result;
8836 }
8837
8838 foreach ( $current as $key => $value ) {
8839 if ( ! is_array( $value ) || ! isset( $result[ $key ] ) || ! is_array( $result[ $key ] ) ) {
8840 continue;
8841 }
8842
8843 // Decided by what is in $result, which comes from the defaults, and
8844 // never by the stored value: array() is a list by any definition, so
8845 // a section stored empty would have replaced a whole group of
8846 // settings with nothing. Same reasoning as in array_merge_deep().
8847 if ( Vigilante_Settings::is_list( $result[ $key ] ) ) {
8848 $result[ $key ] = $value;
8849 continue;
8850 }
8851
8852 $result[ $key ] = $this->restore_lists( $result[ $key ], $value );
8853 }
8854
8855 return $result;
8856 }
8857
8858 /**
8859 * Print the hidden field that lets a checkbox group arrive empty
8860 *
8861 * A checkbox group sends nothing at all when every box is unticked, which
8862 * is indistinguishable from a form that never had that field. Since 3.0.1
8863 * the save path leaves an absent list alone, so the difference has to be
8864 * stated: this empty entry always travels, and the save path drops empty
8865 * strings, so an untouched group arrives as an empty list.
8866 *
8867 * @since 3.0.1
8868 *
8869 * @param string $name Field name without the trailing [].
8870 */
8871 private function list_sentinel( $name ) {
8872 printf( '<input type="hidden" name="%s[]" value="">', esc_attr( $name ) );
8873 }
8874
8875 /**
8876 * AJAX: Export settings
8877 */
8878 public function ajax_export_settings() {
8879 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
8880
8881 if ( ! current_user_can( 'manage_options' ) ) {
8882 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
8883 }
8884
8885 $options = $this->settings->get_all_options();
8886 $filename = 'vigilante-settings-' . gmdate( 'Y-m-d-His' ) . '.json';
8887
8888 wp_send_json_success( array(
8889 'content' => wp_json_encode( $options, JSON_PRETTY_PRINT ),
8890 'filename' => $filename,
8891 ) );
8892 }
8893
8894 /**
8895 * AJAX: Import settings
8896 */
8897 public function ajax_import_settings() {
8898 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
8899
8900 if ( ! current_user_can( 'manage_options' ) ) {
8901 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
8902 }
8903
8904 // Accept both 'settings' (from JS) and 'content' (legacy).
8905 // Do NOT run sanitize_text_field() on the raw payload: it calls
8906 // wp_strip_all_tags() internally, which removes any "<...>" substring
8907 // and turns a valid export JSON into garbage if any stored value
8908 // contains < or > (htaccess snippets, email templates, etc.). The
8909 // real sanitization happens after json_decode(), via map_deep() on
8910 // the parsed array.
8911 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash
8912 $content = isset( $_POST['settings'] ) ? wp_unslash( $_POST['settings'] ) : '';
8913 if ( empty( $content ) ) {
8914 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash
8915 $content = isset( $_POST['content'] ) ? wp_unslash( $_POST['content'] ) : '';
8916 }
8917
8918 if ( empty( $content ) || ! is_string( $content ) ) {
8919 wp_send_json_error( __( 'No content provided.', 'vigilante' ) );
8920 }
8921
8922 $imported = json_decode( $content, true );
8923
8924 if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $imported ) ) {
8925 wp_send_json_error( __( 'Invalid JSON format.', 'vigilante' ) );
8926 }
8927
8928 // Sanitize imported data recursively
8929 $imported = $this->sanitize_imported_settings( $imported );
8930
8931 // Validate structure: only sections and keys of the schema survive, and
8932 // every value takes the type of its default. Until 2.11.0 this was an
8933 // array_replace_recursive() of the file over the defaults, so any key in
8934 // the file, known or not, landed in vigilante_options (S7). Sections
8935 // the file does not carry keep their defaults; a section it does carry
8936 // replaces the default one whole, because validate_options() has
8937 // already filled in whatever the file left out.
8938 $defaults = $this->settings->get_default_options();
8939 $validated = $this->settings->validate_options( $imported );
8940 $merged = $defaults;
8941
8942 foreach ( $validated as $section => $data ) {
8943 if ( is_array( $data ) ) {
8944 $merged[ $section ] = $data;
8945 }
8946 }
8947
8948 // Save
8949 $merged = Vigilante_Settings::keep_locked_file_settings( $merged, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
8950 update_option( Vigilante_Settings::OPTION_NAME, $merged );
8951 $this->settings->clear_cache();
8952
8953 // Re-evaluate the active preset marker. The imported config may match
8954 // a known preset exactly, partially, or not at all — without this step
8955 // the dashboard would keep showing whatever preset was active before
8956 // the import even if the new config no longer matches it.
8957 $matched_preset = $this->detect_matching_preset( $merged );
8958 if ( null === $matched_preset ) {
8959 delete_option( 'vigilante_active_preset' );
8960 } else {
8961 update_option( 'vigilante_active_preset', $matched_preset );
8962 }
8963
8964 // Apply file changes after import
8965 $this->apply_all_file_changes( $merged );
8966
8967 // Refresh the Security Analyzer score so the dashboard widget reflects
8968 // the imported config rather than the pre-import scan. Reuse the
8969 // post-Under-Attack scan hook (same job: full scan, async).
8970 if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
8971 wp_schedule_single_event( time() + 5, 'vigilante_under_attack_post_scan' );
8972 }
8973
8974 wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
8975 }
8976
8977 /**
8978 * Detect whether a vigilante_options array matches a known preset.
8979 *
8980 * A preset matches when every field the preset explicitly declares is
8981 * present in the config with the same (normalised) value. Fields outside
8982 * the preset are ignored — they may have been modified by the user before
8983 * applying the preset and do not invalidate the match. This mirrors how
8984 * apply_preset() now layers presets on top of the user's existing config.
8985 *
8986 * @param array $options The current/imported vigilante_options.
8987 * @return string|null Preset id ('standard', 'maximum') or null if custom.
8988 */
8989 private function detect_matching_preset( $options ) {
8990 if ( ! is_array( $options ) ) {
8991 return null;
8992 }
8993
8994 $presets = $this->settings->get_presets();
8995
8996 foreach ( $presets as $preset_id => $preset_data ) {
8997 unset( $preset_data['name'], $preset_data['description'] );
8998 if ( $this->preset_subset_matches( $preset_data, $options ) ) {
8999 return $preset_id;
9000 }
9001 }
9002
9003 return null;
9004 }
9005
9006 /**
9007 * Check whether every leaf value inside $preset_subset exists with the
9008 * same (normalised) value at the same path inside $config.
9009 *
9010 * @param mixed $preset_subset Branch of the preset definition.
9011 * @param mixed $config Same branch in the live/imported config.
9012 * @return bool
9013 */
9014 private function preset_subset_matches( $preset_subset, $config ) {
9015 if ( is_array( $preset_subset ) ) {
9016 if ( ! is_array( $config ) ) {
9017 return false;
9018 }
9019 foreach ( $preset_subset as $key => $value ) {
9020 if ( ! array_key_exists( $key, $config ) ) {
9021 return false;
9022 }
9023 if ( ! $this->preset_subset_matches( $value, $config[ $key ] ) ) {
9024 return false;
9025 }
9026 }
9027 return true;
9028 }
9029
9030 return $this->normalise_scalar_for_compare( $preset_subset ) === $this->normalise_scalar_for_compare( $config );
9031 }
9032
9033 /**
9034 * Normalise a scalar value so that the variants WordPress and the form
9035 * layer routinely produce ('1' / 1 / true → "1"; '' / '0' / 0 / false /
9036 * null → "") compare equal. Other values become strings unchanged.
9037 *
9038 * @param mixed $value
9039 * @return string
9040 */
9041 private function normalise_scalar_for_compare( $value ) {
9042 if ( is_bool( $value ) ) {
9043 return $value ? '1' : '';
9044 }
9045 if ( null === $value ) {
9046 return '';
9047 }
9048 if ( is_int( $value ) || is_float( $value ) ) {
9049 return (string) $value;
9050 }
9051 if ( is_string( $value ) ) {
9052 if ( 'true' === $value ) {
9053 return '1';
9054 }
9055 if ( 'false' === $value ) {
9056 return '';
9057 }
9058 return $value;
9059 }
9060 // Arrays and objects shouldn't reach here (handled by recursion above),
9061 // but if they do, fall back to a stable comparable representation.
9062 return wp_json_encode( $value );
9063 }
9064
9065 /**
9066 * AJAX: Apply preset
9067 */
9068 public function ajax_apply_preset() {
9069 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
9070
9071 if ( ! current_user_can( 'manage_options' ) ) {
9072 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
9073 }
9074
9075 $preset = isset( $_POST['preset'] ) ? sanitize_key( $_POST['preset'] ) : '';
9076
9077 // Handle reset to defaults
9078 if ( 'reset' === $preset ) {
9079 $stored_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
9080 $defaults = Vigilante_Settings::get_defaults_preserving_user_data( $stored_options );
9081 $defaults = Vigilante_Settings::keep_locked_file_settings( $defaults, $stored_options );
9082 update_option( Vigilante_Settings::OPTION_NAME, $defaults );
9083 $this->settings->clear_cache();
9084
9085 // Clear active preset
9086 update_option( 'vigilante_active_preset', '' );
9087
9088 // Apply file changes after reset
9089 $this->apply_all_file_changes( $defaults );
9090
9091 wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) . $this->locked_file_settings_message() );
9092 return;
9093 }
9094
9095 $presets = $this->settings->get_presets();
9096
9097 if ( ! isset( $presets[ $preset ] ) ) {
9098 wp_send_json_error( __( 'Invalid preset.', 'vigilante' ) );
9099 }
9100
9101 $preset_options = $presets[ $preset ];
9102 unset( $preset_options['name'], $preset_options['description'] );
9103
9104 // Layer the preset on top of the user's CURRENT configuration, not on
9105 // top of defaults. This way applying a preset only changes the fields
9106 // the preset explicitly mentions; everything else stays as the user
9107 // had it. For example, applying Maximum will not flip HSTS off if the
9108 // user had it on — Maximum doesn't touch HSTS, so it's left alone.
9109 // Use "Reset to Defaults" if a clean slate is needed.
9110 $current = get_option( Vigilante_Settings::OPTION_NAME, array() );
9111 if ( ! is_array( $current ) ) {
9112 $current = array();
9113 }
9114 // Make sure all known keys exist before merging — the merge does not
9115 // invent keys that are missing on both sides.
9116 $current = Vigilante_Settings::merge_preset( $this->settings->get_default_options(), $current );
9117
9118 $merged = Vigilante_Settings::merge_preset( $current, $preset_options );
9119 $merged = Vigilante_Settings::keep_locked_file_settings( $merged, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
9120
9121 update_option( Vigilante_Settings::OPTION_NAME, $merged );
9122 $this->settings->clear_cache();
9123
9124 // Save active preset
9125 update_option( 'vigilante_active_preset', $preset );
9126
9127 // Apply file changes after preset
9128 $this->apply_all_file_changes( $merged );
9129
9130 wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
9131 }
9132
9133 /**
9134 * AJAX: Reset a specific section to defaults
9135 */
9136 public function ajax_reset_section() {
9137 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
9138
9139 if ( ! current_user_can( 'manage_options' ) ) {
9140 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
9141 }
9142
9143 $section = isset( $_POST['section'] ) ? sanitize_key( $_POST['section'] ) : '';
9144
9145 if ( empty( $section ) ) {
9146 wp_send_json_error( __( 'No section specified.', 'vigilante' ) );
9147 }
9148
9149 // Get current options and defaults. get_defaults_preserving_user_data()
9150 // applies the tweaks a fresh installation gets, so the button and a new
9151 // install agree, and keeps whatever the owner typed in.
9152 $current_options = $this->settings->get_all_options();
9153 $defaults = Vigilante_Settings::get_defaults_preserving_user_data( $current_options );
9154
9155 // Check if section exists in defaults
9156 if ( ! isset( $defaults[ $section ] ) ) {
9157 wp_send_json_error( __( 'Invalid section.', 'vigilante' ) );
9158 }
9159
9160 $new_values = $defaults[ $section ];
9161
9162 /*
9163 * On a subsite, the settings written to wp-config.php and .htaccess are
9164 * the main site's business. Resetting the local copy of those would only
9165 * make this screen disagree with the file, so they are carried over
9166 * untouched, and a section that is nothing but shared settings is not
9167 * reset at all. On the main site, a user without network rights keeps
9168 * the ones the shared files are built from as well (2.11.6).
9169 */
9170 $locked = Vigilante_Settings::get_locked_file_settings();
9171
9172 if ( isset( $locked[ $section ] ) && true === $locked[ $section ] ) {
9173 wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
9174 }
9175
9176 $current_options[ $section ] = $new_values;
9177 $current_options = Vigilante_Settings::keep_locked_file_settings( $current_options, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
9178
9179 // Save
9180 update_option( Vigilante_Settings::OPTION_NAME, $current_options );
9181 $this->settings->clear_cache();
9182
9183 // Apply file changes if needed
9184 $this->apply_section_changes( $section, $current_options );
9185
9186 wp_send_json_success( array(
9187 'message' => __( 'Section reset to defaults.', 'vigilante' ),
9188 'section' => $section,
9189 'reload' => true,
9190 ) );
9191 }
9192
9193 /**
9194 * AJAX: Clear lockouts
9195 */
9196 public function ajax_clear_lockouts() {
9197 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
9198
9199 if ( ! current_user_can( 'manage_options' ) ) {
9200 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
9201 }
9202
9203 $ip = isset( $_POST['ip'] ) ? sanitize_text_field( wp_unslash( $_POST['ip'] ) ) : '';
9204
9205 if ( ! empty( $ip ) ) {
9206 $this->database->clear_lockout( $ip );
9207 } else {
9208 $this->database->clear_all_lockouts();
9209 }
9210
9211 wp_send_json_success( __( 'Lockouts cleared.', 'vigilante' ) );
9212 }
9213
9214 /**
9215 * AJAX: Clear logs
9216 */
9217 public function ajax_clear_logs() {
9218 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
9219
9220 if ( ! current_user_can( 'manage_options' ) ) {
9221 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
9222 }
9223
9224 if ( $this->activity_log ) {
9225 $result = $this->activity_log->clear_all_logs();
9226 if ( $result ) {
9227 wp_send_json_success( __( 'Logs cleared.', 'vigilante' ) );
9228 } else {
9229 wp_send_json_error( __( 'Failed to clear logs.', 'vigilante' ) );
9230 }
9231 } else {
9232 wp_send_json_error( __( 'Activity log not available.', 'vigilante' ) );
9233 }
9234 }
9235
9236 /**
9237 * AJAX: Run file integrity scan.
9238 *
9239 * Triggers the file integrity scan, which now also runs the closed plugins
9240 * check at the end when the `check_closed_plugins` toggle is on. Activity
9241 * log is passed through so Security Audit entries (both file-level and
9242 * plugin-status) are recorded from this entry point.
9243 */
9244 public function ajax_run_scan() {
9245 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
9246
9247 if ( ! current_user_can( 'manage_options' ) ) {
9248 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
9249 }
9250
9251 // Clear previous results before running new scan
9252 delete_option( 'vigilante_last_integrity_results' );
9253 delete_option( 'vigilante_last_integrity_scan' );
9254
9255 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
9256 $results = $file_integrity->run_scan();
9257
9258 // Save new results
9259 update_option( 'vigilante_last_integrity_scan', time() );
9260 update_option( 'vigilante_last_integrity_results', $results );
9261
9262 // On the main site the scan does compute the lines of wp-config.php and
9263 // .htaccess, for the network administrator. Somebody without network
9264 // rights gets the change and its sizes, not the lines.
9265 if ( $this->critical_approval_locked() && ! empty( $results['modified'] ) && is_array( $results['modified'] ) ) {
9266 foreach ( $results['modified'] as $index => $item ) {
9267 if ( is_array( $item ) && 'critical_config' === ( $item['type'] ?? '' ) ) {
9268 $results['modified'][ $index ]['diff'] = Vigilante_File_Integrity::network_only_diff();
9269 }
9270 }
9271 }
9272
9273 wp_send_json_success( array(
9274 'message' => __( 'Scan completed.', 'vigilante' ),
9275 'results' => $results,
9276 'ignored_count' => count( get_option( 'vigilante_ignored_files', array() ) ),
9277 ) );
9278 }
9279
9280 /**
9281 * AJAX: Clear scan results.
9282 *
9283 * Wipes visually everything inside "Last Scan Results": file scan findings
9284 * (Suspicious / Modified / Extra / Critical Config), file hashes and the
9285 * Closed + Removed Plugins block.
9286 *
9287 * Implementation detail to keep persistence intact:
9288 * - We delete the file scan options + hashes outright.
9289 * - For plugin status we ONLY delete the last_check timestamp — the state
9290 * map and the ignore list are preserved in DB. The render gates the
9291 * plugin_status subsections on last_check > 0, so they hide after
9292 * Clear (visual reset) and reappear on the next Run Scan Now with the
9293 * state intact. This avoids degrading a 'removed' slug (404 without
9294 * metadata) back to 'not_in_repo' on the next scan, which would
9295 * silently lose the alert.
9296 *
9297 * The Ignored Files list and the Ignored Closed + Removed Plugins list
9298 * are preserved on purpose; each has its own explicit "Clear All …"
9299 * button.
9300 */
9301 public function ajax_clear_scan() {
9302 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
9303
9304 if ( ! current_user_can( 'manage_options' ) ) {
9305 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
9306 }
9307
9308 $results = get_option( 'vigilante_last_integrity_results' );
9309 $scanned_at = get_option( 'vigilante_last_integrity_scan' );
9310
9311 delete_option( 'vigilante_last_integrity_results' );
9312 delete_option( 'vigilante_last_integrity_scan' );
9313
9314 // Starting over also means being told again: the next scan emails
9315 // whatever it finds, as if nothing had been reported before.
9316 delete_option( Vigilante_File_Integrity::NOTIFIED_OPTION );
9317
9318 /*
9319 * A pending change to wp-config.php or the root .htaccess is closed by
9320 * approving it, which takes the network. Clearing the results was one
9321 * more way to close it without, until the next scan: the ignore list was
9322 * shut in 2.11.8 and this button was left open, found by the cross
9323 * review of 2.11.8. So for somebody who cannot approve, those entries
9324 * stay and everything else goes.
9325 */
9326 // The findings about Vigilant's own files stay too: they report for the
9327 // whole network, and ignoring them already takes network rights.
9328 if ( $this->critical_approval_locked() && is_array( $results ) ) {
9329 $kept = array();
9330 $found = false;
9331 foreach ( array( 'modified', 'missing', 'suspicious', 'extra' ) as $bucket ) {
9332 $kept[ $bucket ] = array_values(
9333 array_filter(
9334 isset( $results[ $bucket ] ) && is_array( $results[ $bucket ] ) ? $results[ $bucket ] : array(),
9335 function ( $item ) {
9336 return is_array( $item ) && in_array( $item['type'] ?? '', array( 'critical_config', 'vigilante_self' ), true );
9337 }
9338 )
9339 );
9340 $found = $found || ! empty( $kept[ $bucket ] );
9341 }
9342
9343 if ( $found ) {
9344 $results = array_merge( $results, $kept );
9345 update_option( 'vigilante_last_integrity_results', $results );
9346 update_option( 'vigilante_last_integrity_scan', $scanned_at ? $scanned_at : time() );
9347 }
9348 }
9349
9350 if ( $this->database ) {
9351 $this->database->clear_file_hashes();
9352 }
9353
9354 // Visual reset of plugin_status block without touching the state map
9355 // or the ignored list. See PHPDoc above for the rationale.
9356 delete_option( 'vigilante_plugin_status_last_check' );
9357
9358 wp_send_json_success( __( 'Scan results cleared.', 'vigilante' ) );
9359 }
9360
9361 /**
9362 * AJAX: Ignore a file from scan results
9363 */
9364 public function ajax_ignore_file() {
9365 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
9366
9367 if ( ! current_user_can( 'manage_options' ) ) {
9368 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
9369 }
9370
9371 /*
9372 * The path as the scan reported it, which is what the interface sends
9373 * back. sanitize_text_field() is the wrong reader for it: it deletes
9374 * every %XX octet and collapses repeated spaces, so a file named
9375 * foto%20nueva.php was stored as fotonueva.php, the scan compared the
9376 * list with the paths it finds one by one, never matched, and the
9377 * warning came back (measured on 1 oct 2026 against 3.0.1). The four
9378 * handlers of the ignore list read the path the same way.
9379 *
9380 * Known limits, found by the cross review, that only a file name with a
9381 * stray < in it reaches. wp_strip_all_tags() shortens such a name at the
9382 * < instead of refusing it, so the stored entry can match another file
9383 * (a.php<b is stored as a.php), and the value ignore_file() returns is
9384 * not read, so a refusal of its own is answered as success. The fix, if
9385 * a site ever needs it, is to answer with an error when
9386 * wp_strip_all_tags() changes what was sent and to check that return
9387 * value. An entry the old reader stored for such a name (it wrote the
9388 * < as &lt;) is listed but "Stop ignoring" cannot remove it; "Clear all
9389 * ignored files" does.
9390 */
9391 $file = ( isset( $_POST['file'] ) && is_string( $_POST['file'] ) ) ? wp_strip_all_tags( wp_unslash( $_POST['file'] ) ) : '';
9392
9393 if ( empty( $file ) ) {
9394 wp_send_json_error( __( 'No file specified.', 'vigilante' ) );
9395 }
9396
9397 // A change to a shared file is closed by approving it, and approving it
9398 // takes the network. Ignoring it would close the same warning without.
9399 if ( $this->critical_approval_locked() && in_array( $file, array( 'wp-config.php', '.htaccess' ), true ) ) {
9400 wp_send_json_error( $this->critical_approval_notice() );
9401 }
9402
9403 // Vigilant's own files are never ignored, on any site: silencing the
9404 // check that says the security plugin was changed is the one button
9405 // an attacker would want on this screen. Since 3.0.0 those findings
9406 // are not rows of these tables either, so nothing in the interface
9407 // sends them here; this is the door, not the label.
9408 if ( Vigilante_Self_Integrity::is_own_file_path( $file ) ) {
9409 wp_send_json_error( __( 'Findings about Vigilant own files cannot be ignored. File Integrity explains what each one means and how to repair it.', 'vigilante' ) );
9410 }
9411
9412 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database );
9413 $file_integrity->ignore_file( $file );
9414
9415 // Also remove the file from stored scan results so UI updates
9416 $results = get_option( 'vigilante_last_integrity_results' );
9417 if ( $results ) {
9418 foreach ( array( 'modified', 'suspicious', 'extra' ) as $category ) {
9419 if ( ! empty( $results[ $category ] ) ) {
9420 $results[ $category ] = array_values(
9421 array_filter(
9422 $results[ $category ],
9423 function ( $item ) use ( $file ) {
9424 return ( is_array( $item ) ? ( $item['file'] ?? '' ) : (string) $item ) !== $file;
9425 }
9426 )
9427 );
9428 }
9429 }
9430 update_option( 'vigilante_last_integrity_results', $results );
9431 }
9432
9433 wp_send_json_success( __( 'File added to ignored list.', 'vigilante' ) );
9434 }
9435
9436 /**
9437 * AJAX: Stop ignoring a file
9438 */
9439 public function ajax_unignore_file() {
9440 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
9441
9442 if ( ! current_user_can( 'manage_options' ) ) {
9443 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
9444 }
9445
9446 $file = ( isset( $_POST['file'] ) && is_string( $_POST['file'] ) ) ? wp_strip_all_tags( wp_unslash( $_POST['file'] ) ) : '';
9447
9448 if ( empty( $file ) ) {
9449 wp_send_json_error( __( 'No file specified.', 'vigilante' ) );
9450 }
9451
9452 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database );
9453 $file_integrity->unignore_file( $file );
9454
9455 wp_send_json_success( __( 'File removed from ignored list.', 'vigilante' ) );
9456 }
9457
9458 /**
9459 * AJAX: Bulk ignore multiple files at once
9460 *
9461 * Processes a single batch into ignored list and prunes them from the
9462 * stored scan results so the UI updates without a re-scan.
9463 */
9464 public function ajax_bulk_ignore_files() {
9465 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
9466
9467 if ( ! current_user_can( 'manage_options' ) ) {
9468 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
9469 }
9470
9471 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below per-item.
9472 $raw_files = isset( $_POST['files'] ) ? wp_unslash( $_POST['files'] ) : array();
9473 if ( ! is_array( $raw_files ) ) {
9474 wp_send_json_error( __( 'Invalid request.', 'vigilante' ) );
9475 }
9476
9477 $files = array();
9478 $locked = $this->critical_approval_locked();
9479 $shared = $locked ? array( 'wp-config.php', '.htaccess' ) : array();
9480 foreach ( $raw_files as $f ) {
9481 $clean = is_string( $f ) ? wp_strip_all_tags( $f ) : '';
9482 // Same rule as ajax_ignore_file(): the two shared files when the
9483 // network locks them, and Vigilant's own files always.
9484 if ( '' !== $clean && ! in_array( $clean, $shared, true ) && ! Vigilante_Self_Integrity::is_own_file_path( $clean ) ) {
9485 $files[] = $clean;
9486 }
9487 }
9488
9489 if ( empty( $files ) ) {
9490 wp_send_json_error( __( 'No files selected.', 'vigilante' ) );
9491 }
9492
9493 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database );
9494 $count = 0;
9495 foreach ( $files as $file ) {
9496 $file_integrity->ignore_file( $file );
9497 $count++;
9498 }
9499
9500 // Also prune the stored scan results so the UI matches the new ignore list.
9501 $results = get_option( 'vigilante_last_integrity_results' );
9502 if ( $results ) {
9503 $files_set = array_flip( $files );
9504 foreach ( array( 'modified', 'suspicious', 'extra' ) as $category ) {
9505 if ( ! empty( $results[ $category ] ) ) {
9506 $results[ $category ] = array_values(
9507 array_filter(
9508 $results[ $category ],
9509 function ( $item ) use ( $files_set ) {
9510 $path = is_array( $item ) ? ( $item['file'] ?? '' ) : (string) $item;
9511 return ! isset( $files_set[ $path ] );
9512 }
9513 )
9514 );
9515 }
9516 }
9517 update_option( 'vigilante_last_integrity_results', $results );
9518 }
9519
9520 wp_send_json_success(
9521 array(
9522 'count' => $count,
9523 'message' => sprintf(
9524 /* translators: %d: number of files added to the ignored list */
9525 _n( '%d file added to ignored list.', '%d files added to ignored list.', $count, 'vigilante' ),
9526 $count
9527 ),
9528 )
9529 );
9530 }
9531
9532 /**
9533 * AJAX: Bulk un-ignore multiple files at once
9534 */
9535 public function ajax_bulk_unignore_files() {
9536 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
9537
9538 if ( ! current_user_can( 'manage_options' ) ) {
9539 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
9540 }
9541
9542 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below per-item.
9543 $raw_files = isset( $_POST['files'] ) ? wp_unslash( $_POST['files'] ) : array();
9544 if ( ! is_array( $raw_files ) ) {
9545 wp_send_json_error( __( 'Invalid request.', 'vigilante' ) );
9546 }
9547
9548 $files = array();
9549 foreach ( $raw_files as $f ) {
9550 $clean = is_string( $f ) ? wp_strip_all_tags( $f ) : '';
9551 if ( '' !== $clean ) {
9552 $files[] = $clean;
9553 }
9554 }
9555
9556 if ( empty( $files ) ) {
9557 wp_send_json_error( __( 'No files selected.', 'vigilante' ) );
9558 }
9559
9560 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database );
9561 $count = 0;
9562 foreach ( $files as $file ) {
9563 $file_integrity->unignore_file( $file );
9564 $count++;
9565 }
9566
9567 wp_send_json_success(
9568 array(
9569 'count' => $count,
9570 'message' => sprintf(
9571 /* translators: %d: number of files removed from the ignored list */
9572 _n( '%d file removed from ignored list.', '%d files removed from ignored list.', $count, 'vigilante' ),
9573 $count
9574 ),
9575 )
9576 );
9577 }
9578
9579 /**
9580 * AJAX: Clear all ignored files
9581 */
9582 public function ajax_clear_ignored() {
9583 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
9584
9585 if ( ! current_user_can( 'manage_options' ) ) {
9586 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
9587 }
9588
9589 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database );
9590 $file_integrity->clear_ignored_files();
9591
9592 wp_send_json_success( __( 'Ignored files list cleared.', 'vigilante' ) );
9593 }
9594
9595 /**
9596 * AJAX: Ignore a closed/removed plugin slug so it stops appearing in the
9597 * main list and email digests. The plugin keeps running on the site;
9598 * silencing is purely cosmetic and reversible.
9599 */
9600 public function ajax_ignore_closed_plugin() {
9601 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
9602
9603 if ( ! current_user_can( 'manage_options' ) ) {
9604 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
9605 }
9606
9607 $slug = isset( $_POST['slug'] ) ? sanitize_key( wp_unslash( $_POST['slug'] ) ) : '';
9608 if ( '' === $slug ) {
9609 wp_send_json_error( __( 'No slug specified.', 'vigilante' ) );
9610 }
9611
9612 if ( ! class_exists( 'Vigilante_Plugin_Status' ) ) {
9613 require_once VIGILANTE_INCLUDES_DIR . 'class-plugin-status.php';
9614 }
9615 $checker = new Vigilante_Plugin_Status( $this->settings, $this->activity_log );
9616 $checker->ignore_slug( $slug );
9617
9618 wp_send_json_success( __( 'Plugin added to the ignored list.', 'vigilante' ) );
9619 }
9620
9621 /**
9622 * AJAX: Stop ignoring a previously-ignored closed/removed plugin slug.
9623 */
9624 public function ajax_unignore_closed_plugin() {
9625 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
9626
9627 if ( ! current_user_can( 'manage_options' ) ) {
9628 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
9629 }
9630
9631 $slug = isset( $_POST['slug'] ) ? sanitize_key( wp_unslash( $_POST['slug'] ) ) : '';
9632 if ( '' === $slug ) {
9633 wp_send_json_error( __( 'No slug specified.', 'vigilante' ) );
9634 }
9635
9636 if ( ! class_exists( 'Vigilante_Plugin_Status' ) ) {
9637 require_once VIGILANTE_INCLUDES_DIR . 'class-plugin-status.php';
9638 }
9639 $checker = new Vigilante_Plugin_Status( $this->settings, $this->activity_log );
9640 $checker->unignore_slug( $slug );
9641
9642 wp_send_json_success( __( 'Plugin removed from the ignored list.', 'vigilante' ) );
9643 }
9644
9645 /**
9646 * AJAX: Clear the entire ignored-closed-plugins list.
9647 */
9648 public function ajax_clear_ignored_closed_plugins() {
9649 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
9650
9651 if ( ! current_user_can( 'manage_options' ) ) {
9652 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
9653 }
9654
9655 if ( ! class_exists( 'Vigilante_Plugin_Status' ) ) {
9656 require_once VIGILANTE_INCLUDES_DIR . 'class-plugin-status.php';
9657 }
9658 $checker = new Vigilante_Plugin_Status( $this->settings, $this->activity_log );
9659 $checker->clear_ignored();
9660
9661 wp_send_json_success( __( 'Ignored closed plugins list cleared.', 'vigilante' ) );
9662 }
9663
9664 /**
9665 * AJAX: Test security headers
9666 */
9667 public function ajax_test_headers() {
9668 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
9669
9670 if ( ! current_user_can( 'manage_options' ) ) {
9671 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
9672 }
9673
9674 $results = $this->headers_test_results();
9675 if ( is_wp_error( $results ) ) {
9676 wp_send_json_error( $results->get_error_message() );
9677 }
9678
9679 wp_send_json_success( $results );
9680 }
9681
9682 /**
9683 * What the Test Headers button shows: the headers that reach the browser
9684 *
9685 * Asks the home page and runs the header checks of Security Check on the
9686 * answer, so the two can never say different things about the same site.
9687 * Until 3.0.4 the button read the settings of the tab, graded them on a
9688 * scale of its own and listed them as enabled headers: on a server that
9689 * does not apply the rules of Vigilant it showed a B while Security
9690 * Check, which does look, said that no header arrived.
9691 *
9692 * @since 3.0.5
9693 *
9694 * The lists carry text read from the answer of the site (the value of a
9695 * header), so whoever prints them escapes them: displayHeaderResults()
9696 * in assets/js/admin.js.
9697 *
9698 * They travel as passed, failing and notes. The names they had until
9699 * 3.0.4 (headers, missing and warnings) stay in the answer, empty: a
9700 * page that was open when the plugin was updated still runs the script
9701 * it loaded, which prints those three without escaping them, and it must
9702 * get the grade and no text read from the site.
9703 *
9704 * @return array|WP_Error grade, score (0 to 100), passed, failing and notes.
9705 */
9706 private function headers_test_results() {
9707 if ( ! class_exists( 'Vigilante_Security_Analyzer' ) ) {
9708 require_once VIGILANTE_INCLUDES_DIR . 'class-security-analyzer.php';
9709 }
9710 // The constructor loads the checks and their helpers.
9711 new Vigilante_Security_Analyzer( $this->settings, $this->activity_log );
9712 $no_answer = new WP_Error( 'vigilante_no_answer', __( 'Could not fetch the homepage to read response headers.', 'vigilante' ) );
9713 // A new request every time: the button is pressed right after saving.
9714 // With no answer there is nothing to grade, and the checks would ask
9715 // for the page a second time.
9716 if ( null === Vigilante_SA_Helpers::probe_home( true ) ) {
9717 return $no_answer;
9718 }
9719
9720 $category = new Vigilante_SA_Category_Headers( $this->settings );
9721 $passed = array();
9722 $failed = array();
9723 $notes = array();
9724 $earned = 0;
9725 $max = 0;
9726
9727 foreach ( $category->run( 'slow' ) as $check ) {
9728 if ( Vigilante_SA_Check_Result::STATE_SKIP === $check->state ) {
9729 continue;
9730 }
9731 if ( Vigilante_SA_Check_Result::STATE_INFO === $check->state ) {
9732 $notes[] = $check->detail;
9733 continue;
9734 }
9735 $earned += (int) $check->score;
9736 $max += (int) $check->max;
9737 // The three lists are the three counters of Security Check.
9738 if ( Vigilante_SA_Check_Result::STATE_PASS === $check->state ) {
9739 $passed[] = $check->detail;
9740 } elseif ( Vigilante_SA_Check_Result::STATE_WARN === $check->state ) {
9741 $notes[] = $check->detail;
9742 } else {
9743 $failed[] = $check->detail;
9744 }
9745 }
9746
9747 if ( $max < 1 ) {
9748 return $no_answer;
9749 }
9750
9751 $score = (int) round( 100 * $earned / $max );
9752 $sender = new Vigilante_Security_Headers( $this->settings );
9753
9754 return array(
9755 'grade' => Vigilante_SA_Helpers::grade_for_score( $score ),
9756 'score' => $score,
9757 'passed' => $passed,
9758 'failing' => $failed,
9759 'notes' => array_merge( $notes, $sender->get_csp_warnings() ),
9760 // For the script of a page opened before the update: see above.
9761 'headers' => array(),
9762 'missing' => array(),
9763 'warnings' => array(),
9764 );
9765 }
9766
9767 /**
9768 * Sanitize section data (kept for compatibility)
9769 *
9770 * @param string $section Section name.
9771 * @param array $data Section data.
9772 * @return array
9773 */
9774 private function sanitize_section_data( $section, $data ) {
9775 $sanitized = array();
9776
9777 foreach ( $data as $key => $value ) {
9778 $key = sanitize_key( $key );
9779
9780 if ( is_array( $value ) ) {
9781 $sanitized[ $key ] = $this->sanitize_section_data( $key, $value );
9782 } elseif ( is_numeric( $value ) ) {
9783 $sanitized[ $key ] = intval( $value );
9784 } else {
9785 $sanitized[ $key ] = sanitize_text_field( $value );
9786 }
9787 }
9788
9789 return $sanitized;
9790 }
9791
9792 /**
9793 * Apply changes after saving settings
9794 *
9795 * @param string $section Section that was updated.
9796 * @param array $all_options All options.
9797 */
9798 private function apply_section_changes( $section, $all_options ) {
9799 // Create fresh settings instance to ensure we have the latest data
9800 $fresh_settings = new Vigilante_Settings();
9801
9802 // The shared Vigilant .htaccess block carries BOTH the firewall's
9803 // file-protection rules and the server-signature / fingerprinting rules
9804 // that are configured under Security Headers. So it must be regenerated
9805 // whenever either section (or the module toggles) changes, not only on
9806 // the firewall save — otherwise toggling "Hide server signature" or
9807 // "Remove fingerprinting headers" never reaches the .htaccess.
9808 if ( in_array( $section, array( 'firewall', 'security_headers', 'modules' ), true ) ) {
9809 $htaccess = new Vigilante_Htaccess_Protection( $fresh_settings );
9810 $sh = $fresh_settings->get_section( 'security_headers' );
9811 $needs_htaccess_block = ! empty( $all_options['modules']['firewall'] )
9812 || ! empty( $sh['hide_server_signature'] )
9813 || ! empty( $sh['remove_fingerprinting_headers'] );
9814
9815 if ( $needs_htaccess_block ) {
9816 $htaccess->apply_rules();
9817 } else {
9818 $htaccess->remove_rules();
9819 }
9820 }
9821
9822 // Regenerate the HTTP security headers (sent by PHP) for the headers section.
9823 if ( 'security_headers' === $section || 'modules' === $section ) {
9824 $security_headers = new Vigilante_Security_Headers( $fresh_settings );
9825 $headers_enabled = ! empty( $all_options['modules']['security_headers'] );
9826
9827 if ( $headers_enabled ) {
9828 $security_headers->apply_rules();
9829 } else {
9830 $security_headers->remove_rules();
9831 }
9832 }
9833
9834 // Regenerate wp-config for wp_hardening section
9835 if ( 'wp_hardening' === $section || 'modules' === $section ) {
9836 $wpconfig = new Vigilante_Wpconfig_Security( $fresh_settings );
9837 $hardening_enabled = ! empty( $all_options['modules']['wp_hardening'] );
9838
9839 if ( $hardening_enabled ) {
9840 $wpconfig->apply_security_constants();
9841 } else {
9842 $wpconfig->remove_constants();
9843 }
9844
9845 // Apply WordPress options for comments/pingbacks
9846 $hardening_options = $all_options['wp_hardening'] ?? array();
9847
9848 if ( $hardening_enabled ) {
9849 // Pingbacks
9850 if ( ! empty( $hardening_options['disable_pingbacks'] ) ) {
9851 update_option( 'default_pingback_flag', 0 );
9852 } else {
9853 // Restore default: pingbacks enabled
9854 update_option( 'default_pingback_flag', 1 );
9855 }
9856
9857 // Trackbacks and ping status
9858 // Only close if either pingbacks OR trackbacks are disabled
9859 if ( ! empty( $hardening_options['disable_pingbacks'] ) || ! empty( $hardening_options['disable_trackbacks'] ) ) {
9860 update_option( 'default_ping_status', 'closed' );
9861 } else {
9862 // Restore default: pings open
9863 update_option( 'default_ping_status', 'open' );
9864 }
9865
9866 // Comment moderation
9867 if ( ! empty( $hardening_options['require_comment_moderation'] ) ) {
9868 update_option( 'comment_moderation', 1 );
9869 } else {
9870 // Restore default: no moderation required
9871 update_option( 'comment_moderation', 0 );
9872 }
9873 }
9874 }
9875
9876 // Trim activity log entries immediately when limits change
9877 if ( 'activity_log' === $section && $this->activity_log ) {
9878 $this->activity_log->cleanup_old_logs();
9879 }
9880
9881 // Flush rewrite rules if login URL changed
9882 if ( 'login_security' === $section ) {
9883 $login_options = $all_options['login_security'] ?? array();
9884 if ( ! empty( $login_options['custom_login_url'] ) ) {
9885 delete_option( 'vigilante_login_rules_version' );
9886 }
9887 }
9888
9889 // Log the settings change with readable section name
9890 if ( $this->activity_log ) {
9891 $section_names = array(
9892 'firewall' => __( 'Firewall', 'vigilante' ),
9893 'login_security' => __( 'Login Security', 'vigilante' ),
9894 'security_headers' => __( 'Security Headers', 'vigilante' ),
9895 'rest_api_security'=> __( 'REST API Security', 'vigilante' ),
9896 'user_security' => __( 'User Security', 'vigilante' ),
9897 'wp_hardening' => __( 'WP Hardening', 'vigilante' ),
9898 'activity_log' => __( 'Security Audit', 'vigilante' ),
9899 'file_integrity' => __( 'File Integrity', 'vigilante' ),
9900 'email' => __( 'Notification Settings', 'vigilante' ),
9901 'backup' => __( 'Backup', 'vigilante' ),
9902 'advanced' => __( 'Advanced', 'vigilante' ),
9903 'modules' => __( 'Modules', 'vigilante' ),
9904 );
9905 $display_name = isset( $section_names[ $section ] ) ? $section_names[ $section ] : $section;
9906
9907 $this->activity_log->log(
9908 'settings',
9909 'settings_updated',
9910 sprintf(
9911 /* translators: %s: Section name */
9912 __( 'Settings updated: %s', 'vigilante' ),
9913 $display_name
9914 ),
9915 array( 'section' => $section ),
9916 'info'
9917 );
9918 }
9919 }
9920
9921 /**
9922 * Apply all file changes (htaccess, wp-config) based on current options
9923 *
9924 * Used after preset, import, or reset operations
9925 *
9926 * @param array $all_options All plugin options.
9927 */
9928 private function apply_all_file_changes( $all_options ) {
9929 // Refresh settings cache first
9930 $this->settings->clear_cache();
9931
9932 // Create fresh settings instance
9933 $fresh_settings = new Vigilante_Settings();
9934
9935 // Apply firewall htaccess changes
9936 $htaccess = new Vigilante_Htaccess_Protection( $fresh_settings );
9937 $firewall_enabled = ! empty( $all_options['modules']['firewall'] );
9938
9939 if ( $firewall_enabled ) {
9940 $htaccess->apply_rules();
9941 } else {
9942 $htaccess->remove_rules();
9943 }
9944
9945 // Apply security headers htaccess changes
9946 $security_headers = new Vigilante_Security_Headers( $fresh_settings );
9947 $headers_enabled = ! empty( $all_options['modules']['security_headers'] );
9948
9949 if ( $headers_enabled ) {
9950 $security_headers->apply_rules();
9951 } else {
9952 $security_headers->remove_rules();
9953 }
9954
9955 // Apply wp-config changes
9956 $wpconfig = new Vigilante_Wpconfig_Security( $fresh_settings );
9957 $hardening_enabled = ! empty( $all_options['modules']['wp_hardening'] );
9958
9959 if ( $hardening_enabled ) {
9960 $wpconfig->apply_security_constants();
9961 } else {
9962 $wpconfig->remove_constants();
9963 }
9964
9965 // Apply WordPress options for comments/pingbacks
9966 $hardening_options = $all_options['wp_hardening'] ?? array();
9967
9968 if ( $hardening_enabled ) {
9969 // Pingbacks
9970 if ( ! empty( $hardening_options['disable_pingbacks'] ) ) {
9971 update_option( 'default_pingback_flag', 0 );
9972 } else {
9973 // Restore default: pingbacks enabled
9974 update_option( 'default_pingback_flag', 1 );
9975 }
9976
9977 // Trackbacks and ping status
9978 // Only close if either pingbacks OR trackbacks are disabled
9979 if ( ! empty( $hardening_options['disable_pingbacks'] ) || ! empty( $hardening_options['disable_trackbacks'] ) ) {
9980 update_option( 'default_ping_status', 'closed' );
9981 } else {
9982 // Restore default: pings open
9983 update_option( 'default_ping_status', 'open' );
9984 }
9985
9986 // Comment moderation
9987 if ( ! empty( $hardening_options['require_comment_moderation'] ) ) {
9988 update_option( 'comment_moderation', 1 );
9989 } else {
9990 // Restore default: no moderation required
9991 update_option( 'comment_moderation', 0 );
9992 }
9993 }
9994
9995 // Log the change
9996 if ( $this->activity_log ) {
9997 $this->activity_log->log(
9998 'settings',
9999 'bulk_settings_applied',
10000 __( 'Bulk settings applied (preset/import/reset)', 'vigilante' ),
10001 array(),
10002 'info'
10003 );
10004 }
10005 }
10006 }