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

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

1,228 lines 52.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Settings Class
4 *
5 * Centralized settings management with default values
6 *
7 * @package Vigilante
8 */
9
10 // Prevent direct access
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Class Vigilante_Settings
17 *
18 * Handles all plugin settings with defaults, getters and setters
19 */
20 class Vigilante_Settings {
21
22 /**
23 * Option name in database
24 */
25 const OPTION_NAME = 'vigilante_options';
26
27 /**
28 * Cached options
29 *
30 * @var array|null
31 */
32 private $options = null;
33
34 /**
35 * Default options structure
36 *
37 * @var array
38 */
39 private $defaults;
40
41 /**
42 * Constructor
43 */
44 public function __construct() {
45 $this->defaults = $this->get_default_options();
46 }
47
48 /**
49 * Get all default options
50 *
51 * @return array Complete default options array
52 */
53 public function get_default_options() {
54 return array(
55 // Module toggles - 8 modules that match tabs
56 'modules' => array(
57 'firewall' => true,
58 'security_headers' => true,
59 'login_security' => true,
60 'rest_api_security'=> true,
61 'user_security' => true,
62 'wp_hardening' => true,
63 'file_integrity' => true,
64 'activity_log' => true,
65 ),
66
67 // Firewall settings (includes htaccess, rate limiting, file protection)
68 'firewall' => array(
69 // Request filtering (PHP-based)
70 'block_bad_query_strings' => true,
71 'block_sql_injection' => true,
72 'block_xss_attacks' => true,
73 'block_file_inclusion' => true,
74 'block_directory_traversal' => true,
75
76 // Bot protection
77 'block_bad_bots' => true,
78 'block_empty_user_agent' => false,
79
80 // Rate limiting
81 'rate_limiting' => array(
82 'enabled' => true,
83 'requests_per_minute' => 120,
84 'block_duration' => 300,
85 'progressive' => false,
86 'max_block_duration' => 86400,
87 ),
88
89 // IP management
90 'ip_whitelist' => array(),
91 'ip_blacklist' => array(),
92
93 // Proxy / CDN: forwarded header to trust for the visitor IP.
94 // Empty = trust only REMOTE_ADDR (the real connection, unspoofable).
95 'trusted_proxy_header' => '',
96
97 // User-Agent management
98 'ua_whitelist' => array(),
99 'ua_blacklist' => array(),
100 'country_blocking' => array(
101 'enabled' => false,
102 'mode' => 'blacklist',
103 'countries' => array(),
104 ),
105
106 // File protection (htaccess-based)
107 'disable_directory_browsing' => true,
108 'protect_wp_config' => true,
109 'protect_wp_includes' => true,
110 'protect_uploads_php' => true,
111 'protect_sensitive_files' => true,
112 // Off by default — only safe when host has a real server-side cron job
113 // calling wp-cron.php; otherwise scheduled tasks stop running silently.
114 'protect_wp_cron' => false,
115 'block_php_in_plugins' => false,
116 'block_php_in_themes' => false,
117 'limit_http_methods' => true,
118 // All methods needed for WordPress core, Gutenberg, REST API, and page builders
119 'allowed_http_methods' => array( 'GET', 'POST', 'HEAD', 'OPTIONS', 'PUT', 'PATCH', 'DELETE' ),
120 'protected_file_extensions' => array(
121 'htaccess', 'htpasswd', 'ini', 'log', 'sql',
122 'bak', 'old', 'tmp', 'swp', 'save', 'backup'
123 ),
124 ),
125
126 // Security Headers settings (includes HTTPS enforcer)
127 'security_headers' => array(
128 'enabled' => true,
129
130 // Basic headers
131 'x_frame_options' => 'SAMEORIGIN',
132 'x_content_type_options' => true,
133 'referrer_policy' => 'strict-origin-when-cross-origin',
134
135 // HSTS
136 'hsts' => array(
137 'enabled' => false,
138 'max_age' => 31536000,
139 'include_subdomains' => false,
140 'preload' => false,
141 ),
142
143 // Permissions Policy
144 'permissions_policy' => array(
145 'enabled' => true,
146 'geolocation' => '()',
147 'microphone' => '()',
148 'camera' => '()',
149 'payment' => '(self)',
150 'usb' => '()',
151 ),
152
153 // CSP - WordPress/Gutenberg compatible defaults
154 // Note: blob: is required in frame-src and worker-src for the block editor,
155 // and in connect-src for the client-side media processing WordPress 7.1
156 // introduced: @wordpress/vips puts its WebAssembly binary in a blob: URL and
157 // fetches it, and fetch() is governed by connect-src, where 'self' does not
158 // cover blob:. Without it the editor cannot process images before upload.
159 'csp' => array(
160 'enabled' => true,
161 'report_only' => false,
162 'report_uri' => '',
163 'directives' => array(
164 'default-src' => "'self'",
165 'script-src' => "'self' 'unsafe-inline' 'unsafe-eval' https:",
166 'style-src' => "'self' 'unsafe-inline' https:",
167 'img-src' => "'self' data: https: blob:",
168 'font-src' => "'self' data: https:",
169 'connect-src' => "'self' https: wss: blob:",
170 'media-src' => "'self' https: blob:",
171 'frame-src' => "'self' https: blob:",
172 'frame-ancestors' => "'self'",
173 'base-uri' => "'self'",
174 'form-action' => "'self' https:",
175 'object-src' => "'none'",
176 'worker-src' => "'self' blob:",
177 'upgrade-insecure-requests'=> true,
178 ),
179 ),
180
181 // Cross-origin policies
182 'cross_origin_policies' => array(
183 'embedder_policy' => 'unsafe-none',
184 'opener_policy' => 'same-origin-allow-popups',
185 'resource_policy' => 'cross-origin',
186 ),
187
188 // HTTPS Enforcer (moved from separate module)
189 //
190 // force_https rewrites siteurl/home to https on activation, so it
191 // ships off: a site without working HTTPS would end up pointing at
192 // an address that does not answer. It is an opt-in decision per
193 // site, made from the Security Headers tab. Sites whose URLs a
194 // previous version already rewrote keep them; nothing reverts them.
195 'force_https' => false,
196 // Off by default for the same reason as force_https above: the
197 // plugin does not decide that a site is on HTTPS. It only
198 // redirects when the site's own home URL already says https, so
199 // shipping it on was harmless in practice, but it is still a
200 // decision that belongs to the site owner, not to us. Sites that
201 // already have it on keep it.
202 'redirect_http_to_https' => false,
203 // Rewrites http:// URLs of this same site to https://, and only
204 // on a site already served over HTTPS, so it cannot reach a third
205 // party and cannot make a resource fail. It still ships off: a
206 // setting whose own description says it rewrites http to https
207 // does not belong in the factory configuration of a plugin that
208 // deliberately does not decide whether a site is on HTTPS. Every
209 // https-related setting here is the owner's call, and this one is
210 // one click away for anyone who has just migrated and wants their
211 // old content rewritten.
212 'fix_mixed_content' => false,
213 // The Content-Security-Policy directive that tells the browser to
214 // upgrade every http:// request, including the ones pointing at
215 // other people's servers. If any of those has no HTTPS the
216 // resource simply stops loading, so this is the one piece of
217 // mixed content handling that can break a page, and it is off by
218 // default like everything else here that forces HTTPS. It used to
219 // ride along with fix_mixed_content with no way to separate them.
220 'upgrade_insecure_requests' => false,
221
222 // Server Protection (moved from firewall in v2.0.0)
223 'hide_server_signature' => true,
224 'remove_fingerprinting_headers' => true,
225 ),
226
227 // Login Security settings
228 'login_security' => array(
229 'enabled' => true,
230 'max_attempts' => 5,
231 'lockout_duration' => 1800,
232 'lockout_increment' => true,
233 'max_lockout_duration' => 86400,
234 'hide_login_errors' => true,
235 // XML-RPC se movio a wp_hardening en la 2.9.7; lo resuelve
236 // Vigilante_Comment_Security::resolve_xmlrpc_mode(), que
237 // sustituye a las dos casillas anteriores (disable_xmlrpc y
238 // disable_xmlrpc_pingback), que podian estar activas a la vez y
239 // contradecirse. A proposito NO se declara aqui ningun default: si se
240 // declarara, el merge con los defaults lo rellenaria siempre y taparia el
241 // respaldo que lee el ajuste antiguo de los sitios que aun no han vuelto a
242 // guardar la pestana. Sin nada guardado, el resolutor devuelve 'full', que
243 // es lo que hacia el default anterior.
244 'disable_application_passwords' => false,
245 'notify_on_lockout' => false,
246 'notify_on_admin_login' => false,
247 'ip_whitelist' => array(),
248 'custom_login_url' => '',
249 'notify_on_login_url_change' => true,
250 // Two-Factor Authentication
251 'two_factor' => array(
252 'enabled' => false,
253 'method' => 'email',
254 'enforced_roles' => array( 'administrator', 'editor' ),
255 'excluded_users' => array(),
256 'remember_device_days' => 30,
257 'allow_remember_device' => false,
258 'code_expiry_minutes' => 10,
259 'max_attempts' => 3,
260 'email_from_name' => '',
261 'notify_on_enable' => true,
262 'grace_period_days' => 3,
263 ),
264 ),
265
266 // REST API Security settings
267 'rest_api_security' => array(
268 'enabled' => true,
269 'mode' => 'selective',
270 'block_user_enumeration' => true,
271 'disable_jsonp' => true,
272 // Empty by default: /wp/v2/users used to live here, but that
273 // duplicated the dedicated "Block user enumeration" toggle.
274 // Now there is one knob = one behaviour. If you want to
275 // protect additional endpoints in selective mode, add them
276 // explicitly via this setting (or via a filter).
277 'protected_endpoints' => array(),
278 'allowed_public_endpoints' => array(
279 '/wp/v2/posts',
280 '/wp/v2/pages',
281 '/wp/v2/categories',
282 '/wp/v2/tags',
283 '/oembed/',
284 ),
285 'plugin_compatibility' => array(
286 'woocommerce' => true,
287 'contact_form_7' => true,
288 'elementor' => true,
289 ),
290 ),
291
292 // User Security settings
293 'user_security' => array(
294 'enabled' => true,
295 'block_insecure_usernames'=> true,
296 'insecure_usernames' => array(
297 'admin', 'administrator', 'user', 'test', 'guest',
298 'info', 'root', 'adm', 'sysadmin', 'support',
299 'webmaster', 'master', 'owner', 'manager', 'demo',
300 ),
301 'block_author_scanning' => true,
302 'force_strong_passwords' => true,
303 'min_password_length' => 12,
304
305 // Granular password policy. Applies only while
306 // force_strong_passwords is on. The defaults follow current
307 // guidance (NIST SP 800-63B): what makes a password weak is being
308 // guessable, not lacking a symbol, and composition rules push
309 // people towards predictable substitutions and towards writing
310 // the password down. So out of the box only the two rules that
311 // block genuinely guessable passwords are on, and the four
312 // character-class requirements ship off for anyone to turn on.
313 // Existing sites keep whatever they have stored.
314 // affected_roles empty = all roles.
315 'password_policy' => array(
316 'require_uppercase' => false,
317 'require_lowercase' => false,
318 'require_number' => false,
319 'require_special' => false,
320 'block_common' => true,
321 'block_username' => true,
322 'affected_roles' => array(),
323 ),
324
325 'prevent_display_name_login_match' => true,
326
327 // Admin monitoring
328 // The two alerts that report someone gaining power ship on: a
329 // new administrator and a role being raised are the signature of
330 // an account takeover, and they are rare enough not to be noise.
331 // The other two are ordinary admin housekeeping and stay opt-in.
332 'admin_monitoring' => array(
333 'alert_new_admin' => true,
334 'alert_admin_email_change' => false,
335 'alert_permission_elevation' => true,
336 'alert_admin_password_change' => false,
337 ),
338
339 // Force password reset (no options, uses native WordPress flow)
340
341 // Registration approval
342 'registration_approval' => array(
343 'enabled' => false,
344 'notify_admin' => false,
345 'auto_reject_days' => 0,
346 'affected_roles' => array( 'subscriber' ),
347 ),
348
349 // Session management
350 'session_management' => array(
351 'enabled' => true,
352 'show_in_profile' => true,
353 ),
354
355 // Session limits
356 'session_limits' => array(
357 'enabled' => true,
358 'max_sessions' => 3,
359 'behavior' => 'close_oldest',
360 'exclude_admins' => false,
361 ),
362
363 // Password expiration ships OFF. Forced rotation is no longer
364 // recommended (NIST SP 800-63B advises against it) and it is by
365 // far the biggest source of support here: people locked out mid
366 // task, cron reminders that never arrive, roles nobody meant to
367 // include. The feature stays for anyone who has to comply with a
368 // policy that still demands it, and the Configuration Score keeps
369 // pointing at it, which is what that score is for.
370 'password_expiration' => array(
371 'enabled' => false,
372 'expire_days' => 90,
373 'warning_days' => 14,
374 'affected_roles' => array( 'administrator', 'editor' ),
375 'excluded_users' => array(),
376 'password_history' => 3,
377 'send_reminder' => false,
378 ),
379
380 // Email verification
381 'email_verification' => array(
382 'enabled' => false,
383 'token_expiry_hours' => 24,
384 'allow_resend' => true,
385 'auto_delete_days' => 7,
386 ),
387 ),
388
389 // WordPress Hardening (combines wp-config, comments, feeds, head cleaner)
390 'wp_hardening' => array(
391 'enabled' => true,
392
393 // wp-config security
394 'disallow_file_edit' => true,
395 'disallow_file_mods' => false,
396 // Off by default. This one writes FORCE_SSL_ADMIN into
397 // wp-config.php, and it used to do so on activation with no
398 // check that the site answers over HTTPS at all, which locks the
399 // owner out of their own admin. Forcing HTTPS is an opt-in
400 // decision per site, consistent with force_https and with HSTS,
401 // both of which already ship off.
402 'force_ssl_admin' => false,
403 'wp_debug' => true,
404 // Off by default — only safe when host has a real server-side cron job;
405 // pairs with firewall.protect_wp_cron to block both internal triggering
406 // (this constant) and external HTTP abuse (the .htaccess rule).
407 'disable_wp_cron' => false,
408
409 // Comment security
410 'disable_pingbacks' => true,
411 'disable_trackbacks' => true,
412 'require_comment_moderation' => true,
413 'close_old_comments' => false,
414 'close_comments_after_days' => 30,
415 'honeypot_comments' => true,
416
417 // Head cleaner
418 'remove_wp_generator' => true,
419 'remove_wp_version_assets' => false,
420 'remove_rsd_link' => true,
421 'remove_wlw_manifest' => true,
422 'remove_shortlink' => true,
423 'remove_rest_api_link' => false,
424
425 // Feed manager
426 'disable_feeds' => false,
427 'disable_if_no_content' => true,
428 'remove_feed_version' => true,
429 ),
430
431 // File Integrity settings
432 'file_integrity' => array(
433 'enabled' => true,
434 'scan_core' => true,
435 'scan_plugins' => true,
436 'scan_themes' => true,
437 'scan_uploads' => true,
438 'scan_critical_config' => true,
439 'check_closed_plugins' => true,
440 'auto_scan' => true,
441 'scan_frequency' => 'daily',
442 'notify_level' => 'suspicious_only',
443 'instant_alert' => false,
444 'excluded_paths' => array(
445 'wp-content/cache',
446 ),
447 'excluded_extensions' => array(
448 // Translations (regenerated per-locale, never in checksums).
449 '.po', '.mo', '.pot',
450 // Binary images (cosmetic, not executable; often rewritten by image-optimizer plugins).
451 '.jpg', '.jpeg', '.png', '.gif', '.ico', '.webp', '.avif',
452 // Stylesheets: frequently rewritten by themes and optimizer
453 // plugins, a common source of post-update false positives.
454 // Strict-mode users can remove it (CSS injection is still a
455 // vector, defended primarily by CSP in the headers module).
456 '.css',
457 ),
458 'suspicious_patterns' => array(
459 'eval(',
460 'base64_decode(',
461 'gzinflate(',
462 'str_rot13(',
463 'exec(',
464 'shell_exec(',
465 'system(',
466 'passthru(',
467 'assert(',
468 ),
469 ),
470
471 // Activity Log settings
472 'activity_log' => array(
473 'retention_days' => 30,
474 'max_entries' => 10000,
475 'log_logins' => true,
476 'log_failed_logins' => true,
477 'log_user_changes' => true,
478 'log_post_changes' => true,
479 'log_plugin_changes' => true,
480 'log_theme_changes' => true,
481 'log_option_changes' => false,
482 'log_file_changes' => true,
483 'log_comments' => true,
484 'log_media' => true,
485 'excluded_users' => array(),
486 'excluded_ips' => array(),
487 'tracked_options' => array(),
488 ),
489
490 // Backup settings
491 'backup' => array(
492 'auto_backup' => true,
493 'backup_before_update' => true,
494 'keep_backups' => 5,
495 ),
496
497 // Notification settings (centralized recipients for all admin emails)
498 'email' => array(
499 'send_to_admin_email' => true,
500 'additional_recipients' => array(),
501 'send_deactivation_email' => true,
502 ),
503
504 // Advanced settings
505 'advanced' => array(
506 'remove_readme' => true,
507 'remove_license' => true,
508 'block_author_archives' => false,
509 'disable_embeds' => false,
510 'uninstall_cleanup' => true,
511 'debug_mode' => false,
512 ),
513
514 // Security Analyzer (v2.1.0) — on-demand + weekly Security Check
515 'security_analyzer' => array(
516 'weekly_scan_enabled' => true,
517 'email_on_regression' => false,
518 ),
519
520 // Audit Alerts (v2.8.0) — alerting layer on top of Security Audit.
521 // The engine subscribes to logged events and only runs when the
522 // Security Audit (activity_log) module is enabled. Opt-in: both
523 // legs start OFF so it never duplicates the per-module emails that
524 // already exist (User Security admin monitoring, Plugin Status...).
525 'audit_alerts' => array(
526 // Shared anti-repeat cooldown (minutes). After an alert, do not
527 // send another about the same thing (same event type for
528 // immediate, same category for threshold) until this passes.
529 // Prevents a flood during a sustained attack.
530 'cooldown_minutes' => 60,
531 // #38 Immediate alerts: selected event types email right away.
532 'immediate' => array(
533 'enabled' => false,
534 // Alert on any logged event at or above this severity. A new
535 // admin, a closed plugin or a privilege escalation are all
536 // logged as "critical", so "critical" already covers them.
537 'min_severity' => 'critical', // 'critical' | 'warning'
538 ),
539 // #10 Threshold alerts: N events of a category within a window.
540 'threshold' => array(
541 'enabled' => false,
542 'window' => '1h', // 30m | 1h | 6h | 24h
543 // Per-category trigger counts (warning/critical events only);
544 // 0 disables that category. Covers every event type that can
545 // log a warning or critical. Keep in sync with
546 // Vigilante_Audit_Alerts::category_labels().
547 'categories' => array(
548 'firewall' => 50,
549 'login' => 20,
550 'user' => 5,
551 'plugin' => 0,
552 'file' => 0,
553 'security' => 0,
554 'system' => 0,
555 'settings' => 0,
556 'theme' => 0,
557 'content' => 0,
558 'comment' => 0,
559 'media' => 0,
560 ),
561 ),
562 ),
563 );
564 }
565
566 /**
567 * Get all options (merged with defaults)
568 *
569 * @return array All options
570 */
571 public function get_all_options() {
572 if ( null === $this->options ) {
573 $saved = get_option( self::OPTION_NAME, array() );
574 $this->options = $this->array_merge_deep( $this->get_default_options(), $saved );
575 }
576 return $this->options;
577 }
578
579 /**
580 * Deep merge arrays
581 *
582 * @param array $defaults Default values.
583 * @param array $saved Saved values.
584 * @return array Merged array.
585 */
586 private function array_merge_deep( $defaults, $saved ) {
587 $result = $defaults;
588
589 foreach ( $saved as $key => $value ) {
590 if ( is_array( $value ) && isset( $result[ $key ] ) && is_array( $result[ $key ] ) ) {
591 $result[ $key ] = $this->array_merge_deep( $result[ $key ], $value );
592 } else {
593 $result[ $key ] = $value;
594 }
595 }
596
597 return $result;
598 }
599
600 /**
601 * Get a specific section
602 *
603 * @param string $section Section name.
604 * @return array Section options.
605 */
606 public function get_section( $section ) {
607 $options = $this->get_all_options();
608 return isset( $options[ $section ] ) ? $options[ $section ] : array();
609 }
610
611 /**
612 * Get a specific option
613 *
614 * @param string $section Section name.
615 * @param string $key Option key.
616 * @param mixed $default Default value.
617 * @return mixed Option value.
618 */
619 public function get_option( $section, $key, $default = null ) {
620 $options = $this->get_all_options();
621
622 if ( isset( $options[ $section ][ $key ] ) ) {
623 return $options[ $section ][ $key ];
624 }
625
626 return $default;
627 }
628
629 /**
630 * Check if a module is enabled
631 *
632 * @param string $module Module name.
633 * @return bool Whether module is enabled.
634 */
635 public function is_module_enabled( $module ) {
636 $options = $this->get_all_options();
637 return ! empty( $options['modules'][ $module ] );
638 }
639
640 /**
641 * Save options
642 *
643 * @param array $options Options to save.
644 * @return bool Success status.
645 */
646 public function save_options( $options ) {
647 $this->options = null;
648 return update_option( self::OPTION_NAME, $options );
649 }
650
651 /**
652 * Update a section
653 *
654 * @param string $section Section name.
655 * @param array $data Section data.
656 * @return bool Success status.
657 */
658 public function update_section( $section, $data ) {
659 $options = get_option( self::OPTION_NAME, array() );
660 $options[ $section ] = $data;
661 $this->options = null;
662 return update_option( self::OPTION_NAME, $options );
663 }
664
665 /**
666 * Update multiple sections at once
667 *
668 * @param array $sections Associative array of section => data.
669 * @return bool Success status.
670 */
671 public function update_options( $sections ) {
672 $options = get_option( self::OPTION_NAME, array() );
673
674 foreach ( $sections as $section => $data ) {
675 $options[ $section ] = $data;
676 }
677
678 $this->options = null;
679 return update_option( self::OPTION_NAME, $options );
680 }
681
682 /**
683 * Clear the options cache
684 */
685 public function clear_cache() {
686 $this->options = null;
687 wp_cache_delete( self::OPTION_NAME, 'options' );
688 }
689
690 /**
691 * Whether this context is allowed to write the files a network shares
692 *
693 * wp-config.php and the root .htaccess are single files for the whole
694 * network, while Vigilant's settings are per site. Without a gate, every
695 * save, activation and deactivation from any site rewrites those files from
696 * that site's own options, so the last one to save wins and silently undoes
697 * the rest. Measured on a real network: the main site enables "disable file
698 * editing", a subsite admin presses Save on their own screen without
699 * touching it, and the constant disappears from wp-config.php while the main
700 * site's screen keeps showing the box ticked.
701 *
702 * So on a network only the main site decides, and only a network
703 * administrator. WP-CLI on the main site counts too: there is no user to ask
704 * there, but the site is the right one, and a network admin running
705 * `wp plugin activate --network` expects the files to be written.
706 *
707 * On a single site this is always true and nothing changes.
708 *
709 * @since 2.9.8
710 *
711 * @return bool
712 */
713 public static function can_write_shared_files() {
714 if ( ! is_multisite() ) {
715 return true;
716 }
717
718 if ( ! is_main_site() ) {
719 return false;
720 }
721
722 // WP-CLI with nobody logged in: there is no user to ask, and the site is
723 // the right one, so a network admin running `wp plugin activate --network`
724 // gets the files written. With a user set (wp --user=...) the capability
725 // is checked like anywhere else, so the gate cannot be side-stepped by
726 // running as a subsite administrator.
727 if ( defined( 'WP_CLI' ) && WP_CLI && ! get_current_user_id() ) {
728 return true;
729 }
730
731 return current_user_can( 'manage_network_options' );
732 }
733
734 /**
735 * The one message shown wherever a shared-file setting is out of reach
736 *
737 * Deliberately a single string reused by every section, instead of one per
738 * section: it says the same thing everywhere and there is no reason to make
739 * translators write it four times.
740 *
741 * @since 2.9.8
742 *
743 * @return string
744 */
745 public static function get_shared_files_notice() {
746 return __( 'These settings are written to wp-config.php and .htaccess, files the whole network shares. So that one site cannot overwrite another, they are managed from the main site of the network by a network administrator.', 'vigilante' );
747 }
748
749 /**
750 * Apply the tweaks a brand new installation gets on top of the raw defaults
751 *
752 * A few keys are deliberately missing from get_default_options() because
753 * declaring them would break something else. XML-RPC is the one case today:
754 * declaring wp_hardening.xmlrpc_mode would make the defaults merge fill it in
755 * always and hide the fallback that reads the old pair of settings on sites
756 * that have not re-saved the tab. With nothing stored the resolver answers
757 * 'full', which blocks XML-RPC completely, and that is not what we want a new
758 * site to get.
759 *
760 * Everything that seeds a clean configuration has to run this: the activation
761 * hook, the per-section reset and the global reset to defaults. Otherwise the
762 * defaults you get by pressing a button are not the defaults you get by
763 * installing the plugin, which is exactly what happened until 2.9.8.
764 *
765 * @since 2.9.8
766 *
767 * @param array $options Options array to adjust.
768 * @return array
769 */
770 public static function apply_install_tweaks( $options ) {
771 if ( ! isset( $options['wp_hardening'] ) || ! is_array( $options['wp_hardening'] ) ) {
772 $options['wp_hardening'] = array();
773 }
774
775 $options['wp_hardening']['xmlrpc_mode'] = 'pingback';
776
777 return $options;
778 }
779
780 /**
781 * Keys that hold what the site owner typed in, never wiped by a restore
782 *
783 * Putting the security posture back to its defaults is one thing; deleting
784 * an IP whitelist, the secret login address, the two factor setup or the
785 * addresses that receive the alerts is another, and nobody presses a button
786 * called "restore defaults" expecting that. Both the Standard preset and the
787 * two reset buttons leave these alone.
788 *
789 * @since 2.9.8
790 *
791 * @return array<string,string[]>
792 */
793 public static function get_user_data_keys() {
794 return array(
795 'firewall' => array( 'ip_whitelist', 'ip_blacklist', 'ua_whitelist', 'ua_blacklist', 'trusted_proxy_header', 'country_blocking' ),
796 'login_security' => array( 'ip_whitelist', 'custom_login_url', 'two_factor' ),
797 'user_security' => array( 'insecure_usernames' ),
798 'file_integrity' => array( 'excluded_paths', 'excluded_extensions', 'suspicious_patterns' ),
799 'email' => array( 'additional_recipients' ),
800 );
801 }
802
803 /**
804 * Settings whose only effect is written to a file the network shares
805 *
806 * true for a whole section, or the list of keys inside it. Used to keep a
807 * subsite from resetting settings it does not control: the file is written
808 * from the main site, so resetting the local copy would only make the two
809 * disagree.
810 *
811 * Note this is not every setting that reaches .htaccess. Blocking bad bots
812 * or empty user agents also runs in PHP, per site, so those stay editable on
813 * a subsite: the PHP half protects that site and the .htaccess half is
814 * refused, leaving the main site's rules standing.
815 *
816 * @since 2.9.8
817 *
818 * @return array<string,true|string[]>
819 */
820 public static function get_shared_file_settings() {
821 return array(
822 'security_headers' => true,
823 'wp_hardening' => array( 'disallow_file_edit', 'disallow_file_mods', 'force_ssl_admin', 'force_ssl_login', 'wp_debug', 'disable_wp_cron' ),
824 'firewall' => array( 'disable_directory_browsing', 'protect_wp_config', 'protect_wp_includes', 'protect_uploads_php', 'protect_sensitive_files', 'protect_wp_cron', 'limit_http_methods' ),
825 );
826 }
827
828 /**
829 * Put a configuration back to the defaults without deleting what the owner typed
830 *
831 * @since 2.9.8
832 *
833 * @param array $current Configuration being replaced.
834 * @return array
835 */
836 public static function get_defaults_preserving_user_data( $current ) {
837 $instance = new self();
838 $defaults = self::apply_install_tweaks( $instance->get_default_options() );
839
840 foreach ( self::get_user_data_keys() as $section => $keys ) {
841 foreach ( $keys as $key ) {
842 if ( isset( $current[ $section ] ) && array_key_exists( $key, (array) $current[ $section ] ) ) {
843 $defaults[ $section ][ $key ] = $current[ $section ][ $key ];
844 }
845 }
846 }
847
848 return $defaults;
849 }
850
851 /**
852 * The values the Standard preset applies
853 *
854 * Standard is the configuration a new installation gets, with every module
855 * on. It is built from the defaults rather than written out by hand, because
856 * a hand-written copy drifts: until 2.9.8 Standard named a dozen fields and
857 * left everything else alone, so applying it after Maximum kept Maximum's
858 * password rules, its administrator alerts, its session limits and its
859 * password expiry, and the preset that says it applies sensible defaults
860 * applied almost none of them.
861 *
862 * The only thing it does not touch is what the site owner typed in: IP and
863 * user agent lists, the custom login address, two factor configuration, the
864 * integrity scan exclusions and the extra notification recipients. Putting
865 * the security posture back to the defaults is one thing, throwing away
866 * someone's whitelist is another, and "Reset to Defaults" is right there for
867 * that.
868 *
869 * @since 2.9.8
870 *
871 * @return array
872 */
873 private function get_standard_preset_values() {
874 $values = self::apply_install_tweaks( $this->get_default_options() );
875
876 foreach ( array_keys( $values['modules'] ) as $module ) {
877 $values['modules'][ $module ] = true;
878 }
879
880 foreach ( self::get_user_data_keys() as $section => $keys ) {
881 foreach ( $keys as $key ) {
882 unset( $values[ $section ][ $key ] );
883 }
884 }
885
886 unset( $values['user_security']['password_expiration']['excluded_users'] );
887
888 return $values;
889 }
890
891 /**
892 * Merge a preset over a configuration
893 *
894 * Not array_replace_recursive(), which is wrong for this in two ways. A list
895 * of roles in the preset is merged position by position instead of replacing
896 * the stored one, so applying Standard over Maximum turned the two roles
897 * Standard expires passwords for into Maximum's five with the first two
898 * overwritten. And an empty list in the preset clears nothing at all,
899 * because there is no element to replace with.
900 *
901 * So: associative arrays are merged key by key, and lists and scalars are
902 * replaced outright.
903 *
904 * @since 2.9.8
905 *
906 * @param array $base Current configuration.
907 * @param array $overlay Preset values.
908 * @return array
909 */
910 public static function merge_preset( $base, $overlay ) {
911 foreach ( $overlay as $key => $value ) {
912 if ( is_array( $value ) && isset( $base[ $key ] ) && is_array( $base[ $key ] ) && ! self::is_list( $value ) ) {
913 $base[ $key ] = self::merge_preset( $base[ $key ], $value );
914 continue;
915 }
916
917 $base[ $key ] = $value;
918 }
919
920 return $base;
921 }
922
923 /**
924 * Whether an array is a plain list (0..n-1 keys)
925 *
926 * array_is_list() is PHP 8.1 and this plugin supports 7.4.
927 *
928 * @since 2.9.8
929 *
930 * @param array $value Array to inspect.
931 * @return bool
932 */
933 private static function is_list( $value ) {
934 if ( array() === $value ) {
935 return true;
936 }
937
938 return array_keys( $value ) === range( 0, count( $value ) - 1 );
939 }
940
941 /**
942 * Get presets with descriptions
943 *
944 * @return array Presets configuration.
945 */
946 public function get_presets() {
947 return array(
948 'standard' => array_merge(
949 array(
950 'name' => __( 'Standard', 'vigilante' ),
951 'description' => __( 'Balanced security suitable for most websites. Enables every module and puts every setting back to the value a new installation gets.', 'vigilante' ),
952 ),
953 $this->get_standard_preset_values()
954 ),
955
956 'maximum' => array(
957 'name' => __( 'Maximum Security', 'vigilante' ),
958 'description' => __( 'Strictest settings for high-security sites. CSP is set to report-only mode to prevent breaking the admin interface.', 'vigilante' ),
959 'modules' => array(
960 'firewall' => true,
961 'security_headers' => true,
962 'login_security' => true,
963 'rest_api_security'=> true,
964 'user_security' => true,
965 'wp_hardening' => true,
966 'file_integrity' => true,
967 'activity_log' => true,
968 ),
969 'firewall' => array(
970 'block_bad_query_strings' => true,
971 'block_sql_injection' => true,
972 'block_xss_attacks' => true,
973 'block_file_inclusion' => true,
974 'block_directory_traversal' => true,
975 'block_bad_bots' => true,
976 'block_empty_user_agent' => true,
977 'rate_limiting' => array(
978 'enabled' => true,
979 'requests_per_minute' => 60,
980 'block_duration' => 600,
981 'progressive' => true,
982 'max_block_duration' => 86400,
983 ),
984 ),
985 'security_headers' => array(
986 'x_frame_options' => 'DENY',
987 // HSTS is intentionally NOT enabled by Maximum: forcing HSTS on a site
988 // that doesn't have a healthy HTTPS setup (or temporarily falls back to
989 // HTTP) locks visitors out for the full max_age. Leaving HSTS off keeps
990 // it as an explicit opt-in decision per site.
991 'csp' => array(
992 'enabled' => true,
993 'report_only' => false,
994 'directives' => array(
995 'default-src' => "'self'",
996 'script-src' => "'self' 'unsafe-inline' 'unsafe-eval'",
997 'style-src' => "'self' 'unsafe-inline'",
998 'img-src' => "'self' data: https: blob:",
999 'font-src' => "'self' data:",
1000 'connect-src' => "'self' https: blob:",
1001 'frame-src' => "'self' blob:",
1002 'frame-ancestors' => "'none'",
1003 'worker-src' => "'self' blob:",
1004 'object-src' => "'none'",
1005 'base-uri' => "'self'",
1006 ),
1007 ),
1008 ),
1009 'rest_api_security' => array(
1010 'mode' => 'authenticated_only',
1011 ),
1012 'login_security' => array(
1013 'max_attempts' => 3,
1014 'lockout_duration' => 3600,
1015 'lockout_increment' => true,
1016 'notify_on_lockout' => true,
1017 'notify_on_admin_login' => true,
1018 ),
1019 'wp_hardening' => array(
1020 'xmlrpc_mode' => 'full',
1021 'disallow_file_edit' => true,
1022 'disallow_file_mods' => true,
1023 // close_old_comments is intentionally NOT touched by Maximum:
1024 // it would unilaterally close discussion on every old post,
1025 // which is a content decision, not a security one.
1026 ),
1027 'user_security' => array(
1028 'prevent_display_name_login_match' => true,
1029 'min_password_length' => 16,
1030 'password_policy' => array(
1031 'require_uppercase' => true,
1032 'require_lowercase' => true,
1033 'require_number' => true,
1034 'require_special' => true,
1035 'block_common' => true,
1036 'block_username' => true,
1037 'affected_roles' => array(),
1038 ),
1039 'admin_monitoring' => array(
1040 'alert_new_admin' => true,
1041 'alert_admin_email_change' => true,
1042 'alert_permission_elevation' => true,
1043 'alert_admin_password_change' => true,
1044 ),
1045 'registration_approval' => array(
1046 'enabled' => true,
1047 'notify_admin' => true,
1048 'auto_reject_days' => 7,
1049 'affected_roles' => array( 'subscriber', 'contributor', 'author', 'editor' ),
1050 ),
1051 'session_limits' => array(
1052 'enabled' => true,
1053 'max_sessions' => 1,
1054 'behavior' => 'close_oldest',
1055 'exclude_admins' => false,
1056 ),
1057 'password_expiration' => array(
1058 'enabled' => true,
1059 'expire_days' => 30,
1060 'warning_days' => 7,
1061 'affected_roles' => array( 'administrator', 'editor', 'author', 'contributor', 'subscriber' ),
1062 'password_history' => 5,
1063 'send_reminder' => true,
1064 ),
1065 'email_verification' => array(
1066 'enabled' => true,
1067 'token_expiry_hours' => 24,
1068 'allow_resend' => true,
1069 'auto_delete_days' => 3,
1070 ),
1071 ),
1072 'file_integrity' => array(
1073 'scan_core' => true,
1074 'scan_plugins' => true,
1075 'scan_themes' => true,
1076 'scan_uploads' => true,
1077 'scan_critical_config' => true,
1078 'auto_scan' => true,
1079 'scan_frequency' => 'daily',
1080 'notify_level' => 'all',
1081 'instant_alert' => true,
1082 ),
1083 // A configuration called Maximum Security that never tells you
1084 // anything happened is half a product, so the audit alerts ship
1085 // on with it. The shared cooldown keeps a sustained attack from
1086 // turning into a flood. Under Attack mode builds on this preset,
1087 // so it inherits them for as long as it is on and gives them back
1088 // when it is switched off.
1089 'audit_alerts' => array(
1090 'immediate' => array(
1091 'enabled' => true,
1092 'min_severity' => 'critical',
1093 ),
1094 'threshold' => array(
1095 'enabled' => true,
1096 ),
1097 ),
1098 'activity_log' => array(
1099 'log_logins' => true,
1100 'log_failed_logins' => true,
1101 'log_user_changes' => true,
1102 'log_post_changes' => true,
1103 'log_plugin_changes' => true,
1104 'log_theme_changes' => true,
1105 'log_option_changes' => true,
1106 'log_file_changes' => true,
1107 'log_comments' => true,
1108 'log_media' => true,
1109 ),
1110 ),
1111 );
1112 }
1113
1114 /**
1115 * Get module labels for display
1116 *
1117 * @return array Module labels.
1118 */
1119 public function get_module_labels() {
1120 return array(
1121 'firewall' => __( 'Firewall', 'vigilante' ),
1122 'security_headers' => __( 'Security Headers', 'vigilante' ),
1123 'login_security' => __( 'Login Security', 'vigilante' ),
1124 'rest_api_security'=> __( 'REST API Security', 'vigilante' ),
1125 'user_security' => __( 'User Security', 'vigilante' ),
1126 'wp_hardening' => __( 'WordPress Hardening', 'vigilante' ),
1127 'file_integrity' => __( 'File Integrity', 'vigilante' ),
1128 'activity_log' => __( 'Security Audit', 'vigilante' ),
1129 );
1130 }
1131
1132 /**
1133 * Get module descriptions for display
1134 *
1135 * @return array Module descriptions.
1136 */
1137 public function get_module_descriptions() {
1138 return array(
1139 'firewall' => __( 'Blocks malicious requests, SQL injection, XSS attacks, and bad bots. Includes rate limiting and file protection.', 'vigilante' ),
1140 'security_headers' => __( 'Adds HTTP security headers like CSP, HSTS, X-Frame-Options. Forces HTTPS and fixes mixed content.', 'vigilante' ),
1141 'login_security' => __( 'Brute force protection, 2FA, login attempt limits, XML-RPC control, and notifications.', 'vigilante' ),
1142 'rest_api_security'=> __( 'Controls REST API access, blocks user enumeration, and protects sensitive endpoints.', 'vigilante' ),
1143 'user_security' => __( 'Blocks insecure usernames, enforces strong passwords, and prevents author scanning.', 'vigilante' ),
1144 'wp_hardening' => __( 'Hardens wp-config.php, manages comments, cleans header output, and controls feeds.', 'vigilante' ),
1145 'file_integrity' => __( 'Scans WordPress core, plugins, and themes for unauthorized changes and suspicious code.', 'vigilante' ),
1146 'activity_log' => __( 'Records user actions, logins, content changes, and security events for security auditing.', 'vigilante' ),
1147 );
1148 }
1149
1150 /**
1151 * Validate options before saving
1152 *
1153 * @param array $input Raw input to validate.
1154 * @return array Validated options.
1155 */
1156 public function validate_options( $input ) {
1157 $validated = array();
1158 $defaults = $this->get_default_options();
1159
1160 // Validate each section that exists in input
1161 foreach ( $input as $section => $data ) {
1162 if ( ! is_array( $data ) ) {
1163 continue;
1164 }
1165
1166 if ( 'modules' === $section ) {
1167 // Validate modules (booleans)
1168 foreach ( $defaults['modules'] as $module => $default_value ) {
1169 $validated['modules'][ $module ] = isset( $data[ $module ] )
1170 ? (bool) $data[ $module ]
1171 : false;
1172 }
1173 } elseif ( isset( $defaults[ $section ] ) ) {
1174 // Validate other sections using generic validator
1175 $validated[ $section ] = $this->validate_section( $data, $defaults[ $section ] );
1176 }
1177 }
1178
1179 return apply_filters( 'vigilante_validate_options', $validated, $input );
1180 }
1181
1182 /**
1183 * Validate a section based on defaults
1184 *
1185 * @param array $input Input values.
1186 * @param array $defaults Default values.
1187 * @return array Validated values.
1188 */
1189 private function validate_section( $input, $defaults ) {
1190 $validated = array();
1191
1192 foreach ( $defaults as $key => $default_value ) {
1193 if ( ! isset( $input[ $key ] ) ) {
1194 $validated[ $key ] = $default_value;
1195 continue;
1196 }
1197
1198 $value = $input[ $key ];
1199
1200 if ( is_bool( $default_value ) ) {
1201 $validated[ $key ] = (bool) $value;
1202 } elseif ( is_int( $default_value ) ) {
1203 $validated[ $key ] = intval( $value );
1204 } elseif ( is_array( $default_value ) ) {
1205 if ( is_array( $value ) ) {
1206 $validated[ $key ] = $this->validate_section( $value, $default_value );
1207 } else {
1208 $validated[ $key ] = $default_value;
1209 }
1210 } else {
1211 $validated[ $key ] = sanitize_text_field( $value );
1212 }
1213 }
1214
1215 // Include any extra keys from input
1216 foreach ( $input as $key => $value ) {
1217 if ( ! isset( $validated[ $key ] ) ) {
1218 if ( is_array( $value ) ) {
1219 $validated[ $key ] = array_map( 'sanitize_text_field', $value );
1220 } else {
1221 $validated[ $key ] = sanitize_text_field( $value );
1222 }
1223 }
1224 }
1225
1226 return $validated;
1227 }
1228 }