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

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

1,511 lines 65.9 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 // Proxy / CDN: IPs or CIDR ranges the forwarded header is accepted
98 // from. Empty = accept it only from your own network, and, for
99 // CF-Connecting-IP, Cloudflare's own ranges. Since 2.11.9, so a
100 // header cannot be forged by a visitor reaching the origin directly.
101 'trusted_proxies' => array(),
102
103 // User-Agent management
104 'ua_whitelist' => array(),
105 'ua_blacklist' => array(),
106
107 // File protection (htaccess-based)
108 'disable_directory_browsing' => true,
109 'protect_wp_config' => true,
110 'protect_wp_includes' => true,
111 'protect_uploads_php' => true,
112 'protect_sensitive_files' => true,
113 // Off by default — only safe when host has a real server-side cron job
114 // calling wp-cron.php; otherwise scheduled tasks stop running silently.
115 'protect_wp_cron' => false,
116 'block_php_in_plugins' => false,
117 'block_php_in_themes' => false,
118 'limit_http_methods' => true,
119 // All methods needed for WordPress core, Gutenberg, REST API, and page builders
120 'allowed_http_methods' => array( 'GET', 'POST', 'HEAD', 'OPTIONS', 'PUT', 'PATCH', 'DELETE' ),
121 ),
122
123 // Security Headers settings (includes HTTPS enforcer)
124 'security_headers' => array(
125 'enabled' => true,
126
127 // Basic headers
128 'x_frame_options' => 'SAMEORIGIN',
129 'x_content_type_options' => true,
130 'referrer_policy' => 'strict-origin-when-cross-origin',
131
132 // HSTS
133 'hsts' => array(
134 'enabled' => false,
135 'max_age' => 31536000,
136 'include_subdomains' => false,
137 'preload' => false,
138 ),
139
140 // Permissions Policy
141 'permissions_policy' => array(
142 'enabled' => true,
143 'geolocation' => '()',
144 'microphone' => '()',
145 'camera' => '()',
146 'payment' => '(self)',
147 'usb' => '()',
148 ),
149
150 // CSP - WordPress/Gutenberg compatible defaults
151 // Note: blob: is required in frame-src and worker-src for the block editor,
152 // and in connect-src for the client-side media processing WordPress 7.1
153 // introduced: @wordpress/vips puts its WebAssembly binary in a blob: URL and
154 // fetches it, and fetch() is governed by connect-src, where 'self' does not
155 // cover blob:. Without it the editor cannot process images before upload.
156 'csp' => array(
157 'enabled' => true,
158 'report_only' => false,
159 'report_uri' => '',
160 'directives' => array(
161 'default-src' => "'self'",
162 'script-src' => "'self' 'unsafe-inline' 'unsafe-eval' https:",
163 'style-src' => "'self' 'unsafe-inline' https:",
164 'img-src' => "'self' data: https: blob:",
165 'font-src' => "'self' data: https:",
166 'connect-src' => "'self' https: wss: blob:",
167 'media-src' => "'self' https: blob:",
168 'frame-src' => "'self' https: blob:",
169 'frame-ancestors' => "'self'",
170 'base-uri' => "'self'",
171 'form-action' => "'self' https:",
172 'object-src' => "'none'",
173 'worker-src' => "'self' blob:",
174 'upgrade-insecure-requests'=> true,
175 ),
176 ),
177
178 // Cross-origin policies
179 'cross_origin_policies' => array(
180 'embedder_policy' => 'unsafe-none',
181 'opener_policy' => 'same-origin-allow-popups',
182 'resource_policy' => 'cross-origin',
183 ),
184
185 // HTTPS Enforcer (moved from separate module)
186 //
187 // force_https rewrites siteurl/home to https on activation, so it
188 // ships off: a site without working HTTPS would end up pointing at
189 // an address that does not answer. It is an opt-in decision per
190 // site, made from the Security Headers tab. Sites whose URLs a
191 // previous version already rewrote keep them; nothing reverts them.
192 'force_https' => false,
193 // Off by default for the same reason as force_https above: the
194 // plugin does not decide that a site is on HTTPS. It only
195 // redirects when the site's own home URL already says https, so
196 // shipping it on was harmless in practice, but it is still a
197 // decision that belongs to the site owner, not to us. Sites that
198 // already have it on keep it.
199 'redirect_http_to_https' => false,
200 // Rewrites http:// URLs of this same site to https://, and only
201 // on a site already served over HTTPS, so it cannot reach a third
202 // party and cannot make a resource fail. It still ships off: a
203 // setting whose own description says it rewrites http to https
204 // does not belong in the factory configuration of a plugin that
205 // deliberately does not decide whether a site is on HTTPS. Every
206 // https-related setting here is the owner's call, and this one is
207 // one click away for anyone who has just migrated and wants their
208 // old content rewritten.
209 'fix_mixed_content' => false,
210 // The Content-Security-Policy directive that tells the browser to
211 // upgrade every http:// request, including the ones pointing at
212 // other people's servers. If any of those has no HTTPS the
213 // resource simply stops loading, so this is the one piece of
214 // mixed content handling that can break a page, and it is off by
215 // default like everything else here that forces HTTPS. It used to
216 // ride along with fix_mixed_content with no way to separate them.
217 'upgrade_insecure_requests' => false,
218
219 // Server Protection (moved from firewall in v2.0.0)
220 'hide_server_signature' => true,
221 'remove_fingerprinting_headers' => true,
222 ),
223
224 // Login Security settings
225 'login_security' => array(
226 'enabled' => true,
227 'max_attempts' => 5,
228 'lockout_duration' => 1800,
229 'lockout_increment' => true,
230 'max_lockout_duration' => 86400,
231 'hide_login_errors' => true,
232 // XML-RPC se movio a wp_hardening en la 2.9.7; lo resuelve
233 // Vigilante_Comment_Security::resolve_xmlrpc_mode(), que
234 // sustituye a las dos casillas anteriores (disable_xmlrpc y
235 // disable_xmlrpc_pingback), que podian estar activas a la vez y
236 // contradecirse. A proposito NO se declara aqui ningun default: si se
237 // declarara, el merge con los defaults lo rellenaria siempre y taparia el
238 // respaldo que lee el ajuste antiguo de los sitios que aun no han vuelto a
239 // guardar la pestana. Sin nada guardado, el resolutor devuelve 'full', que
240 // es lo que hacia el default anterior.
241 'disable_application_passwords' => false,
242 'notify_on_lockout' => false,
243 'notify_on_admin_login' => false,
244 'ip_whitelist' => array(),
245 'custom_login_url' => '',
246 'notify_on_login_url_change' => true,
247 // Two-Factor Authentication
248 'two_factor' => array(
249 'enabled' => false,
250 'method' => 'email',
251 'enforced_roles' => array( 'administrator', 'editor' ),
252 'excluded_users' => array(),
253 'remember_device_days' => 30,
254 'allow_remember_device' => false,
255 'code_expiry_minutes' => 10,
256 'max_attempts' => 3,
257 'email_from_name' => '',
258 'notify_on_enable' => true,
259 'grace_period_days' => 3,
260 ),
261 ),
262
263 // REST API Security settings
264 'rest_api_security' => array(
265 'enabled' => true,
266 'mode' => 'selective',
267 'block_user_enumeration' => true,
268 'disable_jsonp' => true,
269 // Empty by default: /wp/v2/users used to live here, but that
270 // duplicated the dedicated "Block user enumeration" toggle.
271 // Now there is one knob = one behaviour. If you want to
272 // protect additional endpoints in selective mode, add them
273 // explicitly via this setting (or via a filter).
274 'protected_endpoints' => array(),
275 'allowed_public_endpoints' => array(
276 '/wp/v2/posts',
277 '/wp/v2/pages',
278 '/wp/v2/categories',
279 '/wp/v2/tags',
280 '/oembed/',
281 ),
282 'plugin_compatibility' => array(
283 'woocommerce' => true,
284 'contact_form_7' => true,
285 'elementor' => true,
286 ),
287 ),
288
289 // User Security settings
290 'user_security' => array(
291 'enabled' => true,
292 'block_insecure_usernames'=> true,
293 'insecure_usernames' => array(
294 'admin', 'administrator', 'user', 'test', 'guest',
295 'info', 'root', 'adm', 'sysadmin', 'support',
296 'webmaster', 'master', 'owner', 'manager', 'demo',
297 ),
298 'block_author_scanning' => true,
299 'force_strong_passwords' => true,
300 'min_password_length' => 12,
301
302 // Granular password policy. Applies only while
303 // force_strong_passwords is on. The defaults follow current
304 // guidance (NIST SP 800-63B): what makes a password weak is being
305 // guessable, not lacking a symbol, and composition rules push
306 // people towards predictable substitutions and towards writing
307 // the password down. So out of the box only the two rules that
308 // block genuinely guessable passwords are on, and the four
309 // character-class requirements ship off for anyone to turn on.
310 // Existing sites keep whatever they have stored.
311 // affected_roles empty = all roles.
312 'password_policy' => array(
313 'require_uppercase' => false,
314 'require_lowercase' => false,
315 'require_number' => false,
316 'require_special' => false,
317 'block_common' => true,
318 'block_username' => true,
319 'affected_roles' => array(),
320 ),
321
322 'prevent_display_name_login_match' => true,
323
324 // Admin monitoring
325 // The two alerts that report someone gaining power ship on: a
326 // new administrator and a role being raised are the signature of
327 // an account takeover, and they are rare enough not to be noise.
328 // The other two are ordinary admin housekeeping and stay opt-in.
329 'admin_monitoring' => array(
330 'alert_new_admin' => true,
331 'alert_admin_email_change' => false,
332 'alert_permission_elevation' => true,
333 'alert_admin_password_change' => false,
334 ),
335
336 // Force password reset (no options, uses native WordPress flow)
337
338 // Registration approval
339 'registration_approval' => array(
340 'enabled' => false,
341 'notify_admin' => false,
342 'auto_reject_days' => 0,
343 'affected_roles' => array( 'subscriber' ),
344 ),
345
346 // Session management
347 'session_management' => array(
348 'enabled' => true,
349 'show_in_profile' => true,
350 ),
351
352 // Session limits
353 'session_limits' => array(
354 'enabled' => true,
355 'max_sessions' => 3,
356 'behavior' => 'close_oldest',
357 'exclude_admins' => false,
358 ),
359
360 // Password expiration ships OFF. Forced rotation is no longer
361 // recommended (NIST SP 800-63B advises against it) and it is by
362 // far the biggest source of support here: people locked out mid
363 // task, cron reminders that never arrive, roles nobody meant to
364 // include. The feature stays for anyone who has to comply with a
365 // policy that still demands it, and the Configuration Score keeps
366 // pointing at it, which is what that score is for.
367 'password_expiration' => array(
368 'enabled' => false,
369 'expire_days' => 90,
370 'warning_days' => 14,
371 'affected_roles' => array( 'administrator', 'editor' ),
372 'excluded_users' => array(),
373 'password_history' => 3,
374 'send_reminder' => false,
375 ),
376
377 // Email verification
378 'email_verification' => array(
379 'enabled' => false,
380 'token_expiry_hours' => 24,
381 'allow_resend' => true,
382 'auto_delete_days' => 7,
383 ),
384 ),
385
386 // WordPress Hardening (combines wp-config, comments, feeds, head cleaner)
387 'wp_hardening' => array(
388 'enabled' => true,
389
390 // wp-config security
391 'disallow_file_edit' => true,
392 'disallow_file_mods' => false,
393 // Off by default. This one writes FORCE_SSL_ADMIN into
394 // wp-config.php, and it used to do so on activation with no
395 // check that the site answers over HTTPS at all, which locks the
396 // owner out of their own admin. Forcing HTTPS is an opt-in
397 // decision per site, consistent with force_https and with HSTS,
398 // both of which already ship off.
399 'force_ssl_admin' => false,
400 'wp_debug' => true,
401 // Off by default — only safe when host has a real server-side cron job;
402 // pairs with firewall.protect_wp_cron to block both internal triggering
403 // (this constant) and external HTTP abuse (the .htaccess rule).
404 'disable_wp_cron' => false,
405
406 // Comment security
407 'disable_pingbacks' => true,
408 'disable_trackbacks' => true,
409 'require_comment_moderation' => true,
410 'close_old_comments' => false,
411 'close_comments_after_days' => 30,
412 'honeypot_comments' => true,
413
414 // Head cleaner
415 'remove_wp_generator' => true,
416 'remove_wp_version_assets' => false,
417 'remove_rsd_link' => true,
418 'remove_wlw_manifest' => true,
419 'remove_shortlink' => true,
420 'remove_rest_api_link' => false,
421
422 // Feed manager
423 'disable_feeds' => false,
424 'disable_if_no_content' => true,
425 'remove_feed_version' => true,
426 ),
427
428 // File Integrity settings
429 'file_integrity' => array(
430 'enabled' => true,
431 'scan_core' => true,
432 'scan_plugins' => true,
433 'scan_themes' => true,
434 'scan_uploads' => true,
435 'scan_critical_config' => true,
436 'check_closed_plugins' => true,
437 'auto_scan' => true,
438 'scan_frequency' => 'daily',
439 'notify_level' => 'suspicious_only',
440 'instant_alert' => false,
441 'excluded_paths' => array(
442 'wp-content/cache',
443 ),
444 'excluded_extensions' => array(
445 // Translations (regenerated per-locale, never in checksums).
446 '.po', '.mo', '.pot',
447 // Binary images (cosmetic, not executable; often rewritten by image-optimizer plugins).
448 '.jpg', '.jpeg', '.png', '.gif', '.ico', '.webp', '.avif',
449 // Stylesheets: frequently rewritten by themes and optimizer
450 // plugins, a common source of post-update false positives.
451 // Strict-mode users can remove it (CSS injection is still a
452 // vector, defended primarily by CSP in the headers module).
453 '.css',
454 ),
455 ),
456
457 // Activity Log settings
458 'activity_log' => array(
459 'retention_days' => 30,
460 'max_entries' => 10000,
461 'log_logins' => true,
462 'log_failed_logins' => true,
463 'log_user_changes' => true,
464 'log_post_changes' => true,
465 'log_plugin_changes' => true,
466 'log_theme_changes' => true,
467 'log_option_changes' => false,
468 'log_file_changes' => true,
469 'log_comments' => true,
470 'log_media' => true,
471 'excluded_users' => array(),
472 'excluded_ips' => array(),
473 'tracked_options' => array(),
474 ),
475
476 // Backup settings
477 'backup' => array(
478 'keep_backups' => 5,
479 ),
480
481 // Notification settings (centralized recipients for all admin emails)
482 'email' => array(
483 'send_to_admin_email' => true,
484 'additional_recipients' => array(),
485 'send_deactivation_email' => true,
486 ),
487
488 // Advanced settings
489 'advanced' => array(
490 'remove_readme' => true,
491 'remove_license' => true,
492 ),
493
494 // Security Analyzer (v2.1.0) — on-demand + weekly Security Check
495 'security_analyzer' => array(
496 'weekly_scan_enabled' => true,
497 'email_on_regression' => false,
498 ),
499
500 // Audit Alerts (v2.8.0) — alerting layer on top of Security Audit.
501 // The engine subscribes to logged events and only runs when the
502 // Security Audit (activity_log) module is enabled. Opt-in: both
503 // legs start OFF so it never duplicates the per-module emails that
504 // already exist (User Security admin monitoring, Plugin Status...).
505 'audit_alerts' => array(
506 // Shared anti-repeat cooldown (minutes). After an alert, do not
507 // send another about the same thing (same event type for
508 // immediate, same category for threshold) until this passes.
509 // Prevents a flood during a sustained attack.
510 'cooldown_minutes' => 60,
511 // #38 Immediate alerts: selected event types email right away.
512 'immediate' => array(
513 'enabled' => false,
514 // Alert on any logged event at or above this severity. A new
515 // admin, a closed plugin or a privilege escalation are all
516 // logged as "critical", so "critical" already covers them.
517 'min_severity' => 'critical', // 'critical' | 'warning'
518 ),
519 // #10 Threshold alerts: N events of a category within a window.
520 'threshold' => array(
521 'enabled' => false,
522 'window' => '1h', // 30m | 1h | 6h | 24h
523 // Per-category trigger counts (warning/critical events only);
524 // 0 disables that category. Covers every event type that can
525 // log a warning or critical. Keep in sync with
526 // Vigilante_Audit_Alerts::category_labels().
527 'categories' => array(
528 'firewall' => 50,
529 'login' => 20,
530 'user' => 5,
531 'plugin' => 0,
532 'file' => 0,
533 'security' => 0,
534 'system' => 0,
535 'settings' => 0,
536 'theme' => 0,
537 'content' => 0,
538 'comment' => 0,
539 'media' => 0,
540 ),
541 ),
542 ),
543 );
544 }
545
546 /**
547 * Get all options (merged with defaults)
548 *
549 * @return array All options
550 */
551 public function get_all_options() {
552 if ( null === $this->options ) {
553 $saved = get_option( self::OPTION_NAME, array() );
554 $this->options = $this->array_merge_deep( $this->get_default_options(), $saved );
555 }
556 return $this->options;
557 }
558
559 /**
560 * Deep merge arrays
561 *
562 * @param array $defaults Default values.
563 * @param array $saved Saved values.
564 * @return array Merged array.
565 */
566 private function array_merge_deep( $defaults, $saved ) {
567 $result = $defaults;
568
569 foreach ( $saved as $key => $value ) {
570 if ( is_array( $value ) && isset( $result[ $key ] ) && is_array( $result[ $key ] ) ) {
571 $result[ $key ] = $this->array_merge_deep( $result[ $key ], $value );
572 } else {
573 $result[ $key ] = $value;
574 }
575 }
576
577 return $result;
578 }
579
580 /**
581 * Get a specific section
582 *
583 * @param string $section Section name.
584 * @return array Section options.
585 */
586 public function get_section( $section ) {
587 $options = $this->get_all_options();
588 return isset( $options[ $section ] ) ? $options[ $section ] : array();
589 }
590
591 /**
592 * Get a specific option
593 *
594 * @param string $section Section name.
595 * @param string $key Option key.
596 * @param mixed $default Default value.
597 * @return mixed Option value.
598 */
599 public function get_option( $section, $key, $default = null ) {
600 $options = $this->get_all_options();
601
602 if ( isset( $options[ $section ][ $key ] ) ) {
603 return $options[ $section ][ $key ];
604 }
605
606 return $default;
607 }
608
609 /**
610 * Check if a module is enabled
611 *
612 * @param string $module Module name.
613 * @return bool Whether module is enabled.
614 */
615 public function is_module_enabled( $module ) {
616 $options = $this->get_all_options();
617 return ! empty( $options['modules'][ $module ] );
618 }
619
620 /**
621 * Save options
622 *
623 * @param array $options Options to save.
624 * @return bool Success status.
625 */
626 public function save_options( $options ) {
627 $this->options = null;
628 return update_option( self::OPTION_NAME, $options );
629 }
630
631 /**
632 * Update a section
633 *
634 * @param string $section Section name.
635 * @param array $data Section data.
636 * @return bool Success status.
637 */
638 public function update_section( $section, $data ) {
639 $options = get_option( self::OPTION_NAME, array() );
640 $options[ $section ] = $data;
641 $this->options = null;
642 return update_option( self::OPTION_NAME, $options );
643 }
644
645 /**
646 * Update multiple sections at once
647 *
648 * @param array $sections Associative array of section => data.
649 * @return bool Success status.
650 */
651 public function update_options( $sections ) {
652 $options = get_option( self::OPTION_NAME, array() );
653
654 foreach ( $sections as $section => $data ) {
655 $options[ $section ] = $data;
656 }
657
658 $this->options = null;
659 return update_option( self::OPTION_NAME, $options );
660 }
661
662 /**
663 * Clear the options cache
664 */
665 public function clear_cache() {
666 $this->options = null;
667 wp_cache_delete( self::OPTION_NAME, 'options' );
668 }
669
670 /**
671 * Whether this context is allowed to write the files a network shares
672 *
673 * wp-config.php and the root .htaccess are single files for the whole
674 * network, while Vigilant's settings are per site. Without a gate, every
675 * save, activation and deactivation from any site rewrites those files from
676 * that site's own options, so the last one to save wins and silently undoes
677 * the rest. Measured on a real network: the main site enables "disable file
678 * editing", a subsite admin presses Save on their own screen without
679 * touching it, and the constant disappears from wp-config.php while the main
680 * site's screen keeps showing the box ticked.
681 *
682 * So on a network only the main site decides, and only a network
683 * administrator. WP-CLI on the main site counts too: there is no user to ask
684 * there, but the site is the right one, and a network admin running
685 * `wp plugin activate --network` expects the files to be written.
686 *
687 * On a single site this is always true and nothing changes.
688 *
689 * @since 2.9.8
690 *
691 * @return bool
692 */
693 /**
694 * Whether this site is the one that owns the files a network shares.
695 *
696 * Pure site identity, with no capability in it, and that is the point. A
697 * refresh that Vigilant performs by itself, such as rewriting its own
698 * .htaccess block after an update, decides nothing: the content comes from
699 * this site's own options whoever happens to be visiting. What must not
700 * happen is a *different* site writing the shared file, and that is exactly
701 * what this answers.
702 *
703 * can_write_shared_files() below adds the capability on top, and is the
704 * right question for anything a person initiates from a settings screen.
705 *
706 * @since 2.10.1
707 * @return bool
708 */
709 public static function owns_shared_files() {
710 // One wp-config.php and one root .htaccess per installation, even with
711 // several networks in it: is_main_site() alone is true on the main site
712 // of every network. Since 2.11.8, found by the audit of the network.
713 return ! is_multisite() || ( is_main_site() && is_main_network() );
714 }
715
716 public static function can_write_shared_files() {
717 if ( ! is_multisite() ) {
718 return true;
719 }
720
721 // See owns_shared_files(): the main site of a secondary network, and
722 // its network administrator, do not own the installation's files.
723 if ( ! is_main_site() || ! is_main_network() ) {
724 return false;
725 }
726
727 // WP-CLI with nobody logged in: there is no user to ask, and the site is
728 // the right one, so a network admin running `wp plugin activate --network`
729 // gets the files written. With a user set (wp --user=...) the capability
730 // is checked like anywhere else, so the gate cannot be side-stepped by
731 // running as a subsite administrator.
732 if ( defined( 'WP_CLI' ) && WP_CLI && ! get_current_user_id() ) {
733 return true;
734 }
735
736 return current_user_can( 'manage_network_options' );
737 }
738
739 /**
740 * The one message shown wherever a shared-file setting is out of reach
741 *
742 * Deliberately a single string reused by every section, instead of one per
743 * section: it says the same thing everywhere and there is no reason to make
744 * translators write it four times.
745 *
746 * @since 2.9.8
747 *
748 * @return string
749 */
750 public static function get_shared_files_notice() {
751 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' );
752 }
753
754 /**
755 * Apply the tweaks a brand new installation gets on top of the raw defaults
756 *
757 * A few keys are deliberately missing from get_default_options() because
758 * declaring them would break something else. XML-RPC is the one case today:
759 * declaring wp_hardening.xmlrpc_mode would make the defaults merge fill it in
760 * always and hide the fallback that reads the old pair of settings on sites
761 * that have not re-saved the tab. With nothing stored the resolver answers
762 * 'full', which blocks XML-RPC completely, and that is not what we want a new
763 * site to get.
764 *
765 * Everything that seeds a clean configuration has to run this: the activation
766 * hook, the per-section reset and the global reset to defaults. Otherwise the
767 * defaults you get by pressing a button are not the defaults you get by
768 * installing the plugin, which is exactly what happened until 2.9.8.
769 *
770 * @since 2.9.8
771 *
772 * @param array $options Options array to adjust.
773 * @return array
774 */
775 public static function apply_install_tweaks( $options ) {
776 if ( ! isset( $options['wp_hardening'] ) || ! is_array( $options['wp_hardening'] ) ) {
777 $options['wp_hardening'] = array();
778 }
779
780 $options['wp_hardening']['xmlrpc_mode'] = 'pingback';
781
782 return $options;
783 }
784
785 /**
786 * Keys that hold what the site owner typed in, never wiped by a restore
787 *
788 * Putting the security posture back to its defaults is one thing; deleting
789 * an IP whitelist, the secret login address, the two factor setup or the
790 * addresses that receive the alerts is another, and nobody presses a button
791 * called "restore defaults" expecting that. Both the Standard preset and the
792 * two reset buttons leave these alone.
793 *
794 * @since 2.9.8
795 *
796 * @return array<string,string[]>
797 */
798 public static function get_user_data_keys() {
799 return array(
800 'firewall' => array( 'ip_whitelist', 'ip_blacklist', 'ua_whitelist', 'ua_blacklist', 'trusted_proxy_header', 'trusted_proxies' ),
801 'login_security' => array( 'ip_whitelist', 'custom_login_url', 'two_factor' ),
802 'user_security' => array( 'insecure_usernames' ),
803 'file_integrity' => array( 'excluded_paths', 'excluded_extensions' ),
804 'email' => array( 'additional_recipients' ),
805 );
806 }
807
808 /**
809 * Settings whose only effect is written to a file the network shares
810 *
811 * true for a whole section, or the list of keys inside it. Used to keep a
812 * subsite from resetting settings it does not control: the file is written
813 * from the main site, so resetting the local copy would only make the two
814 * disagree.
815 *
816 * Note this is not every setting that reaches .htaccess. Blocking bad bots
817 * or empty user agents also runs in PHP, per site, so those stay editable on
818 * a subsite: the PHP half protects that site and the .htaccess half is
819 * refused, leaving the main site's rules standing. On the main site they
820 * are locked too, see get_main_site_file_settings().
821 *
822 * The PHP blocks for plugins and themes have no field on the settings
823 * screen, but an imported file carries them, and readme.html and
824 * license.txt are removed from the root the whole network shares.
825 *
826 * @since 2.9.8
827 *
828 * @return array<string,true|string[]>
829 */
830 public static function get_shared_file_settings() {
831 return array(
832 'security_headers' => true,
833 'wp_hardening' => array( 'disallow_file_edit', 'disallow_file_mods', 'force_ssl_admin', 'force_ssl_login', 'wp_debug', 'disable_wp_cron' ),
834 'firewall' => array( 'disable_directory_browsing', 'protect_wp_config', 'protect_wp_includes', 'protect_uploads_php', 'protect_sensitive_files', 'protect_wp_cron', 'limit_http_methods', 'block_php_in_plugins', 'block_php_in_themes' ),
835 'advanced' => array( 'remove_readme', 'remove_license' ),
836 );
837 }
838
839 /**
840 * Settings the shared files are built from that also act on the site storing them
841 *
842 * get_shared_file_settings() lists what does nothing but end up in a shared
843 * file. These do both: blocking bad bots and bad query strings, the visitor
844 * IP detection and the two whitelists run in PHP for the site that stores
845 * them, and on the main site of a network they are also what the .htaccess
846 * rules of every site are generated from; the three writing module switches
847 * (firewall, security_headers, wp_hardening) decide whether the .htaccess
848 * blocks and the wp-config.php constants exist at all.
849 *
850 * Since 2.11.8 it also locks what decides whether the shared files are
851 * WATCHED, not built: the File Integrity module and its scan_critical_config
852 * switch. On the main site the critical-file scan is the network's canary
853 * for a change to wp-config.php or the root .htaccess, which only a network
854 * administrator can approve, so a main-site administrator without network
855 * rights must not be able to silence it by turning either one off. Closing
856 * the ignore list and the clear-results button in 2.11.8 left these two as
857 * the remaining routes; found by the audit of the admin surface.
858 *
859 * On a subsite all of them only act on that site, so they stay editable
860 * there (get_locked_file_settings() adds this set only when owns_shared_files()).
861 *
862 * Until 2.11.6 an administrator of the main site without network rights
863 * could change any of them, and the file-only ones too: the write to the
864 * file was refused at that moment, but the value stayed stored, and the
865 * refresh after the next update, or the next save by a network
866 * administrator, published it to the whole network.
867 *
868 * @since 2.11.6
869 * @since 2.11.8 The file_integrity module and scan_critical_config.
870 *
871 * @return array<string,string[]>
872 */
873 public static function get_main_site_file_settings() {
874 return array(
875 'modules' => array( 'firewall', 'security_headers', 'wp_hardening', 'file_integrity' ),
876 'firewall' => array( 'block_bad_bots', 'block_bad_query_strings', 'trusted_proxy_header', 'ip_whitelist', 'ua_whitelist' ),
877 'file_integrity' => array( 'scan_critical_config' ),
878 );
879 }
880
881 /**
882 * Shared file settings the current user may not change on this site
883 *
884 * Empty when the user can write the shared files. Otherwise the file-only
885 * settings on every site, plus, on the main site, the ones it also builds
886 * the shared files from.
887 *
888 * @since 2.11.6
889 *
890 * @return array<string,true|string[]>
891 */
892 public static function get_locked_file_settings() {
893 if ( self::can_write_shared_files() ) {
894 return array();
895 }
896
897 $locked = self::get_shared_file_settings();
898
899 if ( self::owns_shared_files() ) {
900 foreach ( self::get_main_site_file_settings() as $section => $keys ) {
901 if ( ! isset( $locked[ $section ] ) ) {
902 $locked[ $section ] = $keys;
903 } elseif ( is_array( $locked[ $section ] ) ) {
904 $locked[ $section ] = array_values( array_unique( array_merge( $locked[ $section ], $keys ) ) );
905 }
906 }
907 }
908
909 return $locked;
910 }
911
912 /**
913 * Put back the stored value of every shared file setting the user may not change
914 *
915 * For every writer of the whole configuration: saving a tab, importing a
916 * file, applying a preset, restoring the defaults. Hiding a field on the
917 * screen decides nothing, because the request can carry the key anyway. A
918 * key that was not stored is dropped, so its default keeps applying.
919 *
920 * @since 2.11.6
921 *
922 * @param array $options Configuration about to be stored.
923 * @param array $stored Configuration stored now, as read from the option.
924 * @return array
925 */
926 public static function keep_locked_file_settings( $options, $stored ) {
927 $options = is_array( $options ) ? $options : array();
928 $stored = is_array( $stored ) ? $stored : array();
929 $locked = self::get_locked_file_settings();
930
931 if ( ! $locked ) {
932 return $options;
933 }
934
935 /*
936 * A key that was never stored takes its default, which is what it was
937 * worth before. Until 2.11.8 it was dropped instead, and the sanitize
938 * callback of the option filled it in again, but validate_options()
939 * fills a missing module switch with false, not with its default.
940 */
941 $instance = new self();
942 $defaults = $instance->get_default_options();
943
944 foreach ( $locked as $section => $keys ) {
945 if ( true === $keys ) {
946 if ( array_key_exists( $section, $stored ) ) {
947 $options[ $section ] = $stored[ $section ];
948 } elseif ( isset( $defaults[ $section ] ) ) {
949 $options[ $section ] = $defaults[ $section ];
950 } else {
951 unset( $options[ $section ] );
952 }
953 continue;
954 }
955
956 $stored_section = ( isset( $stored[ $section ] ) && is_array( $stored[ $section ] ) ) ? $stored[ $section ] : array();
957 $default_section = ( isset( $defaults[ $section ] ) && is_array( $defaults[ $section ] ) ) ? $defaults[ $section ] : array();
958
959 foreach ( $keys as $key ) {
960 if ( array_key_exists( $key, $stored_section ) ) {
961 $value = $stored_section[ $key ];
962 } elseif ( array_key_exists( $key, $default_section ) ) {
963 $value = $default_section[ $key ];
964 } else {
965 if ( isset( $options[ $section ] ) && is_array( $options[ $section ] ) ) {
966 unset( $options[ $section ][ $key ] );
967 }
968 continue;
969 }
970
971 if ( ! isset( $options[ $section ] ) || ! is_array( $options[ $section ] ) ) {
972 $options[ $section ] = array();
973 }
974 $options[ $section ][ $key ] = $value;
975 }
976 }
977
978 return $options;
979 }
980
981 /**
982 * Take a lock kept as a row of the options table, or report that another request holds it
983 *
984 * add_option() cannot be a lock: it runs INSERT ... ON DUPLICATE KEY UPDATE
985 * (wp-includes/option.php:1142 in WP 7.1), so two requests that both find
986 * the option missing both "create" it and both believe they hold it. INSERT
987 * IGNORE creates the row for exactly one of them, which is what core does in
988 * WP_Upgrader::create_lock() (wp-admin/includes/class-wp-upgrader.php:1065).
989 * A lock older than the timeout counts as abandoned, by a fatal error between
990 * taking and releasing it, and only one request takes it over.
991 *
992 * @since 2.11.8
993 *
994 * @param string $name Option name of the lock, in the current site's table.
995 * @param int $timeout Seconds after which a held lock counts as abandoned.
996 * @return bool True if this request now holds the lock.
997 */
998 public static function acquire_option_lock( $name, $timeout ) {
999 global $wpdb;
1000
1001 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- an atomic lock needs INSERT IGNORE, which the options API does not offer; same query as WP_Upgrader::create_lock().
1002 if ( $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} ( option_name, option_value, autoload ) VALUES ( %s, %s, 'no' )", $name, (string) time() ) ) ) {
1003 wp_cache_delete( $name, 'options' );
1004 return true;
1005 }
1006
1007 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- the lock row as stored right now, not a cached copy.
1008 $held = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $name ) );
1009
1010 if ( null === $held || ( time() - (int) $held ) < $timeout ) {
1011 return false;
1012 }
1013
1014 // Abandoned: the delete only matches the value that was read, and only one
1015 // request wins the insert that follows.
1016 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- removes an abandoned lock row.
1017 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", $name, $held ) );
1018
1019 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- same atomic insert as above.
1020 return (bool) $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} ( option_name, option_value, autoload ) VALUES ( %s, %s, 'no' )", $name, (string) time() ) );
1021 }
1022
1023 /**
1024 * Release a lock taken with acquire_option_lock()
1025 *
1026 * @since 2.11.8
1027 *
1028 * @param string $name Option name of the lock.
1029 */
1030 public static function release_option_lock( $name ) {
1031 global $wpdb;
1032
1033 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- removes the row acquire_option_lock() inserted.
1034 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s", $name ) );
1035 wp_cache_delete( $name, 'options' );
1036 }
1037
1038 /**
1039 * Put a configuration back to the defaults without deleting what the owner typed
1040 *
1041 * @since 2.9.8
1042 *
1043 * @param array $current Configuration being replaced.
1044 * @return array
1045 */
1046 public static function get_defaults_preserving_user_data( $current ) {
1047 $instance = new self();
1048 $defaults = self::apply_install_tweaks( $instance->get_default_options() );
1049
1050 foreach ( self::get_user_data_keys() as $section => $keys ) {
1051 foreach ( $keys as $key ) {
1052 if ( isset( $current[ $section ] ) && array_key_exists( $key, (array) $current[ $section ] ) ) {
1053 $defaults[ $section ][ $key ] = $current[ $section ][ $key ];
1054 }
1055 }
1056 }
1057
1058 return $defaults;
1059 }
1060
1061 /**
1062 * The values the Standard preset applies
1063 *
1064 * Standard is the configuration a new installation gets, with every module
1065 * on. It is built from the defaults rather than written out by hand, because
1066 * a hand-written copy drifts: until 2.9.8 Standard named a dozen fields and
1067 * left everything else alone, so applying it after Maximum kept Maximum's
1068 * password rules, its administrator alerts, its session limits and its
1069 * password expiry, and the preset that says it applies sensible defaults
1070 * applied almost none of them.
1071 *
1072 * The only thing it does not touch is what the site owner typed in: IP and
1073 * user agent lists, the custom login address, two factor configuration, the
1074 * integrity scan exclusions and the extra notification recipients. Putting
1075 * the security posture back to the defaults is one thing, throwing away
1076 * someone's whitelist is another, and "Reset to Defaults" is right there for
1077 * that.
1078 *
1079 * @since 2.9.8
1080 *
1081 * @return array
1082 */
1083 private function get_standard_preset_values() {
1084 $values = self::apply_install_tweaks( $this->get_default_options() );
1085
1086 foreach ( array_keys( $values['modules'] ) as $module ) {
1087 $values['modules'][ $module ] = true;
1088 }
1089
1090 foreach ( self::get_user_data_keys() as $section => $keys ) {
1091 foreach ( $keys as $key ) {
1092 unset( $values[ $section ][ $key ] );
1093 }
1094 }
1095
1096 unset( $values['user_security']['password_expiration']['excluded_users'] );
1097
1098 return $values;
1099 }
1100
1101 /**
1102 * Merge a preset over a configuration
1103 *
1104 * Not array_replace_recursive(), which is wrong for this in two ways. A list
1105 * of roles in the preset is merged position by position instead of replacing
1106 * the stored one, so applying Standard over Maximum turned the two roles
1107 * Standard expires passwords for into Maximum's five with the first two
1108 * overwritten. And an empty list in the preset clears nothing at all,
1109 * because there is no element to replace with.
1110 *
1111 * So: associative arrays are merged key by key, and lists and scalars are
1112 * replaced outright.
1113 *
1114 * @since 2.9.8
1115 *
1116 * @param array $base Current configuration.
1117 * @param array $overlay Preset values.
1118 * @return array
1119 */
1120 public static function merge_preset( $base, $overlay ) {
1121 foreach ( $overlay as $key => $value ) {
1122 if ( is_array( $value ) && isset( $base[ $key ] ) && is_array( $base[ $key ] ) && ! self::is_list( $value ) ) {
1123 $base[ $key ] = self::merge_preset( $base[ $key ], $value );
1124 continue;
1125 }
1126
1127 $base[ $key ] = $value;
1128 }
1129
1130 return $base;
1131 }
1132
1133 /**
1134 * Whether an array is a plain list (0..n-1 keys)
1135 *
1136 * array_is_list() is PHP 8.1 and this plugin supports 7.4.
1137 *
1138 * @since 2.9.8
1139 *
1140 * @param array $value Array to inspect.
1141 * @return bool
1142 */
1143 private static function is_list( $value ) {
1144 if ( array() === $value ) {
1145 return true;
1146 }
1147
1148 return array_keys( $value ) === range( 0, count( $value ) - 1 );
1149 }
1150
1151 /**
1152 * Get presets with descriptions
1153 *
1154 * @return array Presets configuration.
1155 */
1156 public function get_presets() {
1157 return array(
1158 'standard' => array_merge(
1159 array(
1160 'name' => __( 'Standard', 'vigilante' ),
1161 'description' => __( 'Balanced security suitable for most websites. Enables every module and puts every setting back to the value a new installation gets.', 'vigilante' ),
1162 ),
1163 $this->get_standard_preset_values()
1164 ),
1165
1166 'maximum' => array(
1167 'name' => __( 'Maximum Security', 'vigilante' ),
1168 'description' => __( 'Strictest settings for high-security sites. CSP is set to report-only mode to prevent breaking the admin interface.', 'vigilante' ),
1169 'modules' => array(
1170 'firewall' => true,
1171 'security_headers' => true,
1172 'login_security' => true,
1173 'rest_api_security'=> true,
1174 'user_security' => true,
1175 'wp_hardening' => true,
1176 'file_integrity' => true,
1177 'activity_log' => true,
1178 ),
1179 'firewall' => array(
1180 'block_bad_query_strings' => true,
1181 'block_sql_injection' => true,
1182 'block_xss_attacks' => true,
1183 'block_file_inclusion' => true,
1184 'block_directory_traversal' => true,
1185 'block_bad_bots' => true,
1186 'block_empty_user_agent' => true,
1187 'rate_limiting' => array(
1188 'enabled' => true,
1189 'requests_per_minute' => 60,
1190 'block_duration' => 600,
1191 'progressive' => true,
1192 'max_block_duration' => 86400,
1193 ),
1194 ),
1195 'security_headers' => array(
1196 'x_frame_options' => 'DENY',
1197 // HSTS is intentionally NOT enabled by Maximum: forcing HSTS on a site
1198 // that doesn't have a healthy HTTPS setup (or temporarily falls back to
1199 // HTTP) locks visitors out for the full max_age. Leaving HSTS off keeps
1200 // it as an explicit opt-in decision per site.
1201 'csp' => array(
1202 'enabled' => true,
1203 'report_only' => false,
1204 'directives' => array(
1205 'default-src' => "'self'",
1206 'script-src' => "'self' 'unsafe-inline' 'unsafe-eval'",
1207 'style-src' => "'self' 'unsafe-inline'",
1208 'img-src' => "'self' data: https: blob:",
1209 'font-src' => "'self' data:",
1210 'connect-src' => "'self' https: blob:",
1211 'frame-src' => "'self' blob:",
1212 'frame-ancestors' => "'none'",
1213 'worker-src' => "'self' blob:",
1214 'object-src' => "'none'",
1215 'base-uri' => "'self'",
1216 ),
1217 ),
1218 ),
1219 'rest_api_security' => array(
1220 'mode' => 'authenticated_only',
1221 ),
1222 'login_security' => array(
1223 'max_attempts' => 3,
1224 'lockout_duration' => 3600,
1225 'lockout_increment' => true,
1226 'notify_on_lockout' => true,
1227 'notify_on_admin_login' => true,
1228 ),
1229 'wp_hardening' => array(
1230 'xmlrpc_mode' => 'full',
1231 'disallow_file_edit' => true,
1232 'disallow_file_mods' => true,
1233 // close_old_comments is intentionally NOT touched by Maximum:
1234 // it would unilaterally close discussion on every old post,
1235 // which is a content decision, not a security one.
1236 ),
1237 'user_security' => array(
1238 'prevent_display_name_login_match' => true,
1239 'min_password_length' => 16,
1240 'password_policy' => array(
1241 'require_uppercase' => true,
1242 'require_lowercase' => true,
1243 'require_number' => true,
1244 'require_special' => true,
1245 'block_common' => true,
1246 'block_username' => true,
1247 'affected_roles' => array(),
1248 ),
1249 'admin_monitoring' => array(
1250 'alert_new_admin' => true,
1251 'alert_admin_email_change' => true,
1252 'alert_permission_elevation' => true,
1253 'alert_admin_password_change' => true,
1254 ),
1255 'registration_approval' => array(
1256 'enabled' => true,
1257 'notify_admin' => true,
1258 'auto_reject_days' => 7,
1259 'affected_roles' => array( 'subscriber', 'contributor', 'author', 'editor' ),
1260 ),
1261 'session_limits' => array(
1262 'enabled' => true,
1263 'max_sessions' => 1,
1264 'behavior' => 'close_oldest',
1265 'exclude_admins' => false,
1266 ),
1267 'password_expiration' => array(
1268 'enabled' => true,
1269 'expire_days' => 30,
1270 'warning_days' => 7,
1271 'affected_roles' => array( 'administrator', 'editor', 'author', 'contributor', 'subscriber' ),
1272 'password_history' => 5,
1273 'send_reminder' => true,
1274 ),
1275 'email_verification' => array(
1276 'enabled' => true,
1277 'token_expiry_hours' => 24,
1278 'allow_resend' => true,
1279 'auto_delete_days' => 3,
1280 ),
1281 ),
1282 'file_integrity' => array(
1283 'scan_core' => true,
1284 'scan_plugins' => true,
1285 'scan_themes' => true,
1286 'scan_uploads' => true,
1287 'scan_critical_config' => true,
1288 'auto_scan' => true,
1289 'scan_frequency' => 'daily',
1290 'notify_level' => 'all',
1291 'instant_alert' => true,
1292 ),
1293 // A configuration called Maximum Security that never tells you
1294 // anything happened is half a product, so the audit alerts ship
1295 // on with it. The shared cooldown keeps a sustained attack from
1296 // turning into a flood. Under Attack mode builds on this preset,
1297 // so it inherits them for as long as it is on and gives them back
1298 // when it is switched off.
1299 'audit_alerts' => array(
1300 'immediate' => array(
1301 'enabled' => true,
1302 'min_severity' => 'critical',
1303 ),
1304 'threshold' => array(
1305 'enabled' => true,
1306 ),
1307 ),
1308 'activity_log' => array(
1309 'log_logins' => true,
1310 'log_failed_logins' => true,
1311 'log_user_changes' => true,
1312 'log_post_changes' => true,
1313 'log_plugin_changes' => true,
1314 'log_theme_changes' => true,
1315 'log_option_changes' => true,
1316 'log_file_changes' => true,
1317 'log_comments' => true,
1318 'log_media' => true,
1319 ),
1320 ),
1321 );
1322 }
1323
1324 /**
1325 * Get module labels for display
1326 *
1327 * @return array Module labels.
1328 */
1329 public function get_module_labels() {
1330 return array(
1331 'firewall' => __( 'Firewall', 'vigilante' ),
1332 'security_headers' => __( 'Security Headers', 'vigilante' ),
1333 'login_security' => __( 'Login Security', 'vigilante' ),
1334 'rest_api_security'=> __( 'REST API Security', 'vigilante' ),
1335 'user_security' => __( 'User Security', 'vigilante' ),
1336 'wp_hardening' => __( 'WordPress Hardening', 'vigilante' ),
1337 'file_integrity' => __( 'File Integrity', 'vigilante' ),
1338 'activity_log' => __( 'Security Audit', 'vigilante' ),
1339 );
1340 }
1341
1342 /**
1343 * Get module descriptions for display
1344 *
1345 * @return array Module descriptions.
1346 */
1347 public function get_module_descriptions() {
1348 return array(
1349 'firewall' => __( 'Blocks malicious requests, SQL injection, XSS attacks, and bad bots. Includes rate limiting and file protection.', 'vigilante' ),
1350 'security_headers' => __( 'Adds HTTP security headers like CSP, HSTS, X-Frame-Options. Forces HTTPS and fixes mixed content.', 'vigilante' ),
1351 'login_security' => __( 'Brute force protection, 2FA, login attempt limits, XML-RPC control, and notifications.', 'vigilante' ),
1352 'rest_api_security'=> __( 'Controls REST API access, blocks user enumeration, and protects sensitive endpoints.', 'vigilante' ),
1353 'user_security' => __( 'Blocks insecure usernames, enforces strong passwords, and prevents author scanning.', 'vigilante' ),
1354 'wp_hardening' => __( 'Hardens wp-config.php, manages comments, cleans header output, and controls feeds.', 'vigilante' ),
1355 'file_integrity' => __( 'Scans WordPress core, plugins, and themes for unauthorized changes and suspicious code.', 'vigilante' ),
1356 'activity_log' => __( 'Records user actions, logins, content changes, and security events for security auditing.', 'vigilante' ),
1357 );
1358 }
1359
1360 /**
1361 * Validate options before saving
1362 *
1363 * @param array $input Raw input to validate.
1364 * @return array Validated options.
1365 */
1366 public function validate_options( $input ) {
1367 $validated = array();
1368 $defaults = $this->get_default_options();
1369
1370 // Validate each section that exists in input
1371 foreach ( $input as $section => $data ) {
1372 if ( ! is_array( $data ) ) {
1373 continue;
1374 }
1375
1376 if ( 'modules' === $section ) {
1377 // Validate modules (booleans)
1378 foreach ( $defaults['modules'] as $module => $default_value ) {
1379 $validated['modules'][ $module ] = isset( $data[ $module ] )
1380 ? (bool) $data[ $module ]
1381 : false;
1382 }
1383 } elseif ( isset( $defaults[ $section ] ) ) {
1384 // Validate other sections using generic validator
1385 $validated[ $section ] = $this->validate_section( $data, $defaults[ $section ] );
1386
1387 // The few keys that live outside get_default_options() on
1388 // purpose (see apply_install_tweaks()) survive with their own
1389 // validation, or an import would silently lose them and the
1390 // XML-RPC resolver would fall back to blocking everything.
1391 foreach ( self::undeclared_keys( $section ) as $key => $type ) {
1392 if ( ! array_key_exists( $key, $data ) ) {
1393 continue;
1394 }
1395 if ( 'bool' === $type ) {
1396 $validated[ $section ][ $key ] = (bool) $data[ $key ];
1397 } elseif ( is_array( $type ) && in_array( $data[ $key ], $type, true ) ) {
1398 $validated[ $section ][ $key ] = $data[ $key ];
1399 }
1400 }
1401 }
1402 }
1403
1404 return apply_filters( 'vigilante_validate_options', $validated, $input );
1405 }
1406
1407 /**
1408 * Keys deliberately absent from get_default_options(), with how to validate them
1409 *
1410 * Declaring them as defaults would break the fallback they exist for (see
1411 * apply_install_tweaks()), but the validator still has to know them, or a
1412 * settings import drops them (found in the 2.11.0 cross review).
1413 *
1414 * @since 2.11.0
1415 *
1416 * @param string $section Section name.
1417 * @return array key => 'bool' or list of allowed values.
1418 */
1419 private static function undeclared_keys( $section ) {
1420 $keys = array(
1421 'wp_hardening' => array( 'xmlrpc_mode' => array( 'full', 'pingback', 'none' ) ),
1422 'login_security' => array(
1423 'disable_xmlrpc' => 'bool',
1424 'disable_xmlrpc_pingback' => 'bool',
1425 ),
1426 );
1427
1428 return isset( $keys[ $section ] ) ? $keys[ $section ] : array();
1429 }
1430
1431 /**
1432 * Whether a default value describes a free list rather than a schema
1433 *
1434 * An empty array or sequential numeric keys (an IP whitelist, a list of
1435 * roles) is a list: every entry the user typed is kept. Anything else is a
1436 * schema: only its keys survive validation.
1437 *
1438 * @since 2.11.0
1439 *
1440 * @param array $defaults Default value of a setting.
1441 * @return bool
1442 */
1443 private function is_list_default( $defaults ) {
1444 if ( array() === $defaults ) {
1445 return true;
1446 }
1447
1448 return array_keys( $defaults ) === range( 0, count( $defaults ) - 1 );
1449 }
1450
1451 /**
1452 * Validate a section based on defaults
1453 *
1454 * Since 2.11.0 the result only holds keys the defaults know. The loop that
1455 * used to reincorporate unknown keys "sanitized" meant a settings import
1456 * could merge any key it liked into vigilante_options (S7 of the 28 Aug
1457 * 2026 audit). Lists are the exception, handled first: their entries are
1458 * data, not keys.
1459 *
1460 * @param array $input Input values.
1461 * @param array $defaults Default values.
1462 * @return array Validated values.
1463 */
1464 private function validate_section( $input, $defaults ) {
1465 $validated = array();
1466
1467 if ( $this->is_list_default( $defaults ) ) {
1468 if ( ! is_array( $input ) ) {
1469 return array();
1470 }
1471
1472 $list = array();
1473 foreach ( $input as $value ) {
1474 if ( is_scalar( $value ) ) {
1475 $list[] = sanitize_text_field( (string) $value );
1476 } elseif ( is_array( $value ) ) {
1477 $list[] = map_deep( $value, 'sanitize_text_field' );
1478 }
1479 }
1480
1481 return $list;
1482 }
1483
1484 foreach ( $defaults as $key => $default_value ) {
1485 if ( ! isset( $input[ $key ] ) ) {
1486 $validated[ $key ] = $default_value;
1487 continue;
1488 }
1489
1490 $value = $input[ $key ];
1491
1492 if ( is_bool( $default_value ) ) {
1493 $validated[ $key ] = (bool) $value;
1494 } elseif ( is_int( $default_value ) ) {
1495 $validated[ $key ] = intval( $value );
1496 } elseif ( is_array( $default_value ) ) {
1497 if ( is_array( $value ) ) {
1498 $validated[ $key ] = $this->validate_section( $value, $default_value );
1499 } else {
1500 $validated[ $key ] = $default_value;
1501 }
1502 } else {
1503 $validated[ $key ] = sanitize_text_field( $value );
1504 }
1505 }
1506
1507 // Keys the defaults do not declare are dropped on purpose (S7).
1508
1509 return $validated;
1510 }
1511 }