PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.8
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.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
← All changes | admin/class-admin.php +789 -138 2.9.82.11.8 View file →
@@ -27,8 +27,9 @@
27 27
28 28 use Vigilante_Admin_Ajax;
29 29 use Vigilante_Admin_Analyzer_Ajax;
30 30 use Vigilante_Admin_Audit_Alerts_Ajax;
31 + use Vigilante_Admin_Recovery_Ajax;
31 32
32 33 /**
33 34 * Settings instance
34 35 *
@@ -182,8 +183,13 @@
182 183 add_action( 'wp_ajax_vigilante_analyzer_history', array( $this, 'ajax_analyzer_history' ) );
183 184 add_action( 'wp_ajax_vigilante_analyzer_dismiss_notice', array( $this, 'ajax_analyzer_dismiss_notice' ) );
184 185 add_action( 'wp_ajax_vigilante_analyzer_save_settings', array( $this, 'ajax_analyzer_save_settings' ) );
185 186
187 + // Security Headers settings recovery (2.10.0)
188 + add_action( 'wp_ajax_vigilante_headers_recovery_restore', array( $this, 'ajax_headers_recovery_restore' ) );
189 + add_action( 'wp_ajax_vigilante_headers_recovery_undo', array( $this, 'ajax_headers_recovery_undo' ) );
190 + add_action( 'wp_ajax_vigilante_headers_recovery_dismiss', array( $this, 'ajax_headers_recovery_dismiss' ) );
191 +
186 192 // Shared "Send test email" handler — Notification settings, File Integrity, Audit Alerts (v2.8.0)
187 193 add_action( 'wp_ajax_vigilante_send_test_email', array( $this, 'ajax_send_test_email' ) );
188 194
189 195 // Run migrations on admin load
@@ -267,9 +273,29 @@
267 273 if ( ! class_exists( 'Vigilante_File_Integrity' ) ) {
268 274 require_once VIGILANTE_INCLUDES_DIR . 'class-file-integrity.php';
269 275 }
270 276 $fi = new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
271 - $fi->regenerate_all_baselines();
277 +
278 + /*
279 + * Only when there is nothing on record. This migration exists to
280 + * create the baseline that did not exist, never to discard the one
281 + * the owner approved: rebuilding it from the files takes whatever
282 + * is on disk right now as approved, so a wp-config.php modified and
283 + * awaiting review would be blessed in silence.
284 + *
285 + * And this is not theory. vigilante_db_version is written on two
286 + * different scales into the same option: this file counts in plugin
287 + * versions (2.11.0) and Vigilante_Database counts in schema
288 + * versions, currently 1.4.0 (class-database.php:322 and :380). For
289 + * version_compare, 1.4.0 is LOWER than 1.14.0, so any site whose
290 + * option was last written by the schema runs this migration again.
291 + * Measured on the Multisite install on 10 sep 2026: one of the three
292 + * sites was sitting on 1.4.0.
293 + */
294 + if ( ! $fi->get_critical_files_baseline() ) {
295 + $fi->regenerate_all_baselines();
296 + }
297 +
272 298 update_option( 'vigilante_db_version', '1.14.0' );
273 299 }
274 300
275 301 // 2.0.0: Move hide_server_signature and remove_fingerprinting_headers
@@ -361,18 +387,85 @@
361 387 $raw = get_option( Vigilante_Settings::OPTION_NAME, array() );
362 388 $stored = ( is_array( $raw ) && isset( $raw['security_headers'] ) && is_array( $raw['security_headers'] ) ) ? $raw['security_headers'] : array();
363 389 $had_fix = array_key_exists( 'fix_mixed_content', $stored ) ? ! empty( $stored['fix_mixed_content'] ) : true;
364 390
391 + /*
392 + * Merge, never replace. update_section() overwrites the whole
393 + * section, so passing just these two keys wiped every other header
394 + * setting the site had stored (HSTS, CSP, cross-origin policies,
395 + * the HTTPS switches, Server Identity) and left the screen showing
396 + * factory defaults while the .htaccess kept serving the old values.
397 + */
365 398 $this->settings->update_section(
366 399 'security_headers',
367 - array(
368 - 'fix_mixed_content' => $had_fix,
369 - 'upgrade_insecure_requests' => $had_fix,
400 + array_merge(
401 + $stored,
402 + array(
403 + 'fix_mixed_content' => $had_fix,
404 + 'upgrade_insecure_requests' => $had_fix,
405 + )
370 406 )
371 407 );
372 408
373 409 update_option( 'vigilante_db_version', '2.9.8' );
374 410 }
411 +
412 + /*
413 + * 2.9.9: drop the settings that no code has read for versions.
414 + *
415 + * They were carried in the defaults and therefore written into every
416 + * saved configuration, they show up in an exported configuration, and
417 + * anyone reading them assumes a feature exists behind them. Removing
418 + * them from the defaults is not enough: the stored copies survive, so
419 + * they are swept here too. Nothing reads them, so nothing changes.
420 + */
421 + if ( version_compare( $db_version, '2.9.9', '<' ) ) {
422 + $raw = get_option( Vigilante_Settings::OPTION_NAME, array() );
423 + $dead = array(
424 + 'firewall' => array( 'country_blocking', 'protected_file_extensions' ),
425 + 'file_integrity' => array( 'suspicious_patterns' ),
426 + 'backup' => array( 'auto_backup', 'backup_before_update' ),
427 + 'advanced' => array( 'block_author_archives', 'disable_embeds', 'uninstall_cleanup', 'debug_mode' ),
428 + );
429 +
430 + $changed = false;
431 + foreach ( $dead as $section => $keys ) {
432 + if ( ! isset( $raw[ $section ] ) || ! is_array( $raw[ $section ] ) ) {
433 + continue;
434 + }
435 + foreach ( $keys as $key ) {
436 + if ( array_key_exists( $key, $raw[ $section ] ) ) {
437 + unset( $raw[ $section ][ $key ] );
438 + $changed = true;
439 + }
440 + }
441 + }
442 +
443 + if ( $changed ) {
444 + update_option( Vigilante_Settings::OPTION_NAME, $raw );
445 + }
446 +
447 + update_option( 'vigilante_db_version', '2.9.9' );
448 + }
449 +
450 + /*
451 + * 2.11.0: security release (audit of 28 Aug 2026). Runs here and not
452 + * from Vigilante_Database::needs_update(): this option is shared with
453 + * that class, and on any updated site it already holds a plugin version
454 + * (2.9.9 or later), so a bump of DB_VERSION would never fire.
455 + * create_tables() widens the email code column through dbDelta (varchar
456 + * 6 to 64, the code is stored hashed since 2.11.0) and purge_for_2_11_0()
457 + * does what dbDelta cannot: it empties the trusted devices, which were
458 + * identified by User-Agent until now (S1), and the pending email codes,
459 + * stored in clear until now (S11). Every remembered device asks for the
460 + * second factor once more after this update, and the changelog says so.
461 + */
462 + if ( version_compare( $db_version, '2.11.0', '<' ) ) {
463 + $this->database->create_tables();
464 + $this->database->purge_for_2_11_0();
465 +
466 + update_option( 'vigilante_db_version', '2.11.0' );
467 + }
375 468 }
376 469
377 470 /**
378 471 * Migration: Remove orphaned email fields from saved options
@@ -1177,8 +1270,12 @@
1177 1270 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'HSTS', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'HSTS', 'vigilante' ), 'label_en' => 'HSTS', 'keywords' => _x( 'hsts strict transport security ssl tls https headers', 'settings search keywords', 'vigilante' ) ),
1178 1271 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Content Security Policy', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Content Security Policy', 'vigilante' ), 'label_en' => 'Content Security Policy', 'keywords' => _x( 'content security policy csp xss headers', 'settings search keywords', 'vigilante' ) ),
1179 1272 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Server Identity', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Server Signature', 'vigilante' ), 'label_en' => 'Server Signature', 'keywords' => _x( 'server signature fingerprint banner', 'settings search keywords', 'vigilante' ) ),
1180 1273 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Server Identity', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Remove Fingerprinting Headers', 'vigilante' ), 'label_en' => 'Remove Fingerprinting Headers', 'keywords' => _x( 'remove fingerprinting headers fingerprint banner header http', 'settings search keywords', 'vigilante' ) ),
1274 + // Security Headers - Cross-Origin Policies
1275 + array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Cross-Origin Policies', 'vigilante' ), 'anchor' => 'vigilante-section-headers-cross-origin', 'label' => __( 'Cross-Origin-Opener-Policy (COOP)', 'vigilante' ), 'label_en' => 'Cross-Origin-Opener-Policy (COOP)', 'keywords' => _x( 'coop cross-origin opener policy popup popups window opener tag assistant google isolation browsing context headers', 'settings search keywords', 'vigilante' ) ),
1276 + array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Cross-Origin Policies', 'vigilante' ), 'anchor' => 'vigilante-section-headers-cross-origin', 'label' => __( 'Cross-Origin-Embedder-Policy (COEP)', 'vigilante' ), 'label_en' => 'Cross-Origin-Embedder-Policy (COEP)', 'keywords' => _x( 'coep cross-origin embedder policy require-corp credentialless embed embeds iframe fonts headers', 'settings search keywords', 'vigilante' ) ),
1277 + array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Cross-Origin Policies', 'vigilante' ), 'anchor' => 'vigilante-section-headers-cross-origin', 'label' => __( 'Cross-Origin-Resource-Policy (CORP)', 'vigilante' ), 'label_en' => 'Cross-Origin-Resource-Policy (CORP)', 'keywords' => _x( 'corp cross-origin resource policy hotlink hotlinking cdn images assets headers', 'settings search keywords', 'vigilante' ) ),
1181 1278 // Login Security
1182 1279 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Custom login URL', 'vigilante' ), 'label_en' => 'Custom login URL', 'keywords' => _x( 'custom login url signin log-in access slug', 'settings search keywords', 'vigilante' ) ),
1183 1280 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Two-Factor Authentication', 'vigilante' ), 'label_en' => 'Two-Factor Authentication', 'keywords' => _x( 'two-factor authentication 2fa mfa otp totp authenticator', 'settings search keywords', 'vigilante' ) ),
1184 1281 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( '2FA', 'vigilante' ), 'label_en' => '2FA', 'keywords' => _x( '2fa two-factor mfa otp totp authenticator', 'settings search keywords', 'vigilante' ) ),
@@ -1371,8 +1468,11 @@
1371 1468 'currentUserId' => get_current_user_id(),
1372 1469 'logoutUrl' => wp_logout_url( wp_login_url() ),
1373 1470 'adminUrl' => admin_url( 'admin.php?page=vigilante' ),
1374 1471 'searchIndex' => $this->get_search_index(),
1472 + // The scan repaints this table from JavaScript, so the same gate
1473 + // has to travel with it or half the screen keeps the dead button.
1474 + 'approvalLocked' => $this->critical_approval_locked(),
1375 1475 'underAttack' => array(
1376 1476 'active' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->is_active(),
1377 1477 'remaining' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->get_remaining_time(),
1378 1478 ),
@@ -1439,13 +1539,17 @@
1439 1539 'criticalConfigTitle' => __( 'Critical config files modified', 'vigilante' ),
1440 1540 'criticalConfigDesc' => __( 'These files are common targets for code injection. Review the changes and approve if they are legitimate. Vigilant\'s own blocks are excluded from this check.', 'vigilante' ),
1441 1541 'approve' => __( 'Approve', 'vigilante' ),
1442 1542 'approving' => __( 'Approving...', 'vigilante' ),
1543 + 'approvalLockedNotice' => $this->critical_approval_notice(),
1443 1544 'criticalApproved' => __( 'Change approved. Next scan will use the current state as baseline.', 'vigilante' ),
1444 1545 'reviewChanges' => __( 'Review changes', 'vigilante' ),
1445 1546 'hideChanges' => __( 'Hide changes', 'vigilante' ),
1446 1547 'changes' => __( 'Changes', 'vigilante' ),
1447 1548 'diffUnavailable' => __( 'Diff not available for this file (baseline was created before diff tracking was added). Approve to enable diff on future changes.', 'vigilante' ),
1549 + 'diffNetwork' => __( 'This file belongs to the whole network, so its line changes are only shown to network administrators, on the main site.', 'vigilante' ),
1550 + 'diffRescan' => __( 'Run a new scan to see the line changes of this file.', 'vigilante' ),
1551 + 'diffRedaction' => __( 'The line changes of this file are not shown because a value in it could not be hidden safely. The change itself is still detected.', 'vigilante' ),
1448 1552 'diffEmpty' => __( 'No line-level changes detected (may be whitespace or reordering).', 'vigilante' ),
1449 1553 'diffLines' => __( 'lines', 'vigilante' ),
1450 1554 // Under Attack mode strings
1451 1555 'underAttackConfirmActivate' => __( 'Activate Under Attack mode? All visitors will see a verification page for the next 4 hours.', 'vigilante' ),
@@ -1501,8 +1605,9 @@
1501 1605 'logType' => __( 'Type', 'vigilante' ),
1502 1606 'logAction' => __( 'Action', 'vigilante' ),
1503 1607 'logSeverity' => __( 'Severity', 'vigilante' ),
1504 1608 'logMessage' => __( 'Message', 'vigilante' ),
1609 + 'logRequestUri' => __( 'Address', 'vigilante' ),
1505 1610 'logClient' => __( 'Client', 'vigilante' ),
1506 1611 'logUser' => __( 'User', 'vigilante' ),
1507 1612 'logIpAddress' => __( 'IP Address', 'vigilante' ),
1508 1613 'logUserAgent' => __( 'User Agent', 'vigilante' ),
@@ -1537,8 +1642,10 @@
1537 1642 /* translators: 1: selected count, 2: human-readable size */
1538 1643 'dbTablesSelected' => __( '%1$d tables selected (%2$s)', 'vigilante' ),
1539 1644 // Settings search strings
1540 1645 'searchNoResults' => __( 'No matching settings found.', 'vigilante' ),
1646 + /* translators: %d: number of results that did not fit in the list. */
1647 + 'searchMoreResults' => __( '%d more results. Refine the search to see them.', 'vigilante' ),
1541 1648 'searchInTab' => __( 'in', 'vigilante' ),
1542 1649 // Modules string
1543 1650 /* translators: 1: enabled count, 2: total count */
1544 1651 'modulesEnabled' => __( '%1$d / %2$d modules enabled', 'vigilante' ),
@@ -1705,8 +1812,21 @@
1705 1812 </p>
1706 1813 <p>
1707 1814 <em><?php esc_html_e( 'Vigilant has applied the Maximum preset plus extra hardening on top of your previous configuration. Any changes you make to Vigilant settings while this mode is active will be reverted when it ends.', 'vigilante' ); ?></em>
1708 1815 </p>
1816 + <?php
1817 + // The cache-bypass rules could not be written (a host where
1818 + // WordPress cannot write files by itself, a held lock, a
1819 + // failed read-back): show them, so they can be added by hand.
1820 + $ua_instance = new Vigilante_Under_Attack( $this->settings, $this->activity_log );
1821 + if ( $ua_instance->cache_rules_missing() ) :
1822 + ?>
1823 + <p>
1824 + <strong><?php esc_html_e( 'The cache-bypass rules could not be written to your .htaccess.', 'vigilante' ); ?></strong>
1825 + <?php esc_html_e( 'Without them a page cache may keep serving stored pages during the attack. Add this block at the top of the .htaccess in your site root (the activity log records why it was not written):', 'vigilante' ); ?>
1826 + </p>
1827 + <textarea readonly rows="9" class="large-text code" onclick="this.select();"><?php echo esc_textarea( Vigilante_Under_Attack::get_cache_bypass_block() ); ?></textarea>
1828 + <?php endif; ?>
1709 1829 </div>
1710 1830 <?php
1711 1831 }
1712 1832 }
@@ -1808,9 +1928,9 @@
1808 1928 </h1>
1809 1929 <div class="vigilante-search-wrapper">
1810 1930 <div class="vigilante-search-input-wrap">
1811 1931 <span class="vigilante-search-icon dashicons dashicons-search" aria-hidden="true"></span>
1812 - <input type="search" id="vigilante-settings-search" class="vigilante-settings-search" placeholder="<?php esc_attr_e( 'Search settings…', 'vigilante' ); ?>" autocomplete="off">
1932 + <input type="search" id="vigilante-settings-search" class="vigilante-settings-search" aria-label="<?php esc_attr_e( 'Search settings', 'vigilante' ); ?>" placeholder="<?php esc_attr_e( 'Search settings…', 'vigilante' ); ?>" autocomplete="off">
1813 1933 <span class="vigilante-search-shortcut" aria-hidden="true">/</span>
1814 1934 </div>
1815 1935 <div id="vigilante-settings-search-results" class="vigilante-search-results" hidden role="listbox"></div>
1816 1936 </div>
@@ -1938,8 +2058,41 @@
1938 2058 return ! Vigilante_Settings::can_write_shared_files();
1939 2059 }
1940 2060
1941 2061 /**
2062 + * Whether this is the main site and the user cannot change what it builds the shared files from
2063 + *
2064 + * See Vigilante_Settings::get_main_site_file_settings(). On a subsite those
2065 + * settings only act on that site, so they are never locked there.
2066 + *
2067 + * @since 2.11.6
2068 + *
2069 + * @return bool
2070 + */
2071 + private function main_site_files_locked() {
2072 + return $this->shared_files_locked() && Vigilante_Settings::owns_shared_files();
2073 + }
2074 +
2075 + /**
2076 + * Sentence added to a bulk change when some settings were left as they were
2077 + *
2078 + * Importing a file, applying a preset and restoring the defaults touch every
2079 + * section at once, so the user is told that the shared file settings did
2080 + * not move.
2081 + *
2082 + * @since 2.11.6
2083 + *
2084 + * @return string Empty when the user can change every setting.
2085 + */
2086 + private function locked_file_settings_message() {
2087 + if ( ! Vigilante_Settings::get_locked_file_settings() ) {
2088 + return '';
2089 + }
2090 +
2091 + return ' ' . __( 'The settings that end up in wp-config.php or .htaccess were left as they were.', 'vigilante' ) . ' ' . Vigilante_Settings::get_shared_files_notice();
2092 + }
2093 +
2094 + /**
1942 2095 * Print the shared-files notice for a section that cannot be edited here
1943 2096 *
1944 2097 * @since 2.9.8
1945 2098 */
@@ -1954,8 +2107,109 @@
1954 2107 <?php
1955 2108 }
1956 2109
1957 2110 /**
2111 + * Acting on another user's account needs permission over that user
2112 + *
2113 + * Since 2.10.3 the handlers behind these tools ask for edit_user over the
2114 + * target, which is the rule WordPress itself applies. On a network the core
2115 + * grants edit_user only to network administrators, so for anybody else these
2116 + * controls do nothing. Better to say so than to paint a button that silently
2117 + * skips every user.
2118 + *
2119 + * @since 2.10.4
2120 + * @return bool
2121 + */
2122 + private function forwarded_chain_readings() {
2123 + // Shown, not decided on: the firewall resolves the address elsewhere.
2124 + $chain = Vigilante_IP_Utils::trusted_forwarded_for();
2125 +
2126 + if ( '' === $chain ) {
2127 + return array();
2128 + }
2129 +
2130 + $public = array();
2131 +
2132 + foreach ( explode( ',', $chain ) as $entry ) {
2133 + $address = Vigilante_IP_Utils::unmap_ipv4( trim( $entry ) );
2134 +
2135 + if ( filter_var( $address, FILTER_VALIDATE_IP ) && ! Vigilante_IP_Utils::is_own_network( $address ) ) {
2136 + $public[] = $address;
2137 + }
2138 + }
2139 +
2140 + if ( count( $public ) < 2 ) {
2141 + return array();
2142 + }
2143 +
2144 + return array(
2145 + 'now' => Vigilante_IP_Utils::client_from_chain( $chain ),
2146 + 'before' => $public[0],
2147 + );
2148 + }
2149 +
2150 + /**
2151 + * Whether the user tools of this screen are out of reach for this user
2152 + *
2153 + * @return bool
2154 + */
2155 + private function user_actions_locked() {
2156 + // On a single site edit_user maps to edit_users, which a custom role with
2157 + // manage_options may lack: since 2.11.8 approving and rejecting a pending
2158 + // registration ask for it, so the buttons have to say so there too.
2159 + return is_multisite() ? ! current_user_can( 'manage_network_users' ) : ! current_user_can( 'edit_users' );
2160 + }
2161 +
2162 + /**
2163 + * Print the notice for user tools that cannot be used from this site
2164 + *
2165 + * @since 2.10.4
2166 + */
2167 + private function render_user_actions_notice() {
2168 + if ( ! $this->user_actions_locked() ) {
2169 + return;
2170 + }
2171 + ?>
2172 + <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;">
2173 + <?php if ( is_multisite() ) : ?>
2174 + <p style="margin:0;"><?php esc_html_e( 'These tools act on user accounts, which on a network belong to the whole network rather than to one site. WordPress reserves that to network administrators, so they are managed from the network admin.', 'vigilante' ); ?></p>
2175 + <?php else : ?>
2176 + <p style="margin:0;"><?php esc_html_e( 'These tools act on other user accounts, and your role cannot edit users, so they are not available to you.', 'vigilante' ); ?></p>
2177 + <?php endif; ?>
2178 + </div>
2179 + <?php
2180 + }
2181 +
2182 + /**
2183 + * Approving a change to the shared config files needs the network
2184 + *
2185 + * Since 2.11.3 the handler behind the Approve button asks for
2186 + * manage_network_options, because the two files it approves, wp-config.php
2187 + * and the root .htaccess, belong to the installation, and so does the
2188 + * record of them. The button, though, went on being painted for everybody,
2189 + * so the administrator of a subsite saw the warning, saw the button,
2190 + * pressed it and got "Permission denied" with no explanation. That is
2191 + * exactly what user_actions_locked() above exists to avoid, one release
2192 + * later and one screen over. Flagged by @calzbert.
2193 + *
2194 + * @since 2.11.4
2195 + * @return bool
2196 + */
2197 + private function critical_approval_locked() {
2198 + return is_multisite() && ! current_user_can( 'manage_network_options' );
2199 + }
2200 +
2201 + /**
2202 + * The line that replaces the Approve button where it cannot be used
2203 + *
2204 + * @since 2.11.4
2205 + * @return string
2206 + */
2207 + private function critical_approval_notice() {
2208 + return __( 'These files belong to the whole network rather than to this site, so a change to them is approved from the network admin.', 'vigilante' );
2209 + }
2210 +
2211 + /**
1958 2212 * Check if module is disabled and render warning
1959 2213 *
1960 2214 * @param string $module_key Module key.
1961 2215 * @return bool True if disabled.
@@ -2499,14 +2753,15 @@
2499 2753
2500 2754 <?php $this->render_analyzer_widget( $analyzer_last_scan, $analyzer_history, $analyzer_categories_def, $analyzer_settings ); ?>
2501 2755
2502 2756 <div class="vigilante-modules-grid">
2503 - <h2><?php esc_html_e( 'Security Modules', 'vigilante' ); ?></h2>
2757 + <h2 id="vigilante-section-dashboard-modules"><?php esc_html_e( 'Security Modules', 'vigilante' ); ?></h2>
2504 2758 <p class="description"><?php esc_html_e( 'Enable or disable security modules. Each module controls a tab with detailed settings.', 'vigilante' ); ?></p>
2505 2759 <div class="vigilante-modules-list">
2506 2760 <?php foreach ( $options['modules'] as $module => $enabled ) :
2507 2761 $label = isset( $module_labels[ $module ] ) ? $module_labels[ $module ] : ucwords( str_replace( '_', ' ', $module ) );
2508 2762 $description = isset( $module_descriptions[ $module ] ) ? $module_descriptions[ $module ] : '';
2763 + $vg_module_locked = $this->main_site_files_locked() && in_array( $module, Vigilante_Settings::get_main_site_file_settings()['modules'], true );
2509 2764 ?>
2510 2765 <div class="vigilante-module-item <?php echo $enabled ? 'enabled' : 'disabled'; ?>">
2511 2766 <div class="vigilante-module-header">
2512 2767 <span class="vigilante-module-status"></span>
@@ -2519,8 +2774,9 @@
2519 2774 <input type="checkbox"
2520 2775 name="modules[<?php echo esc_attr( $module ); ?>]"
2521 2776 value="1"
2522 2777 <?php checked( $enabled ); ?>
2778 + <?php disabled( $vg_module_locked ); ?>
2523 2779 aria-label="<?php echo esc_attr( $toggle_label ); ?>"
2524 2780 data-module="<?php echo esc_attr( $module ); ?>">
2525 2781 <span class="vigilante-toggle-slider"></span>
2526 2782 </label>
@@ -2527,8 +2783,11 @@
2527 2783 </div>
2528 2784 <?php if ( $description ) : ?>
2529 2785 <p class="vigilante-module-desc"><?php echo esc_html( $description ); ?></p>
2530 2786 <?php endif; ?>
2787 + <?php if ( $vg_module_locked ) : ?>
2788 + <p class="vigilante-module-desc"><?php esc_html_e( 'On the main site of a network this module also writes files every site shares, so only a network administrator can switch it.', 'vigilante' ); ?></p>
2789 + <?php endif; ?>
2531 2790 </div>
2532 2791 <?php endforeach; ?>
2533 2792 </div>
2534 2793 </div>
@@ -2560,9 +2819,9 @@
2560 2819 $ua_remaining_hours = floor( $ua_remaining / 3600 );
2561 2820 $ua_remaining_mins = floor( ( $ua_remaining % 3600 ) / 60 );
2562 2821 ?>
2563 2822 <div class="vigilante-preset-card vigilante-under-attack-card <?php echo $ua_active ? 'vigilante-under-attack-active' : ''; ?>">
2564 - <h3>
2823 + <h3 id="vigilante-section-dashboard-under-attack">
2565 2824 <span class="dashicons dashicons-shield"></span>
2566 2825 <?php esc_html_e( 'Under Attack', 'vigilante' ); ?>
2567 2826 </h3>
2568 2827 <p><?php esc_html_e( 'Emergency mode. JavaScript challenge for all visitors, aggressive rate limiting, and restricted access. Auto-deactivates after 4 hours.', 'vigilante' ); ?></p>
@@ -2640,11 +2899,11 @@
2640 2899 </label>
2641 2900 </td>
2642 2901 </tr>
2643 2902 <tr>
2644 - <th scope="row"><?php esc_html_e( 'Additional Recipients', 'vigilante' ); ?></th>
2903 + <th scope="row"><label for="vigilante-f-email-additional-recipients"><?php esc_html_e( 'Additional Recipients', 'vigilante' ); ?></label></th>
2645 2904 <td>
2646 - <textarea name="email[additional_recipients]" rows="3" class="large-text code" placeholder="maintenance@example.com&#10;security@example.com"><?php echo esc_textarea( $additional ); ?></textarea>
2905 + <textarea id="vigilante-f-email-additional-recipients" name="email[additional_recipients]" rows="3" class="large-text code" placeholder="maintenance@example.com&#10;security@example.com"><?php echo esc_textarea( $additional ); ?></textarea>
2647 2906 <p class="description"><?php esc_html_e( 'One email per line.', 'vigilante' ); ?></p>
2648 2907 </td>
2649 2908 </tr>
2650 2909 <tr>
@@ -2819,9 +3078,9 @@
2819 3078
2820 3079 <div class="vigilante-tool-card">
2821 3080 <h3><?php esc_html_e( 'Import Settings', 'vigilante' ); ?></h3>
2822 3081 <p><?php esc_html_e( 'Import settings from a previously exported JSON file.', 'vigilante' ); ?></p>
2823 - <input type="file" id="vigilante-import-file" accept=".json" style="display: none;">
3082 + <input type="file" id="vigilante-import-file" aria-label="<?php esc_attr_e( 'Configuration file to import', 'vigilante' ); ?>" accept=".json" style="display: none;">
2824 3083 <button type="button" class="button vigilante-import-settings">
2825 3084 <?php esc_html_e( 'Import Settings', 'vigilante' ); ?>
2826 3085 </button>
2827 3086 </div>
@@ -2918,14 +3177,21 @@
2918 3177 <?php esc_html_e( 'Full page caching systems that serve cached pages before PHP executes (Varnish, LiteSpeed Cache, NGINX FastCGI Cache, Cloudflare APO) may bypass PHP-level firewall rules for cached requests. The .htaccess rules will still apply on Apache/LiteSpeed servers.', 'vigilante' ); ?>
2919 3178 </p>
2920 3179 </div>
2921 3180
3181 + <?php $vg_main_locked = $this->main_site_files_locked(); ?>
3182 + <?php if ( $vg_main_locked ) : ?>
3183 + <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;">
3184 + <p style="margin:0;"><?php esc_html_e( 'On the main site of a network, blocking bad bots and bad query strings, the visitor IP detection and the two whitelists also build the .htaccess rules every site shares, so only a network administrator can change them.', 'vigilante' ); ?></p>
3185 + </div>
3186 + <?php endif; ?>
3187 +
2922 3188 <table class="form-table">
2923 3189 <tr>
2924 3190 <th scope="row"><?php esc_html_e( 'Block Bad Query Strings', 'vigilante' ); ?></th>
2925 3191 <td>
2926 3192 <label>
2927 - <input type="checkbox" name="firewall[block_bad_query_strings]" value="1" <?php checked( ! empty( $options['block_bad_query_strings'] ) ); ?>>
3193 + <input type="checkbox" name="firewall[block_bad_query_strings]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( ! empty( $options['block_bad_query_strings'] ) ); ?>>
2928 3194 <?php esc_html_e( 'Block malicious query string patterns', 'vigilante' ); ?>
2929 3195 </label>
2930 3196 </td>
2931 3197 </tr>
@@ -2968,9 +3234,9 @@
2968 3234 <tr>
2969 3235 <th scope="row"><?php esc_html_e( 'Block Bad Bots', 'vigilante' ); ?></th>
2970 3236 <td>
2971 3237 <label>
2972 - <input type="checkbox" name="firewall[block_bad_bots]" value="1" <?php checked( ! empty( $options['block_bad_bots'] ) ); ?>>
3238 + <input type="checkbox" name="firewall[block_bad_bots]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( ! empty( $options['block_bad_bots'] ) ); ?>>
2973 3239 <?php esc_html_e( 'Block known malicious bots and scanners', 'vigilante' ); ?>
2974 3240 </label>
2975 3241 </td>
2976 3242 </tr>
@@ -2987,11 +3253,11 @@
2987 3253 </label>
2988 3254 </td>
2989 3255 </tr>
2990 3256 <tr>
2991 - <th scope="row"><?php esc_html_e( 'Requests per Minute', 'vigilante' ); ?></th>
3257 + <th scope="row"><label for="vigilante-f-firewall-rate-limiting-requests-per-minute"><?php esc_html_e( 'Requests per Minute', 'vigilante' ); ?></label></th>
2992 3258 <td>
2993 - <input type="number" name="firewall[rate_limiting][requests_per_minute]" value="<?php echo esc_attr( $options['rate_limiting']['requests_per_minute'] ?? 120 ); ?>" min="10" max="500" class="small-text">
3259 + <input id="vigilante-f-firewall-rate-limiting-requests-per-minute" type="number" name="firewall[rate_limiting][requests_per_minute]" value="<?php echo esc_attr( $options['rate_limiting']['requests_per_minute'] ?? 120 ); ?>" min="10" max="500" class="small-text">
2994 3260 <p class="description">
2995 3261 <?php esc_html_e( 'Counts only PHP requests to WordPress (pages, admin-ajax, REST, login) from a single IP, not static assets like images, CSS or JS. 120/min suits most sites; sustained traffic above that from one IP is usually a bot. To allow a legitimate service, whitelist its IP instead of raising the limit.', 'vigilante' ); ?>
2996 3262 </p>
2997 3263 </td>
@@ -2996,11 +3262,11 @@
2996 3262 </p>
2997 3263 </td>
2998 3264 </tr>
2999 3265 <tr>
3000 - <th scope="row"><?php esc_html_e( 'Block Duration (seconds)', 'vigilante' ); ?></th>
3266 + <th scope="row"><label for="vigilante-f-firewall-rate-limiting-block-duration"><?php esc_html_e( 'Block Duration (seconds)', 'vigilante' ); ?></label></th>
3001 3267 <td>
3002 - <input type="number" name="firewall[rate_limiting][block_duration]" value="<?php echo esc_attr( $options['rate_limiting']['block_duration'] ?? 300 ); ?>" min="60" max="3600" class="small-text">
3268 + <input id="vigilante-f-firewall-rate-limiting-block-duration" type="number" name="firewall[rate_limiting][block_duration]" value="<?php echo esc_attr( $options['rate_limiting']['block_duration'] ?? 300 ); ?>" min="60" max="3600" class="small-text">
3003 3269 </td>
3004 3270 </tr>
3005 3271 <tr>
3006 3272 <th scope="row"><?php esc_html_e( 'Progressive Blocking', 'vigilante' ); ?></th>
@@ -3023,11 +3289,11 @@
3023 3289 </p>
3024 3290 </td>
3025 3291 </tr>
3026 3292 <tr>
3027 - <th scope="row"><?php esc_html_e( 'Maximum Block Duration', 'vigilante' ); ?></th>
3293 + <th scope="row"><label for="vigilante-f-firewall-rate-limiting-max-block-duration"><?php esc_html_e( 'Maximum Block Duration', 'vigilante' ); ?></label></th>
3028 3294 <td>
3029 - <select name="firewall[rate_limiting][max_block_duration]">
3295 + <select id="vigilante-f-firewall-rate-limiting-max-block-duration" name="firewall[rate_limiting][max_block_duration]">
3030 3296 <?php
3031 3297 $max_options = array(
3032 3298 3600 => __( '1 hour', 'vigilante' ),
3033 3299 21600 => __( '6 hours', 'vigilante' ),
@@ -3084,8 +3350,29 @@
3084 3350 </table>
3085 3351 </div>
3086 3352 <?php endif; ?>
3087 3353
3354 + <?php
3355 + // Since 2.11.8 X-Forwarded-For is read from its end, where the proxy
3356 + // writes. The administrator's own request shows whether that end is
3357 + // a CDN or a balancer for everybody here. Cross review of 2.11.8.
3358 + $xff_readings = $this->forwarded_chain_readings();
3359 + if ( $xff_readings ) :
3360 + ?>
3361 + <div id="vigilante-xff-chain-notice" class="notice notice-warning inline" style="margin:10px 0 16px;padding:8px 12px;">
3362 + <p style="margin:0;">
3363 + <?php
3364 + printf(
3365 + /* translators: 1: address Vigilant reads now, 2: address earlier versions read */
3366 + esc_html__( 'Your own request reaches the site with more than one public address in X-Forwarded-For. Vigilant reads the last one, %1$s, which is the one your proxy added; up to version 2.11.7 it read the first one, %2$s, which a visitor can write. If %1$s belongs to a CDN or a load balancer rather than to you, every visitor shares it for rate limiting, login lockouts and the IP lists: choose the header of that CDN in Visitor IP detection, such as CF-Connecting-IP for Cloudflare.', 'vigilante' ),
3367 + esc_html( $xff_readings['now'] ),
3368 + esc_html( $xff_readings['before'] )
3369 + );
3370 + ?>
3371 + </p>
3372 + </div>
3373 + <?php endif; ?>
3374 +
3088 3375 <h3><?php esc_html_e( 'IP Lists', 'vigilante' ); ?></h3>
3089 3376 <p class="description">
3090 3377 <?php
3091 3378 printf(
@@ -3096,12 +3383,12 @@
3096 3383 ?>
3097 3384 </p>
3098 3385 <table class="form-table">
3099 3386 <tr>
3100 - <th scope="row"><?php esc_html_e( 'Visitor IP detection', 'vigilante' ); ?></th>
3387 + <th scope="row"><label for="vigilante-f-firewall-trusted-proxy-header"><?php esc_html_e( 'Visitor IP detection', 'vigilante' ); ?></label></th>
3101 3388 <td>
3102 3389 <?php $proxy_header = $options['trusted_proxy_header'] ?? ''; ?>
3103 - <select name="firewall[trusted_proxy_header]">
3390 + <select id="vigilante-f-firewall-trusted-proxy-header" name="firewall[trusted_proxy_header]" <?php disabled( $vg_main_locked ); ?>>
3104 3391 <option value="" <?php selected( $proxy_header, '' ); ?>><?php esc_html_e( 'Direct connection, only REMOTE_ADDR (recommended)', 'vigilante' ); ?></option>
3105 3392 <option value="cf-connecting-ip" <?php selected( $proxy_header, 'cf-connecting-ip' ); ?>><?php esc_html_e( 'Behind Cloudflare (CF-Connecting-IP)', 'vigilante' ); ?></option>
3106 3393 <option value="x-forwarded-for" <?php selected( $proxy_header, 'x-forwarded-for' ); ?>><?php esc_html_e( 'Behind a reverse proxy or load balancer (X-Forwarded-For)', 'vigilante' ); ?></option>
3107 3394 <option value="x-real-ip" <?php selected( $proxy_header, 'x-real-ip' ); ?>><?php esc_html_e( 'Behind an nginx proxy (X-Real-IP)', 'vigilante' ); ?></option>
@@ -3111,13 +3398,13 @@
3111 3398 </p>
3112 3399 </td>
3113 3400 </tr>
3114 3401 <tr>
3115 - <th scope="row"><?php esc_html_e( 'IP Whitelist', 'vigilante' ); ?></th>
3402 + <th scope="row"><label for="vigilante-f-firewall-ip-whitelist"><?php esc_html_e( 'IP Whitelist', 'vigilante' ); ?></label></th>
3116 3403 <td>
3117 - <textarea name="firewall[ip_whitelist]" rows="4" class="large-text code" placeholder="192.168.1.50&#10;192.168.1.0/24&#10;192.168.1.*"><?php echo esc_textarea( implode( "\n", $options['ip_whitelist'] ?? array() ) ); ?></textarea>
3404 + <textarea id="vigilante-f-firewall-ip-whitelist" name="firewall[ip_whitelist]" <?php disabled( $vg_main_locked ); ?> rows="4" class="large-text code" placeholder="192.168.1.50&#10;192.168.1.0/24&#10;192.168.1.*"><?php echo esc_textarea( implode( "\n", $options['ip_whitelist'] ?? array() ) ); ?></textarea>
3118 3405 <p class="description">
3119 - <?php esc_html_e( 'One IP per line. These IPs will bypass firewall checks.', 'vigilante' ); ?>
3406 + <?php esc_html_e( 'One IP per line. These IPs bypass the firewall checks, and they also reach wp-admin when the login URL is hidden, so remote managers such as MainWP or ManageWP are not turned away with a 404. The hidden login form itself stays hidden for every IP, this one included.', 'vigilante' ); ?>
3120 3407 <br>
3121 3408 <?php
3122 3409 printf(
3123 3410 /* translators: 1: opening <code>, 2: closing </code>. Placeholders wrap the IP, CIDR and wildcard examples. */
@@ -3129,11 +3416,11 @@
3129 3416 </p>
3130 3417 </td>
3131 3418 </tr>
3132 3419 <tr>
3133 - <th scope="row"><?php esc_html_e( 'IP Blacklist', 'vigilante' ); ?></th>
3420 + <th scope="row"><label for="vigilante-f-firewall-ip-blacklist"><?php esc_html_e( 'IP Blacklist', 'vigilante' ); ?></label></th>
3134 3421 <td>
3135 - <textarea name="firewall[ip_blacklist]" rows="4" class="large-text code" placeholder="203.0.113.42&#10;203.0.113.0/24&#10;203.0.113.*"><?php echo esc_textarea( implode( "\n", $options['ip_blacklist'] ?? array() ) ); ?></textarea>
3422 + <textarea id="vigilante-f-firewall-ip-blacklist" name="firewall[ip_blacklist]" rows="4" class="large-text code" placeholder="203.0.113.42&#10;203.0.113.0/24&#10;203.0.113.*"><?php echo esc_textarea( implode( "\n", $options['ip_blacklist'] ?? array() ) ); ?></textarea>
3136 3423 <p class="description">
3137 3424 <?php esc_html_e( 'One IP per line. These IPs will be blocked immediately.', 'vigilante' ); ?>
3138 3425 <br>
3139 3426 <?php
@@ -3152,18 +3439,18 @@
3152 3439 <h3><?php esc_html_e( 'User-Agent Lists', 'vigilante' ); ?></h3>
3153 3440 <p><?php esc_html_e( 'Partial matching: enter a keyword and any User-Agent containing it will be matched.', 'vigilante' ); ?></p>
3154 3441 <table class="form-table">
3155 3442 <tr>
3156 - <th scope="row"><?php esc_html_e( 'User-Agent Whitelist', 'vigilante' ); ?></th>
3443 + <th scope="row"><label for="vigilante-f-firewall-ua-whitelist"><?php esc_html_e( 'User-Agent Whitelist', 'vigilante' ); ?></label></th>
3157 3444 <td>
3158 - <textarea name="firewall[ua_whitelist]" rows="4" class="large-text code"><?php echo esc_textarea( implode( "\n", $options['ua_whitelist'] ?? array() ) ); ?></textarea>
3445 + <textarea id="vigilante-f-firewall-ua-whitelist" name="firewall[ua_whitelist]" <?php disabled( $vg_main_locked ); ?> rows="4" class="large-text code"><?php echo esc_textarea( implode( "\n", $options['ua_whitelist'] ?? array() ) ); ?></textarea>
3159 3446 <p class="description"><?php esc_html_e( 'One User-Agent per line. These will bypass all firewall checks. Example: ManageWP, MainWP, UptimeRobot.', 'vigilante' ); ?></p>
3160 3447 </td>
3161 3448 </tr>
3162 3449 <tr>
3163 - <th scope="row"><?php esc_html_e( 'User-Agent Blacklist', 'vigilante' ); ?></th>
3450 + <th scope="row"><label for="vigilante-f-firewall-ua-blacklist"><?php esc_html_e( 'User-Agent Blacklist', 'vigilante' ); ?></label></th>
3164 3451 <td>
3165 - <textarea name="firewall[ua_blacklist]" rows="4" class="large-text code"><?php echo esc_textarea( implode( "\n", $options['ua_blacklist'] ?? array() ) ); ?></textarea>
3452 + <textarea id="vigilante-f-firewall-ua-blacklist" name="firewall[ua_blacklist]" rows="4" class="large-text code"><?php echo esc_textarea( implode( "\n", $options['ua_blacklist'] ?? array() ) ); ?></textarea>
3166 3453 <p class="description"><?php esc_html_e( 'One User-Agent per line. These will be blocked immediately.', 'vigilante' ); ?></p>
3167 3454 </td>
3168 3455 </tr>
3169 3456 </table>
@@ -3291,18 +3578,18 @@
3291 3578 <p><?php esc_html_e( 'Brute force protection and WordPress login hardening.', 'vigilante' ); ?></p>
3292 3579
3293 3580 <table class="form-table">
3294 3581 <tr id="field-max-attempts">
3295 - <th scope="row"><?php esc_html_e( 'Max Login Attempts', 'vigilante' ); ?></th>
3582 + <th scope="row"><label for="vigilante-f-login-security-max-attempts"><?php esc_html_e( 'Max Login Attempts', 'vigilante' ); ?></label></th>
3296 3583 <td>
3297 - <input type="number" name="login_security[max_attempts]" value="<?php echo esc_attr( $options['max_attempts'] ?? 5 ); ?>" min="1" max="20" class="small-text">
3584 + <input id="vigilante-f-login-security-max-attempts" type="number" name="login_security[max_attempts]" value="<?php echo esc_attr( $options['max_attempts'] ?? 5 ); ?>" min="1" max="20" class="small-text">
3298 3585 <p class="description"><?php esc_html_e( 'Number of failed attempts before lockout.', 'vigilante' ); ?></p>
3299 3586 </td>
3300 3587 </tr>
3301 3588 <tr>
3302 - <th scope="row"><?php esc_html_e( 'Lockout Duration', 'vigilante' ); ?></th>
3589 + <th scope="row"><label for="vigilante-f-login-security-lockout-duration"><?php esc_html_e( 'Lockout Duration', 'vigilante' ); ?></label></th>
3303 3590 <td>
3304 - <input type="number" name="login_security[lockout_duration]" value="<?php echo esc_attr( ( $options['lockout_duration'] ?? 1800 ) / 60 ); ?>" min="1" max="1440" class="small-text">
3591 + <input id="vigilante-f-login-security-lockout-duration" type="number" name="login_security[lockout_duration]" value="<?php echo esc_attr( ( $options['lockout_duration'] ?? 1800 ) / 60 ); ?>" min="1" max="1440" class="small-text">
3305 3592 <?php esc_html_e( 'minutes', 'vigilante' ); ?>
3306 3593 </td>
3307 3594 </tr>
3308 3595 <tr>
@@ -3351,8 +3638,11 @@
3351 3638 </p>
3352 3639 <p class="description">
3353 3640 <?php esc_html_e( 'Direct access to wp-login.php and wp-admin will return a 404 error for non-logged users.', 'vigilante' ); ?>
3354 3641 </p>
3642 + <p class="description">
3643 + <?php esc_html_e( 'An IP in the firewall whitelist is still allowed into wp-admin, so remote managers keep working, but it does not get the login form: the hidden URL is the only way in for everyone.', 'vigilante' ); ?>
3644 + </p>
3355 3645 </div>
3356 3646 </td>
3357 3647 </tr>
3358 3648 </table>
@@ -3441,9 +3731,9 @@
3441 3731 $two_factor = $options['two_factor'] ?? array();
3442 3732 $two_factor_enabled = ! empty( $two_factor['enabled'] );
3443 3733 ?>
3444 3734 <div class="vigilante-settings-section vigilante-lockout-section">
3445 - <h2><?php esc_html_e( 'Login Protection Status', 'vigilante' ); ?></h2>
3735 + <h2 id="vigilante-section-login-status"><?php esc_html_e( 'Login Protection Status', 'vigilante' ); ?></h2>
3446 3736
3447 3737 <table class="form-table">
3448 3738 <tr>
3449 3739 <th scope="row"><?php esc_html_e( 'Current settings', 'vigilante' ); ?></th>
@@ -3603,9 +3893,9 @@
3603 3893 $excluded = $two_factor['excluded_users'] ?? array();
3604 3894 $method = $two_factor['method'] ?? 'email';
3605 3895 $grace_days = $two_factor['grace_period_days'] ?? 3;
3606 3896 ?>
3607 - <h3>
3897 + <h3 id="vigilante-section-login-2fa">
3608 3898 <?php esc_html_e( 'Two-Factor Authentication (2FA)', 'vigilante' ); ?>
3609 3899 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
3610 3900 <span class="vigilante-method-badge database"><?php esc_html_e( 'Database', 'vigilante' ); ?></span>
3611 3901 </h3>
@@ -3719,11 +4009,11 @@
3719 4009 </tr>
3720 4010
3721 4011 <!-- TOTP-specific: Grace period -->
3722 4012 <tr class="vigilante-2fa-totp-only" <?php echo 'totp' !== $method ? 'style="display:none;"' : ''; ?>>
3723 - <th scope="row"><?php esc_html_e( 'Grace period', 'vigilante' ); ?></th>
4013 + <th scope="row"><label for="vigilante-f-login-security-two-factor-grace-period-days"><?php esc_html_e( 'Grace period', 'vigilante' ); ?></label></th>
3724 4014 <td>
3725 - <input type="number"
4015 + <input id="vigilante-f-login-security-two-factor-grace-period-days" type="number"
3726 4016 name="login_security[two_factor][grace_period_days]"
3727 4017 value="<?php echo esc_attr( $grace_days ); ?>"
3728 4018 min="0" max="30" class="small-text">
3729 4019 <?php esc_html_e( 'days', 'vigilante' ); ?>
@@ -3732,11 +4022,11 @@
3732 4022 </tr>
3733 4023
3734 4024 <!-- Email-specific: Sender name -->
3735 4025 <tr class="vigilante-2fa-email-only" <?php echo 'email' !== $method ? 'style="display:none;"' : ''; ?>>
3736 - <th scope="row"><?php esc_html_e( 'Email sender name', 'vigilante' ); ?></th>
4026 + <th scope="row"><label for="vigilante-f-login-security-two-factor-email-from-name"><?php esc_html_e( 'Email sender name', 'vigilante' ); ?></label></th>
3737 4027 <td>
3738 - <input type="text"
4028 + <input id="vigilante-f-login-security-two-factor-email-from-name" type="text"
3739 4029 name="login_security[two_factor][email_from_name]"
3740 4030 value="<?php echo esc_attr( $two_factor['email_from_name'] ?? '' ); ?>"
3741 4031 class="regular-text vigilante-2fa-email-from"
3742 4032 placeholder="<?php echo esc_attr( get_bloginfo( 'name' ) ); ?>">
@@ -3804,8 +4094,128 @@
3804 4094
3805 4095 /**
3806 4096 * Render security headers tab
3807 4097 */
4098 + /**
4099 + * Offer back the header settings the 2.9.8 migration wiped.
4100 + *
4101 + * Rendered outside the settings form on purpose, so its buttons can never
4102 + * submit it, and only when there is something to actually change. Shows the
4103 + * difference before anything is written: nothing is applied that the owner
4104 + * has not seen first.
4105 + *
4106 + * @since 2.10.0
4107 + */
4108 + private function render_headers_recovery_offer() {
4109 + /*
4110 + * On a network the .htaccess belongs to every site and only the main one
4111 + * writes it, so this is not a decision a subsite gets to make. Its own
4112 + * security_headers options are inert anyway: what the network serves
4113 + * comes from the file the main site owns. Without this gate a subsite
4114 + * administrator was shown a Restore button that could only ever answer
4115 + * with a permission error, which is worse than showing nothing.
4116 + */
4117 + if ( ! Vigilante_Settings::can_write_shared_files() ) {
4118 + return;
4119 + }
4120 +
4121 + if ( ! Vigilante_Htaccess_Recovery::is_available() ) {
4122 + /*
4123 + * Already restored. Offer to take it back for as long as the previous
4124 + * section is still stored: a restore that cannot be undone is a second
4125 + * irreversible change on top of the one being repaired.
4126 + */
4127 + if ( Vigilante_Htaccess_Recovery::has_undo() ) {
4128 + ?>
4129 + <div class="notice notice-info inline" id="vigilante-headers-recovery-undo">
4130 + <p>
4131 + <?php esc_html_e( 'The Security Headers settings were restored from the copy Vigilant had kept of your .htaccess.', 'vigilante' ); ?>
4132 + <button type="button" class="button button-small" id="vigilante-recovery-undo">
4133 + <?php esc_html_e( 'Undo the restore', 'vigilante' ); ?>
4134 + </button>
4135 + </p>
4136 + </div>
4137 + <?php
4138 + }
4139 +
4140 + return;
4141 + }
4142 +
4143 + $rows = Vigilante_Htaccess_Recovery::get_diff( $this->settings );
4144 +
4145 + if ( empty( $rows ) ) {
4146 + return;
4147 + }
4148 +
4149 + $snapshot = Vigilante_Htaccess_Recovery::get_snapshot();
4150 + $taken = isset( $snapshot['time'] ) ? (int) $snapshot['time'] : 0;
4151 + $block = Vigilante_Htaccess_Recovery::get_raw_block();
4152 + ?>
4153 + <div class="vigilante-settings-section" id="vigilante-headers-recovery">
4154 + <h2><?php esc_html_e( 'Recover your previous header settings', 'vigilante' ); ?></h2>
4155 + <p>
4156 + <?php esc_html_e( 'Updating to 2.9.8 reset this tab to factory values: the migration replaced the whole section instead of merging into it. Your server kept sending the right headers, because the .htaccess had not been rewritten yet, so Vigilant saved a copy of that file before touching it. These are the settings it found in that copy.', 'vigilante' ); ?>
4157 + </p>
4158 + <?php if ( $taken ) : ?>
4159 + <p class="description">
4160 + <?php
4161 + printf(
4162 + /* translators: %s: date and time the .htaccess copy was taken. */
4163 + esc_html__( 'Copy taken on %s.', 'vigilante' ),
4164 + esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $taken ) )
4165 + );
4166 + ?>
4167 + </p>
4168 + <?php endif; ?>
4169 +
4170 + <table class="widefat striped">
4171 + <thead>
4172 + <tr>
4173 + <th scope="col"><?php esc_html_e( 'Setting', 'vigilante' ); ?></th>
4174 + <th scope="col"><?php esc_html_e( 'Now', 'vigilante' ); ?></th>
4175 + <th scope="col"><?php esc_html_e( 'Would be restored to', 'vigilante' ); ?></th>
4176 + </tr>
4177 + </thead>
4178 + <tbody>
4179 + <?php foreach ( $rows as $row ) : ?>
4180 + <tr>
4181 + <th scope="row"><?php echo esc_html( $row['label'] ); ?></th>
4182 + <td><?php echo esc_html( $row['current'] ); ?></td>
4183 + <td>
4184 + <?php echo esc_html( $row['recovered'] ); ?>
4185 + <?php if ( ! empty( $row['detail'] ) ) : ?>
4186 + <br><span class="description"><?php echo esc_html( $row['detail'] ); ?></span>
4187 + <?php endif; ?>
4188 + </td>
4189 + </tr>
4190 + <?php endforeach; ?>
4191 + </tbody>
4192 + </table>
4193 +
4194 + <p class="description">
4195 + <?php esc_html_e( 'Only these settings are written. The .htaccess is then rebuilt from them, the same way saving this tab rebuilds it. The stored copy of the file is never written back, so nothing your host, your cache plugin or your CDN added to it is touched.', 'vigilante' ); ?>
4196 + </p>
4197 +
4198 + <?php if ( '' !== $block ) : ?>
4199 + <details>
4200 + <summary><?php esc_html_e( 'Show the saved .htaccess block', 'vigilante' ); ?></summary>
4201 + <textarea readonly rows="12" class="large-text code" onclick="this.select();"><?php echo esc_textarea( $block ); ?></textarea>
4202 + </details>
4203 + <?php endif; ?>
4204 +
4205 + <p class="submit vigilante-submit-buttons">
4206 + <button type="button" class="button button-primary" id="vigilante-recovery-restore">
4207 + <?php esc_html_e( 'Restore these settings', 'vigilante' ); ?>
4208 + </button>
4209 + <button type="button" class="button" id="vigilante-recovery-dismiss">
4210 + <?php esc_html_e( 'No thanks, keep what I have', 'vigilante' ); ?>
4211 + </button>
4212 + </p>
4213 + <div id="vigilante-recovery-result"></div>
4214 + </div>
4215 + <?php
4216 + }
4217 +
3808 4218 private function render_tab_headers() {
3809 4219 $is_disabled = $this->render_module_disabled_notice( 'security_headers' );
3810 4220 // Every setting on this tab ends up in .htaccess, so on a subsite the
3811 4221 // whole tab is somebody else's, values included.
@@ -3811,8 +4221,10 @@
3811 4221 // whole tab is somebody else's, values included.
3812 4222 $vg_shared_locked = $this->shared_files_locked();
3813 4223 $options = $this->get_section_for_display( 'security_headers' );
3814 4224 ?>
4225 + <?php $this->render_headers_recovery_offer(); ?>
4226 +
3815 4227 <form class="vigilante-settings-form <?php echo $is_disabled ? 'vigilante-form-disabled' : ''; ?>" data-section="security_headers" <?php echo $is_disabled ? 'inert' : ''; ?>>
3816 4228 <?php $this->render_shared_files_notice(); ?>
3817 4229 <div id="vigilante-section-headers-main" class="vigilante-settings-section <?php echo $vg_shared_locked ? 'vigilante-form-disabled' : ''; ?>" <?php echo $vg_shared_locked ? 'inert' : ''; ?>>
3818 4230 <h2>
@@ -3822,11 +4234,11 @@
3822 4234 <p><?php esc_html_e( 'HTTP headers sent with every response via .htaccess (mod_headers).', 'vigilante' ); ?></p>
3823 4235
3824 4236 <table class="form-table">
3825 4237 <tr>
3826 - <th scope="row"><?php esc_html_e( 'X-Frame-Options', 'vigilante' ); ?></th>
4238 + <th scope="row"><label for="vigilante-f-security-headers-x-frame-options"><?php esc_html_e( 'X-Frame-Options', 'vigilante' ); ?></label></th>
3827 4239 <td>
3828 - <select name="security_headers[x_frame_options]">
4240 + <select id="vigilante-f-security-headers-x-frame-options" name="security_headers[x_frame_options]">
3829 4241 <option value="" <?php selected( empty( $options['x_frame_options'] ) ); ?>><?php esc_html_e( 'Disabled', 'vigilante' ); ?></option>
3830 4242 <option value="SAMEORIGIN" <?php selected( $options['x_frame_options'] ?? '', 'SAMEORIGIN' ); ?>>SAMEORIGIN</option>
3831 4243 <option value="DENY" <?php selected( $options['x_frame_options'] ?? '', 'DENY' ); ?>>DENY</option>
3832 4244 </select>
@@ -3842,11 +4254,11 @@
3842 4254 </label>
3843 4255 </td>
3844 4256 </tr>
3845 4257 <tr>
3846 - <th scope="row"><?php esc_html_e( 'Referrer-Policy', 'vigilante' ); ?></th>
4258 + <th scope="row"><label for="vigilante-f-security-headers-referrer-policy"><?php esc_html_e( 'Referrer-Policy', 'vigilante' ); ?></label></th>
3847 4259 <td>
3848 - <select name="security_headers[referrer_policy]">
4260 + <select id="vigilante-f-security-headers-referrer-policy" name="security_headers[referrer_policy]">
3849 4261 <option value="" <?php selected( empty( $options['referrer_policy'] ) ); ?>><?php esc_html_e( 'Disabled', 'vigilante' ); ?></option>
3850 4262 <option value="no-referrer" <?php selected( $options['referrer_policy'] ?? '', 'no-referrer' ); ?>>no-referrer</option>
3851 4263 <option value="strict-origin-when-cross-origin" <?php selected( $options['referrer_policy'] ?? '', 'strict-origin-when-cross-origin' ); ?>>strict-origin-when-cross-origin</option>
3852 4264 <option value="same-origin" <?php selected( $options['referrer_policy'] ?? '', 'same-origin' ); ?>>same-origin</option>
@@ -3854,9 +4266,9 @@
3854 4266 </td>
3855 4267 </tr>
3856 4268 </table>
3857 4269
3858 - <h3><?php esc_html_e( 'Content Security Policy', 'vigilante' ); ?></h3>
4270 + <h3 id="vigilante-section-headers-csp"><?php esc_html_e( 'Content Security Policy', 'vigilante' ); ?></h3>
3859 4271 <table class="form-table">
3860 4272 <tr>
3861 4273 <th scope="row"><?php esc_html_e( 'Enable CSP', 'vigilante' ); ?></th>
3862 4274 <td>
@@ -3876,9 +4288,9 @@
3876 4288 </td>
3877 4289 </tr>
3878 4290 </table>
3879 4291
3880 - <h3><?php esc_html_e( 'HTTPS', 'vigilante' ); ?></h3>
4292 + <h3 id="vigilante-section-headers-force-https"><?php esc_html_e( 'HTTPS', 'vigilante' ); ?></h3>
3881 4293 <p class="description"><?php esc_html_e( 'HTTPS is strongly recommended, but Vigilant will not impose it. Enable only what your site already supports.', 'vigilante' ); ?></p>
3882 4294 <table class="form-table">
3883 4295 <tr>
3884 4296 <th scope="row"><?php esc_html_e( 'Redirect HTTP to HTTPS', 'vigilante' ); ?></th>
@@ -3921,9 +4333,9 @@
3921 4333 </td>
3922 4334 </tr>
3923 4335 </table>
3924 4336
3925 - <h3><?php esc_html_e( 'HSTS (HTTP Strict Transport Security)', 'vigilante' ); ?></h3>
4337 + <h3 id="vigilante-section-headers-hsts"><?php esc_html_e( 'HSTS (HTTP Strict Transport Security)', 'vigilante' ); ?></h3>
3926 4338 <?php $vig_home_https = ( 0 === strpos( (string) get_option( 'home' ), 'https://' ) ); ?>
3927 4339 <p class="description"><?php esc_html_e( 'Tells browsers to reach this site over HTTPS and never over HTTP, for as long as the max age below.', 'vigilante' ); ?></p>
3928 4340 <?php if ( ! $vig_home_https ) : ?>
3929 4341 <p class="description" style="color:#b32d2e"><strong><?php esc_html_e( 'Unavailable: the site address still starts with http://. Enabling HSTS on a site not published over HTTPS would make it unreachable in any browser that honours it.', 'vigilante' ); ?></strong></p>
@@ -3943,11 +4355,11 @@
3943 4355 <p class="description"><?php esc_html_e( '&#9888; Hard to undo: browsers remember it for the whole max age even if you turn it off later, so a site that loses its certificate stays unreachable until it expires. Start with a short max age.', 'vigilante' ); ?></p>
3944 4356 </td>
3945 4357 </tr>
3946 4358 <tr>
3947 - <th scope="row"><?php esc_html_e( 'Max Age', 'vigilante' ); ?></th>
4359 + <th scope="row"><label for="vigilante-f-security-headers-hsts-max-age"><?php esc_html_e( 'Max Age', 'vigilante' ); ?></label></th>
3948 4360 <td>
3949 - <select name="security_headers[hsts][max_age]">
4361 + <select id="vigilante-f-security-headers-hsts-max-age" name="security_headers[hsts][max_age]">
3950 4362 <option value="86400" <?php selected( $options['hsts']['max_age'] ?? 31536000, 86400 ); ?>><?php esc_html_e( '1 day (testing)', 'vigilante' ); ?></option>
3951 4363 <option value="2592000" <?php selected( $options['hsts']['max_age'] ?? 31536000, 2592000 ); ?>><?php esc_html_e( '30 days', 'vigilante' ); ?></option>
3952 4364 <option value="31536000" <?php selected( $options['hsts']['max_age'] ?? 31536000, 31536000 ); ?>><?php esc_html_e( '1 year (recommended)', 'vigilante' ); ?></option>
3953 4365 <option value="63072000" <?php selected( $options['hsts']['max_age'] ?? 31536000, 63072000 ); ?>><?php esc_html_e( '2 years', 'vigilante' ); ?></option>
@@ -3964,9 +4376,9 @@
3964 4376 </td>
3965 4377 </tr>
3966 4378 </table>
3967 4379
3968 - <h3><?php esc_html_e( 'Server Identity', 'vigilante' ); ?></h3>
4380 + <h3 id="vigilante-section-headers-fingerprint"><?php esc_html_e( 'Server Identity', 'vigilante' ); ?></h3>
3969 4381 <p class="description"><?php esc_html_e( 'Hide identifying information that servers expose in responses.', 'vigilante' ); ?></p>
3970 4382 <table class="form-table">
3971 4383 <tr>
3972 4384 <th scope="row"><?php esc_html_e( 'Server Signature', 'vigilante' ); ?></th>
@@ -3988,8 +4400,55 @@
3988 4400 </tr>
3989 4401 </table>
3990 4402 </div>
3991 4403
4404 + <?php $vg_cop = ( isset( $options['cross_origin_policies'] ) && is_array( $options['cross_origin_policies'] ) ) ? $options['cross_origin_policies'] : array(); ?>
4405 + <div id="vigilante-section-headers-cross-origin" class="vigilante-settings-section <?php echo $vg_shared_locked ? 'vigilante-form-disabled' : ''; ?>" <?php echo $vg_shared_locked ? 'inert' : ''; ?>>
4406 + <h2>
4407 + <?php esc_html_e( 'Cross-Origin Policies', 'vigilante' ); ?>
4408 + <span class="vigilante-method-badge htaccess"><?php esc_html_e( 'HTACCESS', 'vigilante' ); ?></span>
4409 + </h2>
4410 + <p><?php esc_html_e( 'Control how other origins may open, embed or fetch your site. Vigilant already sends these headers with the values below.', 'vigilante' ); ?></p>
4411 +
4412 + <table class="form-table">
4413 + <tr>
4414 + <th scope="row"><label for="vigilante-f-security-headers-coop"><?php esc_html_e( 'Cross-Origin-Opener-Policy (COOP)', 'vigilante' ); ?></label></th>
4415 + <td>
4416 + <select id="vigilante-f-security-headers-coop" name="security_headers[cross_origin_policies][opener_policy]">
4417 + <option value="" <?php selected( empty( $vg_cop['opener_policy'] ) ); ?>><?php esc_html_e( 'Disabled (header not sent)', 'vigilante' ); ?></option>
4418 + <option value="unsafe-none" <?php selected( $vg_cop['opener_policy'] ?? '', 'unsafe-none' ); ?>>unsafe-none</option>
4419 + <option value="same-origin-allow-popups" <?php selected( $vg_cop['opener_policy'] ?? '', 'same-origin-allow-popups' ); ?>><?php esc_html_e( 'same-origin-allow-popups (recommended)', 'vigilante' ); ?></option>
4420 + <option value="same-origin" <?php selected( $vg_cop['opener_policy'] ?? '', 'same-origin' ); ?>>same-origin</option>
4421 + </select>
4422 + <p class="description"><?php esc_html_e( '&#9432; Cuts the link between your site and a window from another origin that opened it. Side effect: external tools that open your site in a new tab and talk to it through window.opener, such as Google Tag Assistant, will report that they cannot connect. Pick unsafe-none or Disabled if you need those tools.', 'vigilante' ); ?></p>
4423 + </td>
4424 + </tr>
4425 + <tr>
4426 + <th scope="row"><label for="vigilante-f-security-headers-coep"><?php esc_html_e( 'Cross-Origin-Embedder-Policy (COEP)', 'vigilante' ); ?></label></th>
4427 + <td>
4428 + <select id="vigilante-f-security-headers-coep" name="security_headers[cross_origin_policies][embedder_policy]">
4429 + <option value="unsafe-none" <?php selected( ( $vg_cop['embedder_policy'] ?? 'unsafe-none' ), 'unsafe-none' ); ?>><?php esc_html_e( 'unsafe-none (header not sent)', 'vigilante' ); ?></option>
4430 + <option value="credentialless" <?php selected( $vg_cop['embedder_policy'] ?? '', 'credentialless' ); ?>>credentialless</option>
4431 + <option value="require-corp" <?php selected( $vg_cop['embedder_policy'] ?? '', 'require-corp' ); ?>>require-corp</option>
4432 + </select>
4433 + <p class="description"><?php esc_html_e( '&#9432; Requires every cross-origin resource to opt in. require-corp can block third-party images, fonts, videos and embeds that do not send their own CORP or CORS headers.', 'vigilante' ); ?></p>
4434 + </td>
4435 + </tr>
4436 + <tr>
4437 + <th scope="row"><label for="vigilante-f-security-headers-corp"><?php esc_html_e( 'Cross-Origin-Resource-Policy (CORP)', 'vigilante' ); ?></label></th>
4438 + <td>
4439 + <select id="vigilante-f-security-headers-corp" name="security_headers[cross_origin_policies][resource_policy]">
4440 + <option value="" <?php selected( empty( $vg_cop['resource_policy'] ) ); ?>><?php esc_html_e( 'Disabled (header not sent)', 'vigilante' ); ?></option>
4441 + <option value="same-site" <?php selected( $vg_cop['resource_policy'] ?? '', 'same-site' ); ?>>same-site</option>
4442 + <option value="same-origin" <?php selected( $vg_cop['resource_policy'] ?? '', 'same-origin' ); ?>>same-origin</option>
4443 + <option value="cross-origin" <?php selected( $vg_cop['resource_policy'] ?? '', 'cross-origin' ); ?>><?php esc_html_e( 'cross-origin (recommended)', 'vigilante' ); ?></option>
4444 + </select>
4445 + <p class="description"><?php esc_html_e( '&#9432; Declares who may load resources from this site. same-origin stops hotlinking, but it also breaks CDNs, feed readers and any external service that fetches your images or files.', 'vigilante' ); ?></p>
4446 + </td>
4447 + </tr>
4448 + </table>
4449 + </div>
4450 +
3992 4451 <p class="submit vigilante-submit-buttons">
3993 4452 <?php if ( ! $vg_shared_locked ) : ?>
3994 4453 <button type="submit" class="button button-primary vigilante-save-btn" data-original-text="<?php esc_attr_e( 'Save Settings', 'vigilante' ); ?>">
3995 4454 <?php esc_html_e( 'Save Settings', 'vigilante' ); ?>
@@ -4025,11 +4484,11 @@
4025 4484 <p><?php esc_html_e( 'Control access to WordPress REST API endpoints.', 'vigilante' ); ?></p>
4026 4485
4027 4486 <table class="form-table">
4028 4487 <tr>
4029 - <th scope="row"><?php esc_html_e( 'Access Mode', 'vigilante' ); ?></th>
4488 + <th scope="row"><label for="vigilante-f-rest-api-security-mode"><?php esc_html_e( 'Access Mode', 'vigilante' ); ?></label></th>
4030 4489 <td>
4031 - <select name="rest_api_security[mode]">
4490 + <select id="vigilante-f-rest-api-security-mode" name="rest_api_security[mode]">
4032 4491 <option value="open" <?php selected( $options['mode'] ?? 'selective', 'open' ); ?>><?php esc_html_e( 'Open - Allow all requests', 'vigilante' ); ?></option>
4033 4492 <option value="selective" <?php selected( $options['mode'] ?? 'selective', 'selective' ); ?>><?php esc_html_e( 'Selective - Protect sensitive endpoints', 'vigilante' ); ?></option>
4034 4493 <option value="authenticated_only" <?php selected( $options['mode'] ?? 'selective', 'authenticated_only' ); ?>><?php esc_html_e( 'Authenticated - Require login for all', 'vigilante' ); ?></option>
4035 4494 </select>
@@ -4129,11 +4588,11 @@
4129 4588 </label>
4130 4589 </td>
4131 4590 </tr>
4132 4591 <tr>
4133 - <th scope="row"><?php esc_html_e( 'Minimum Password Length', 'vigilante' ); ?></th>
4592 + <th scope="row"><label for="vigilante-f-user-security-min-password-length"><?php esc_html_e( 'Minimum Password Length', 'vigilante' ); ?></label></th>
4134 4593 <td>
4135 - <input type="number" name="user_security[min_password_length]" value="<?php echo esc_attr( $options['min_password_length'] ?? 12 ); ?>" min="6" max="32" class="small-text">
4594 + <input id="vigilante-f-user-security-min-password-length" type="number" name="user_security[min_password_length]" value="<?php echo esc_attr( $options['min_password_length'] ?? 12 ); ?>" min="6" max="32" class="small-text">
4136 4595 <?php esc_html_e( 'characters', 'vigilante' ); ?>
4137 4596 </td>
4138 4597 </tr>
4139 4598 <tr>
@@ -4286,11 +4745,11 @@
4286 4745 <p class="description"><?php esc_html_e( 'Disable on high-traffic sites to avoid email overload.', 'vigilante' ); ?></p>
4287 4746 </td>
4288 4747 </tr>
4289 4748 <tr>
4290 - <th scope="row"><?php esc_html_e( 'Auto-reject After', 'vigilante' ); ?></th>
4749 + <th scope="row"><label for="vigilante-f-user-security-registration-approval-auto-reject-days"><?php esc_html_e( 'Auto-reject After', 'vigilante' ); ?></label></th>
4291 4750 <td>
4292 - <input type="number" name="user_security[registration_approval][auto_reject_days]" value="<?php echo esc_attr( $registration['auto_reject_days'] ?? 0 ); ?>" min="0" max="365" class="small-text">
4751 + <input id="vigilante-f-user-security-registration-approval-auto-reject-days" type="number" name="user_security[registration_approval][auto_reject_days]" value="<?php echo esc_attr( $registration['auto_reject_days'] ?? 0 ); ?>" min="0" max="365" class="small-text">
4293 4752 <?php esc_html_e( 'days (0 = never)', 'vigilante' ); ?>
4294 4753 <p class="description"><?php esc_html_e( 'Automatically reject pending registrations after this many days.', 'vigilante' ); ?></p>
4295 4754 </td>
4296 4755 </tr>
@@ -4315,18 +4774,18 @@
4315 4774 </label>
4316 4775 </td>
4317 4776 </tr>
4318 4777 <tr>
4319 - <th scope="row"><?php esc_html_e( 'Maximum Sessions', 'vigilante' ); ?></th>
4778 + <th scope="row"><label for="vigilante-f-user-security-session-limits-max-sessions"><?php esc_html_e( 'Maximum Sessions', 'vigilante' ); ?></label></th>
4320 4779 <td>
4321 - <input type="number" name="user_security[session_limits][max_sessions]" value="<?php echo esc_attr( $session_limits['max_sessions'] ?? 3 ); ?>" min="1" max="10" class="small-text">
4780 + <input id="vigilante-f-user-security-session-limits-max-sessions" type="number" name="user_security[session_limits][max_sessions]" value="<?php echo esc_attr( $session_limits['max_sessions'] ?? 3 ); ?>" min="1" max="10" class="small-text">
4322 4781 <?php esc_html_e( 'sessions per user', 'vigilante' ); ?>
4323 4782 </td>
4324 4783 </tr>
4325 4784 <tr>
4326 - <th scope="row"><?php esc_html_e( 'When Limit Exceeded', 'vigilante' ); ?></th>
4785 + <th scope="row"><label for="vigilante-f-user-security-session-limits-behavior"><?php esc_html_e( 'When Limit Exceeded', 'vigilante' ); ?></label></th>
4327 4786 <td>
4328 - <select name="user_security[session_limits][behavior]">
4787 + <select id="vigilante-f-user-security-session-limits-behavior" name="user_security[session_limits][behavior]">
4329 4788 <option value="block_new" <?php selected( ( $session_limits['behavior'] ?? 'close_oldest' ), 'block_new' ); ?>><?php esc_html_e( 'Block new login', 'vigilante' ); ?></option>
4330 4789 <option value="close_oldest" <?php selected( ( $session_limits['behavior'] ?? 'close_oldest' ), 'close_oldest' ); ?>><?php esc_html_e( 'Close oldest session', 'vigilante' ); ?></option>
4331 4790 </select>
4332 4791 <p class="description"><?php esc_html_e( '"Close oldest" is recommended for security - ensures attackers cannot lock out legitimate users.', 'vigilante' ); ?></p>
@@ -4362,27 +4821,27 @@
4362 4821 </label>
4363 4822 </td>
4364 4823 </tr>
4365 4824 <tr>
4366 - <th scope="row"><?php esc_html_e( 'Expire After', 'vigilante' ); ?></th>
4825 + <th scope="row"><label for="vigilante-f-user-security-password-expiration-expire-days"><?php esc_html_e( 'Expire After', 'vigilante' ); ?></label></th>
4367 4826 <td>
4368 - <input type="number" name="user_security[password_expiration][expire_days]" value="<?php echo esc_attr( $password_exp['expire_days'] ?? 90 ); ?>" min="7" max="365" class="small-text">
4827 + <input id="vigilante-f-user-security-password-expiration-expire-days" type="number" name="user_security[password_expiration][expire_days]" value="<?php echo esc_attr( $password_exp['expire_days'] ?? 90 ); ?>" min="7" max="365" class="small-text">
4369 4828 <?php esc_html_e( 'days', 'vigilante' ); ?>
4370 4829 <p class="description"><?php esc_html_e( 'PCI-DSS recommends 90 days.', 'vigilante' ); ?></p>
4371 4830 </td>
4372 4831 </tr>
4373 4832 <tr>
4374 - <th scope="row"><?php esc_html_e( 'Warning Period', 'vigilante' ); ?></th>
4833 + <th scope="row"><label for="vigilante-f-user-security-password-expiration-warning-days"><?php esc_html_e( 'Warning Period', 'vigilante' ); ?></label></th>
4375 4834 <td>
4376 - <input type="number" name="user_security[password_expiration][warning_days]" value="<?php echo esc_attr( $password_exp['warning_days'] ?? 14 ); ?>" min="1" max="30" class="small-text">
4835 + <input id="vigilante-f-user-security-password-expiration-warning-days" type="number" name="user_security[password_expiration][warning_days]" value="<?php echo esc_attr( $password_exp['warning_days'] ?? 14 ); ?>" min="1" max="30" class="small-text">
4377 4836 <?php esc_html_e( 'days before expiration', 'vigilante' ); ?>
4378 4837 <p class="description"><?php esc_html_e( 'Show warning notice this many days before password expires.', 'vigilante' ); ?></p>
4379 4838 </td>
4380 4839 </tr>
4381 4840 <tr>
4382 - <th scope="row"><?php esc_html_e( 'Password History', 'vigilante' ); ?></th>
4841 + <th scope="row"><label for="vigilante-f-user-security-password-expiration-password-history"><?php esc_html_e( 'Password History', 'vigilante' ); ?></label></th>
4383 4842 <td>
4384 - <input type="number" name="user_security[password_expiration][password_history]" value="<?php echo esc_attr( $password_exp['password_history'] ?? 3 ); ?>" min="0" max="24" class="small-text">
4843 + <input id="vigilante-f-user-security-password-expiration-password-history" type="number" name="user_security[password_expiration][password_history]" value="<?php echo esc_attr( $password_exp['password_history'] ?? 3 ); ?>" min="0" max="24" class="small-text">
4385 4844 <?php esc_html_e( 'passwords to remember', 'vigilante' ); ?>
4386 4845 <p class="description"><?php esc_html_e( 'Prevent reusing recent passwords. Set to 0 to disable.', 'vigilante' ); ?></p>
4387 4846 </td>
4388 4847 </tr>
@@ -4464,11 +4923,11 @@
4464 4923 </label>
4465 4924 </td>
4466 4925 </tr>
4467 4926 <tr>
4468 - <th scope="row"><?php esc_html_e( 'Link Expiration', 'vigilante' ); ?></th>
4927 + <th scope="row"><label for="vigilante-f-user-security-email-verification-token-expiry-hours"><?php esc_html_e( 'Link Expiration', 'vigilante' ); ?></label></th>
4469 4928 <td>
4470 - <input type="number" name="user_security[email_verification][token_expiry_hours]" value="<?php echo esc_attr( $email_verify['token_expiry_hours'] ?? 24 ); ?>" min="1" max="168" class="small-text">
4929 + <input id="vigilante-f-user-security-email-verification-token-expiry-hours" type="number" name="user_security[email_verification][token_expiry_hours]" value="<?php echo esc_attr( $email_verify['token_expiry_hours'] ?? 24 ); ?>" min="1" max="168" class="small-text">
4471 4930 <?php esc_html_e( 'hours', 'vigilante' ); ?>
4472 4931 </td>
4473 4932 </tr>
4474 4933 <tr>
@@ -4480,11 +4939,11 @@
4480 4939 </label>
4481 4940 </td>
4482 4941 </tr>
4483 4942 <tr>
4484 - <th scope="row"><?php esc_html_e( 'Auto-delete Unverified', 'vigilante' ); ?></th>
4943 + <th scope="row"><label for="vigilante-f-user-security-email-verification-auto-delete-days"><?php esc_html_e( 'Auto-delete Unverified', 'vigilante' ); ?></label></th>
4485 4944 <td>
4486 - <input type="number" name="user_security[email_verification][auto_delete_days]" value="<?php echo esc_attr( $email_verify['auto_delete_days'] ?? 7 ); ?>" min="0" max="365" class="small-text">
4945 + <input id="vigilante-f-user-security-email-verification-auto-delete-days" type="number" name="user_security[email_verification][auto_delete_days]" value="<?php echo esc_attr( $email_verify['auto_delete_days'] ?? 7 ); ?>" min="0" max="365" class="small-text">
4487 4946 <?php esc_html_e( 'days (0 = never)', 'vigilante' ); ?>
4488 4947 <p class="description"><?php esc_html_e( 'Automatically delete users who never verify their email.', 'vigilante' ); ?></p>
4489 4948 </td>
4490 4949 </tr>
@@ -4508,8 +4967,11 @@
4508 4967 <h2 class="vigilante-tools-header">
4509 4968 <?php esc_html_e( 'User security tools', 'vigilante' ); ?>
4510 4969 </h2>
4511 4970
4971 + <?php $this->render_user_actions_notice(); ?>
4972 + <?php if ( ! $this->user_actions_locked() ) : ?>
4973 +
4512 4974 <!-- Force Password Reset -->
4513 4975 <div class="vigilante-tool-box">
4514 4976 <h3><?php esc_html_e( 'Force password reset', 'vigilante' ); ?></h3>
4515 4977 <p class="description"><?php esc_html_e( 'Force users to reset their password. Useful after a security incident. Users will receive an email with a reset link.', 'vigilante' ); ?></p>
@@ -4645,9 +5107,9 @@
4645 5107 <?php
4646 5108 $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log );
4647 5109 $pending_users = $user_security->get_pending_users();
4648 5110 ?>
4649 - <div class="vigilante-tool-box vigilante-pending-users-section">
5111 + <div id="vigilante-section-users-pending" class="vigilante-tool-box vigilante-pending-users-section">
4650 5112 <h3>
4651 5113 <?php esc_html_e( 'Pending registrations', 'vigilante' ); ?>
4652 5114 <?php if ( count( $pending_users ) > 0 ) : ?>
4653 5115 <span class="vigilante-badge vigilante-badge-warning"><?php echo esc_html( count( $pending_users ) ); ?></span>
@@ -4664,8 +5126,9 @@
4664 5126 <span class="dashicons dashicons-yes-alt"></span>
4665 5127 <p><?php esc_html_e( 'No pending registrations.', 'vigilante' ); ?></p>
4666 5128 </div>
4667 5129 <?php else : ?>
5130 + <?php $this->render_user_actions_notice(); ?>
4668 5131 <table class="wp-list-table widefat fixed striped vigilante-pending-users-table">
4669 5132 <thead>
4670 5133 <tr>
4671 5134 <th><?php esc_html_e( 'User', 'vigilante' ); ?></th>
@@ -4694,12 +5157,12 @@
4694 5157 }
4695 5158 ?>
4696 5159 </td>
4697 5160 <td>
4698 - <button type="button" class="button button-small vigilante-approve-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>">
5161 + <button type="button" class="button button-small vigilante-approve-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>" <?php disabled( $this->user_actions_locked() ); ?>>
4699 5162 <?php esc_html_e( 'Approve', 'vigilante' ); ?>
4700 5163 </button>
4701 - <button type="button" class="button button-small vigilante-reject-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>" style="color: #d63638;">
5164 + <button type="button" class="button button-small vigilante-reject-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>" style="color: #d63638;" <?php disabled( $this->user_actions_locked() ); ?>>
4702 5165 <?php esc_html_e( 'Reject', 'vigilante' ); ?>
4703 5166 </button>
4704 5167 </td>
4705 5168 </tr>
@@ -4821,8 +5284,10 @@
4821 5284 </button>
4822 5285 </p>
4823 5286 </div>
4824 5287 </div>
5288 +
5289 + <?php endif; ?>
4825 5290 </div>
4826 5291 <?php
4827 5292 }
4828 5293
@@ -4998,12 +5463,12 @@
4998 5463 <p><?php esc_html_e( 'The legacy remote interface. It is what the WordPress mobile app, Jetpack and remote managers talk to, and also the way pingback amplification and password guessing reach a site.', 'vigilante' ); ?></p>
4999 5464
5000 5465 <table class="form-table">
5001 5466 <tr id="field-disable-xmlrpc">
5002 - <th scope="row"><?php esc_html_e( 'XML-RPC access', 'vigilante' ); ?></th>
5467 + <th scope="row"><label for="vigilante-f-wp-hardening-xmlrpc-mode"><?php esc_html_e( 'XML-RPC access', 'vigilante' ); ?></label></th>
5003 5468 <td>
5004 5469 <?php $vig_xmlrpc_mode = Vigilante_Comment_Security::resolve_xmlrpc_mode( $this->settings ); ?>
5005 - <select name="wp_hardening[xmlrpc_mode]">
5470 + <select id="vigilante-f-wp-hardening-xmlrpc-mode" name="wp_hardening[xmlrpc_mode]">
5006 5471 <option value="none" <?php selected( $vig_xmlrpc_mode, 'none' ); ?>>
5007 5472 <?php esc_html_e( 'Leave XML-RPC enabled', 'vigilante' ); ?>
5008 5473 </option>
5009 5474 <option value="pingback" <?php selected( $vig_xmlrpc_mode, 'pingback' ); ?>>
@@ -5061,10 +5526,10 @@
5061 5526 <label>
5062 5527 <input type="checkbox" name="wp_hardening[close_old_comments]" value="1" <?php checked( ! empty( $options['close_old_comments'] ) ); ?>>
5063 5528 <?php esc_html_e( 'Automatically close comments on old posts after', 'vigilante' ); ?>
5064 5529 </label>
5065 - <input type="number" name="wp_hardening[close_comments_after_days]" value="<?php echo esc_attr( $options['close_comments_after_days'] ?? 30 ); ?>" min="1" max="365" class="small-text">
5066 - <?php esc_html_e( 'days', 'vigilante' ); ?>
5530 + <input type="number" id="vigilante-f-wp-hardening-close-comments-after-days" name="wp_hardening[close_comments_after_days]" value="<?php echo esc_attr( $options['close_comments_after_days'] ?? 30 ); ?>" min="1" max="365" class="small-text">
5531 + <label for="vigilante-f-wp-hardening-close-comments-after-days"><?php esc_html_e( 'days', 'vigilante' ); ?></label>
5067 5532 </td>
5068 5533 </tr>
5069 5534 <tr>
5070 5535 <th scope="row"><?php esc_html_e( 'Honeypot Protection', 'vigilante' ); ?></th>
@@ -5216,13 +5681,13 @@
5216 5681 <table class="form-table">
5217 5682 <tr>
5218 5683 <th scope="row"><?php esc_html_e( 'Retention', 'vigilante' ); ?></th>
5219 5684 <td>
5220 - <input type="number" name="activity_log[retention_days]" value="<?php echo esc_attr( $options['retention_days'] ?? 30 ); ?>" min="7" max="365" class="small-text">
5221 - <?php esc_html_e( 'days', 'vigilante' ); ?>
5685 + <input type="number" id="vigilante-f-activity-log-retention-days" name="activity_log[retention_days]" value="<?php echo esc_attr( $options['retention_days'] ?? 30 ); ?>" min="7" max="365" class="small-text">
5686 + <label for="vigilante-f-activity-log-retention-days"><?php esc_html_e( 'days', 'vigilante' ); ?></label>
5222 5687 &nbsp;&nbsp;
5223 - <input type="number" name="activity_log[max_entries]" value="<?php echo esc_attr( $options['max_entries'] ?? 10000 ); ?>" min="100" max="100000" step="100" class="small-text">
5224 - <?php esc_html_e( 'max entries', 'vigilante' ); ?>
5688 + <input type="number" id="vigilante-f-activity-log-max-entries" name="activity_log[max_entries]" value="<?php echo esc_attr( $options['max_entries'] ?? 10000 ); ?>" min="100" max="100000" step="100" class="small-text">
5689 + <label for="vigilante-f-activity-log-max-entries"><?php esc_html_e( 'max entries', 'vigilante' ); ?></label>
5225 5690 <p class="description"><?php esc_html_e( 'Whichever limit is reached first takes effect. Changes apply immediately on save; daily maintenance also enforces these limits automatically.', 'vigilante' ); ?></p>
5226 5691 </td>
5227 5692 </tr>
5228 5693 <tr>
@@ -5247,14 +5712,14 @@
5247 5712 </div>
5248 5713 </td>
5249 5714 </tr>
5250 5715 <tr>
5251 - <th scope="row"><?php esc_html_e( 'Option Tracking', 'vigilante' ); ?></th>
5716 + <th scope="row"><label for="vigilante-f-activity-log-tracked-options"><?php esc_html_e( 'Option Tracking', 'vigilante' ); ?></label></th>
5252 5717 <td>
5253 5718 <p class="description" style="margin-top:0;"><?php esc_html_e( 'When "WordPress option changes" is enabled, Vigilant tracks ~30 core WordPress settings (site URL, admin email, registration, active plugins, theme, comments, privacy, etc.). Use the field below to track additional options from other plugins.', 'vigilante' ); ?></p>
5254 5719 <br>
5255 5720 <label><?php esc_html_e( 'Additional options to track:', 'vigilante' ); ?></label><br>
5256 - <textarea name="activity_log[tracked_options]" rows="3" cols="50" class="regular-text code" placeholder="woocommerce_&#10;seopress_&#10;wpforms_"><?php echo esc_textarea( implode( "\n", $options['tracked_options'] ?? array() ) ); ?></textarea>
5721 + <textarea id="vigilante-f-activity-log-tracked-options" name="activity_log[tracked_options]" rows="3" cols="50" class="regular-text code" placeholder="woocommerce_&#10;seopress_&#10;wpforms_"><?php echo esc_textarea( implode( "\n", $options['tracked_options'] ?? array() ) ); ?></textarea>
5257 5722 <p class="description"><?php esc_html_e( 'One option name per line. Use a trailing underscore to match all options with that prefix (e.g. "woocommerce_" tracks all WooCommerce settings).', 'vigilante' ); ?></p>
5258 5723 </td>
5259 5724 </tr>
5260 5725 <tr>
@@ -5261,15 +5726,15 @@
5261 5726 <th scope="row"><?php esc_html_e( 'Exclusions', 'vigilante' ); ?></th>
5262 5727 <td>
5263 5728 <div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:16px; max-width:600px;">
5264 5729 <div>
5265 - <label><?php esc_html_e( 'Excluded user IDs:', 'vigilante' ); ?></label><br>
5266 - <textarea name="activity_log[excluded_users]" rows="3" cols="25"><?php echo esc_textarea( implode( "\n", $options['excluded_users'] ?? array() ) ); ?></textarea>
5730 + <label for="vigilante-f-activity-log-excluded-users"><?php esc_html_e( 'Excluded user IDs:', 'vigilante' ); ?></label><br>
5731 + <textarea id="vigilante-f-activity-log-excluded-users" name="activity_log[excluded_users]" rows="3" cols="25"><?php echo esc_textarea( implode( "\n", $options['excluded_users'] ?? array() ) ); ?></textarea>
5267 5732 <p class="description"><?php esc_html_e( 'One user ID per line. Actions by these users will not be logged.', 'vigilante' ); ?></p>
5268 5733 </div>
5269 5734 <div>
5270 - <label><?php esc_html_e( 'Excluded IPs:', 'vigilante' ); ?></label><br>
5271 - <textarea name="activity_log[excluded_ips]" rows="3" cols="25"><?php echo esc_textarea( implode( "\n", $options['excluded_ips'] ?? array() ) ); ?></textarea>
5735 + <label for="vigilante-f-activity-log-excluded-ips"><?php esc_html_e( 'Excluded IPs:', 'vigilante' ); ?></label><br>
5736 + <textarea id="vigilante-f-activity-log-excluded-ips" name="activity_log[excluded_ips]" rows="3" cols="25"><?php echo esc_textarea( implode( "\n", $options['excluded_ips'] ?? array() ) ); ?></textarea>
5272 5737 <p class="description"><?php esc_html_e( 'One IP per line. Requests from these IPs will not be logged.', 'vigilante' ); ?></p>
5273 5738 </div>
5274 5739 </div>
5275 5740 </td>
@@ -5318,11 +5783,11 @@
5318 5783 <p class="description"><?php esc_html_e( 'Sends one email per event type, then waits for the cooldown below before repeating, so a burst of the same event is a single notice.', 'vigilante' ); ?></p>
5319 5784 </td>
5320 5785 </tr>
5321 5786 <tr>
5322 - <th scope="row"><?php esc_html_e( 'Alert on severity', 'vigilante' ); ?></th>
5787 + <th scope="row"><label for="vigilante-f-audit-alerts-immediate-min-severity"><?php esc_html_e( 'Alert on severity', 'vigilante' ); ?></label></th>
5323 5788 <td>
5324 - <select name="audit_alerts[immediate][min_severity]">
5789 + <select id="vigilante-f-audit-alerts-immediate-min-severity" name="audit_alerts[immediate][min_severity]">
5325 5790 <option value="critical" <?php selected( $alert_severity, 'critical' ); ?>><?php esc_html_e( 'Critical only (recommended)', 'vigilante' ); ?></option>
5326 5791 <option value="warning" <?php selected( $alert_severity, 'warning' ); ?>><?php esc_html_e( 'Warning and Critical', 'vigilante' ); ?></option>
5327 5792 </select>
5328 5793 <p class="description"><?php esc_html_e( 'A new administrator, a closed plugin or a privilege escalation are all logged as Critical, so "Critical only" already covers them.', 'vigilante' ); ?></p>
@@ -5338,11 +5803,11 @@
5338 5803 <p class="description"><?php esc_html_e( 'Catches an attack in progress, e.g. hundreds of firewall blocks or login failures in an hour.', 'vigilante' ); ?></p>
5339 5804 </td>
5340 5805 </tr>
5341 5806 <tr>
5342 - <th scope="row"><?php esc_html_e( 'Time window', 'vigilante' ); ?></th>
5807 + <th scope="row"><label for="vigilante-f-audit-alerts-threshold-window"><?php esc_html_e( 'Time window', 'vigilante' ); ?></label></th>
5343 5808 <td>
5344 - <select name="audit_alerts[threshold][window]">
5809 + <select id="vigilante-f-audit-alerts-threshold-window" name="audit_alerts[threshold][window]">
5345 5810 <option value="30m" <?php selected( $alert_window, '30m' ); ?>><?php esc_html_e( '30 minutes', 'vigilante' ); ?></option>
5346 5811 <option value="1h" <?php selected( $alert_window, '1h' ); ?>><?php esc_html_e( '1 hour', 'vigilante' ); ?></option>
5347 5812 <option value="6h" <?php selected( $alert_window, '6h' ); ?>><?php esc_html_e( '6 hours', 'vigilante' ); ?></option>
5348 5813 <option value="24h" <?php selected( $alert_window, '24h' ); ?>><?php esc_html_e( '24 hours', 'vigilante' ); ?></option>
@@ -5369,10 +5834,10 @@
5369 5834 </tr>
5370 5835 <tr>
5371 5836 <th scope="row"><?php esc_html_e( "Don't repeat alerts", 'vigilante' ); ?></th>
5372 5837 <td>
5373 - <input type="number" name="audit_alerts[cooldown_minutes]" value="<?php echo esc_attr( isset( $alerts['cooldown_minutes'] ) ? (int) $alerts['cooldown_minutes'] : 60 ); ?>" min="0" max="1440" class="small-text">
5374 - <?php esc_html_e( 'minutes', 'vigilante' ); ?>
5838 + <input type="number" id="vigilante-f-audit-alerts-cooldown-minutes" name="audit_alerts[cooldown_minutes]" value="<?php echo esc_attr( isset( $alerts['cooldown_minutes'] ) ? (int) $alerts['cooldown_minutes'] : 60 ); ?>" min="0" max="1440" class="small-text">
5839 + <label for="vigilante-f-audit-alerts-cooldown-minutes"><?php esc_html_e( 'minutes', 'vigilante' ); ?></label>
5375 5840 <p class="description"><?php esc_html_e( 'After an alert, Vigilant waits this long before sending another about the same thing: the same event type for immediate alerts, or the same category for threshold alerts. This prevents a flood during a sustained attack. Applies to both alert types above.', 'vigilante' ); ?></p>
5376 5841 </td>
5377 5842 </tr>
5378 5843
@@ -5445,10 +5910,10 @@
5445 5910 $ua_blacklist = $firewall_options['ua_blacklist'] ?? array();
5446 5911 ?>
5447 5912
5448 5913 <div class="vigilante-log-filters">
5449 - <input type="text" id="vigilante-log-search" size="1" placeholder="<?php esc_attr_e( 'Search logs (min. 3 characters)...', 'vigilante' ); ?>" class="vigilante-log-search-input">
5450 - <select id="vigilante-log-type-filter">
5914 + <input type="text" id="vigilante-log-search" aria-label="<?php esc_attr_e( 'Search the activity log', 'vigilante' ); ?>" size="1" placeholder="<?php esc_attr_e( 'Search logs (min. 3 characters)...', 'vigilante' ); ?>" class="vigilante-log-search-input">
5915 + <select id="vigilante-log-type-filter" aria-label="<?php esc_attr_e( 'Filter the log by event type', 'vigilante' ); ?>">
5451 5916 <option value=""><?php esc_html_e( 'All Types', 'vigilante' ); ?></option>
5452 5917 <option value="login"><?php esc_html_e( 'Login', 'vigilante' ); ?></option>
5453 5918 <option value="user"><?php esc_html_e( 'User', 'vigilante' ); ?></option>
5454 5919 <option value="content"><?php esc_html_e( 'Content', 'vigilante' ); ?></option>
@@ -5461,15 +5926,15 @@
5461 5926 <option value="file"><?php esc_html_e( 'File', 'vigilante' ); ?></option>
5462 5927 <option value="security"><?php esc_html_e( 'Security', 'vigilante' ); ?></option>
5463 5928 <option value="system"><?php esc_html_e( 'System', 'vigilante' ); ?></option>
5464 5929 </select>
5465 - <select id="vigilante-log-severity-filter">
5930 + <select id="vigilante-log-severity-filter" aria-label="<?php esc_attr_e( 'Filter the log by severity', 'vigilante' ); ?>">
5466 5931 <option value=""><?php esc_html_e( 'All Severities', 'vigilante' ); ?></option>
5467 5932 <option value="info"><?php esc_html_e( 'Info', 'vigilante' ); ?></option>
5468 5933 <option value="warning"><?php esc_html_e( 'Warning', 'vigilante' ); ?></option>
5469 5934 <option value="critical"><?php esc_html_e( 'Critical', 'vigilante' ); ?></option>
5470 5935 </select>
5471 - <select id="vigilante-log-method-filter">
5936 + <select id="vigilante-log-method-filter" aria-label="<?php esc_attr_e( 'Filter the log by HTTP method', 'vigilante' ); ?>">
5472 5937 <option value=""><?php esc_html_e( 'All Methods', 'vigilante' ); ?></option>
5473 5938 <option value="GET">GET</option>
5474 5939 <option value="POST">POST</option>
5475 5940 <option value="PUT">PUT</option>
@@ -5536,8 +6001,9 @@
5536 6001 'user' => (string) ( $log->user_login ?? '' ),
5537 6002 'ip' => $ip_val,
5538 6003 'user_agent' => $ua_val,
5539 6004 'request_method' => (string) $request_method,
6005 + 'request_uri' => Vigilante_Activity_Log::extract_request_uri( $log->extra_data ?? '' ),
5540 6006 'date' => (string) ( $log->created_at ?? '' ),
5541 6007 'severity' => (string) ( $log->severity ?? 'info' ),
5542 6008 'is_ip_whitelisted' => ( '' !== $ip_val && in_array( $ip_val, $ip_whitelist, true ) ),
5543 6009 'is_ip_blacklisted' => ( '' !== $ip_val && in_array( $ip_val, $ip_blacklist, true ) ),
@@ -5590,8 +6056,13 @@
5590 6056 */
5591 6057 private function render_tab_file_integrity() {
5592 6058 $is_disabled = $this->render_module_disabled_notice( 'file_integrity' );
5593 6059 $options = $this->settings->get_section( 'file_integrity' );
6060 + // On the main site of a network the critical-file scan is the network's
6061 + // canary for a change to wp-config.php or the root .htaccess, so a
6062 + // main-site admin without network rights cannot turn it off. Since
6063 + // 2.11.8; see Vigilante_Settings::get_main_site_file_settings().
6064 + $vg_main_locked = $this->main_site_files_locked();
5594 6065 $last_scan = get_option( 'vigilante_last_integrity_scan' );
5595 6066 $last_results = get_option( 'vigilante_last_integrity_results' );
5596 6067 $ignored_files = get_option( 'vigilante_ignored_files', array() );
5597 6068
@@ -5644,11 +6115,11 @@
5644 6115 </label>
5645 6116 </td>
5646 6117 </tr>
5647 6118 <tr>
5648 - <th scope="row"><?php esc_html_e( 'Scan Frequency', 'vigilante' ); ?></th>
6119 + <th scope="row"><label for="vigilante-f-file-integrity-scan-frequency"><?php esc_html_e( 'Scan Frequency', 'vigilante' ); ?></label></th>
5649 6120 <td>
5650 - <select name="file_integrity[scan_frequency]">
6121 + <select id="vigilante-f-file-integrity-scan-frequency" name="file_integrity[scan_frequency]">
5651 6122 <option value="daily" <?php selected( $options['scan_frequency'] ?? 'daily', 'daily' ); ?>><?php esc_html_e( 'Daily', 'vigilante' ); ?></option>
5652 6123 <option value="weekly" <?php selected( $options['scan_frequency'] ?? 'daily', 'weekly' ); ?>><?php esc_html_e( 'Weekly', 'vigilante' ); ?></option>
5653 6124 </select>
5654 6125 </td>
@@ -5653,11 +6124,11 @@
5653 6124 </select>
5654 6125 </td>
5655 6126 </tr>
5656 6127 <tr>
5657 - <th scope="row"><?php esc_html_e( 'Email Notifications', 'vigilante' ); ?></th>
6128 + <th scope="row"><label for="vigilante-f-file-integrity-notify-level"><?php esc_html_e( 'Email Notifications', 'vigilante' ); ?></label></th>
5658 6129 <td>
5659 - <select name="file_integrity[notify_level]">
6130 + <select id="vigilante-f-file-integrity-notify-level" name="file_integrity[notify_level]">
5660 6131 <option value="all" <?php selected( $notify_level, 'all' ); ?>><?php esc_html_e( 'All issues (modified + suspicious)', 'vigilante' ); ?></option>
5661 6132 <option value="suspicious_only" <?php selected( $notify_level, 'suspicious_only' ); ?>><?php esc_html_e( 'Suspicious files only', 'vigilante' ); ?></option>
5662 6133 <option value="disabled" <?php selected( $notify_level, 'disabled' ); ?>><?php esc_html_e( 'Disabled', 'vigilante' ); ?></option>
5663 6134 </select>
@@ -5717,10 +6188,13 @@
5717 6188 <?php esc_html_e( 'Uploads directory (detect PHP files, double extensions, .htaccess)', 'vigilante' ); ?>
5718 6189 </label>
5719 6190 <br>
5720 6191 <label>
5721 - <input type="checkbox" name="file_integrity[scan_critical_config]" value="1" <?php checked( $options['scan_critical_config'] ?? true ); ?>>
6192 + <input type="checkbox" name="file_integrity[scan_critical_config]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( $options['scan_critical_config'] ?? true ); ?>>
5722 6193 <?php esc_html_e( 'Critical config files (wp-config.php, .htaccess baseline monitoring)', 'vigilante' ); ?>
6194 + <?php if ( $vg_main_locked ) : ?>
6195 + <span class="description" style="display:block;margin-left:24px;"><?php echo esc_html( Vigilante_Settings::get_shared_files_notice() ); ?></span>
6196 + <?php endif; ?>
5723 6197 </label>
5724 6198 <br>
5725 6199 <label>
5726 6200 <input type="checkbox" name="file_integrity[check_closed_plugins]" value="1" <?php checked( $options['check_closed_plugins'] ?? true ); ?>>
@@ -5729,19 +6203,30 @@
5729 6203 </fieldset>
5730 6204 </td>
5731 6205 </tr>
5732 6206 <tr>
5733 - <th scope="row"><?php esc_html_e( 'Excluded Paths', 'vigilante' ); ?></th>
6207 + <th scope="row"><label for="vigilante-f-file-integrity-excluded-paths"><?php esc_html_e( 'Excluded Paths', 'vigilante' ); ?></label></th>
5734 6208 <td>
5735 - <textarea name="file_integrity[excluded_paths]" rows="4" class="large-text code" placeholder="wp-content/cache&#10;wp-content/languages"><?php echo esc_textarea( implode( "\n", $options['excluded_paths'] ?? array() ) ); ?></textarea>
5736 - <p class="description"><?php esc_html_e( 'One path per line (relative to WordPress root). Files within these paths will be skipped during scans.', 'vigilante' ); ?></p>
6209 + <textarea id="vigilante-f-file-integrity-excluded-paths" name="file_integrity[excluded_paths]" rows="4" class="large-text code" placeholder="wp-content/cache&#10;wp-content/languages"><?php echo esc_textarea( implode( "\n", $options['excluded_paths'] ?? array() ) ); ?></textarea>
6210 + <p class="description"><?php esc_html_e( 'One path per line, relative to the WordPress root. A path such as wp-content/cache excludes exactly that folder and everything under it. A name on its own, such as cache, excludes any folder called exactly that, wherever it is.', 'vigilante' ); ?></p>
5737 6211 </td>
5738 6212 </tr>
5739 6213 <tr>
5740 - <th scope="row"><?php esc_html_e( 'Excluded Extensions', 'vigilante' ); ?></th>
6214 + <th scope="row"><label for="vigilante-f-file-integrity-excluded-extensions"><?php esc_html_e( 'Excluded Extensions', 'vigilante' ); ?></label></th>
5741 6215 <td>
5742 - <textarea name="file_integrity[excluded_extensions]" rows="3" class="large-text code" placeholder=".log&#10;.po&#10;.mo&#10;.pot"><?php echo esc_textarea( implode( "\n", $options['excluded_extensions'] ?? array() ) ); ?></textarea>
5743 - <p class="description"><?php esc_html_e( 'One extension per line (e.g. .log, .po, .mo). Files with these extensions will be skipped. Useful to avoid false positives from translation or log files.', 'vigilante' ); ?></p>
6216 + <textarea id="vigilante-f-file-integrity-excluded-extensions" name="file_integrity[excluded_extensions]" rows="3" class="large-text code" placeholder=".log&#10;.po&#10;.mo&#10;.pot"><?php echo esc_textarea( implode( "\n", $options['excluded_extensions'] ?? array() ) ); ?></textarea>
6217 + <p class="description">
6218 + <?php esc_html_e( 'One extension per line (e.g. .log, .po, .mo). Files with these extensions will be skipped. Useful to avoid false positives from translation or log files.', 'vigilante' ); ?>
6219 + <br>
6220 + <?php
6221 + printf(
6222 + /* translators: 1: opening <code>, 2: closing </code>. Placeholders wrap the scoped-extension example. */
6223 + esc_html__( 'An extension on its own applies to the whole site. To limit it to one folder, write it as %1$swp-content/languages/*.json%2$s, which leaves the same extension watched everywhere else.', 'vigilante' ),
6224 + '<code>',
6225 + '</code>'
6226 + ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML tags are hardcoded.
6227 + ?>
6228 + </p>
5744 6229 </td>
5745 6230 </tr>
5746 6231 </table>
5747 6232 </div>
@@ -5988,9 +6473,15 @@
5988 6473 $crit_diff = $crit_item['diff'] ?? array();
5989 6474 $crit_id = sanitize_html_class( $crit_file );
5990 6475 $added_count = is_array( $crit_diff ) ? count( $crit_diff['added'] ?? array() ) : 0;
5991 6476 $removed_count = is_array( $crit_diff ) ? count( $crit_diff['removed'] ?? array() ) : 0;
5992 - $diff_unavailable = is_array( $crit_diff ) && ! empty( $crit_diff['unavailable'] );
6477 + // The lines of a shared file are for whoever approves it. Results
6478 + // stored before 2.11.8 on the main site still carry them, so the
6479 + // screen asks too, not only the scan that wrote them.
6480 + $diff_network = ( is_array( $crit_diff ) && ! empty( $crit_diff['network'] ) ) || $this->critical_approval_locked();
6481 + $diff_rescan = is_array( $crit_diff ) && ! empty( $crit_diff['rescan'] );
6482 + $diff_redaction = is_array( $crit_diff ) && ! empty( $crit_diff['redaction'] );
6483 + $diff_unavailable = $diff_network || ( is_array( $crit_diff ) && ! empty( $crit_diff['unavailable'] ) );
5993 6484 ?>
5994 6485 <tr>
5995 6486 <td><code style="color: #e36210;"><?php echo esc_html( $crit_file ); ?></code></td>
5996 6487 <td>
@@ -6013,18 +6504,36 @@
6013 6504 <td>
6014 6505 <button type="button" class="button button-small vigilante-toggle-critical-content" data-target="vigilante-critical-content-<?php echo esc_attr( $crit_id ); ?>" data-label-show="<?php esc_attr_e( 'Review changes', 'vigilante' ); ?>" data-label-hide="<?php esc_attr_e( 'Hide changes', 'vigilante' ); ?>">
6015 6506 <?php esc_html_e( 'Review changes', 'vigilante' ); ?>
6016 6507 </button>
6017 - <button type="button" class="button button-small button-primary vigilante-approve-critical-file" data-file="<?php echo esc_attr( $crit_file ); ?>">
6018 - <?php esc_html_e( 'Approve', 'vigilante' ); ?>
6019 - </button>
6508 + <?php if ( $this->critical_approval_locked() ) : ?>
6509 + <span class="description" style="display:block;margin-top:4px;">
6510 + <?php echo esc_html( $this->critical_approval_notice() ); ?>
6511 + </span>
6512 + <?php else : ?>
6513 + <button type="button" class="button button-small button-primary vigilante-approve-critical-file" data-file="<?php echo esc_attr( $crit_file ); ?>">
6514 + <?php esc_html_e( 'Approve', 'vigilante' ); ?>
6515 + </button>
6516 + <?php endif; ?>
6020 6517 </td>
6021 6518 </tr>
6022 6519 <tr id="vigilante-critical-content-<?php echo esc_attr( $crit_id ); ?>" class="vigilante-critical-content-row" style="display:none;">
6023 6520 <td colspan="3" style="padding: 0;">
6024 6521 <div class="vigilante-critical-content" style="max-height: 400px; overflow: auto; background: #fff; padding: 10px; font-size: 12px; line-height: 1.5; font-family: Consolas, Monaco, monospace; border-top: 1px solid #c3c4c7;">
6025 - <?php if ( $diff_unavailable ) : ?>
6522 + <?php if ( $diff_network ) : ?>
6026 6523 <p style="color: #50575e; font-style: italic; margin: 0;">
6524 + <?php esc_html_e( 'This file belongs to the whole network, so its line changes are only shown to network administrators, on the main site.', 'vigilante' ); ?>
6525 + </p>
6526 + <?php elseif ( $diff_rescan ) : ?>
6527 + <p style="color: #50575e; font-style: italic; margin: 0;">
6528 + <?php esc_html_e( 'Run a new scan to see the line changes of this file.', 'vigilante' ); ?>
6529 + </p>
6530 + <?php elseif ( $diff_redaction ) : ?>
6531 + <p style="color: #50575e; font-style: italic; margin: 0;">
6532 + <?php esc_html_e( 'The line changes of this file are not shown because a value in it could not be hidden safely. The change itself is still detected.', 'vigilante' ); ?>
6533 + </p>
6534 + <?php elseif ( $diff_unavailable ) : ?>
6535 + <p style="color: #50575e; font-style: italic; margin: 0;">
6027 6536 <?php esc_html_e( 'Diff not available for this file (baseline was created before diff tracking was added). Approve to enable diff on future changes.', 'vigilante' ); ?>
6028 6537 </p>
6029 6538 <?php elseif ( empty( $crit_diff['added'] ) && empty( $crit_diff['removed'] ) ) : ?>
6030 6539 <p style="color: #50575e; font-style: italic; margin: 0;">
@@ -6052,9 +6561,9 @@
6052 6561 <?php endif; ?>
6053 6562
6054 6563 <?php if ( $has_closed ) : ?>
6055 6564 <div class="vigilante-file-list vigilante-closed-plugins">
6056 - <h3 style="color: #d63638;"><?php esc_html_e( 'Closed + Removed Plugins', 'vigilante' ); ?></h3>
6565 + <h3 id="vigilante-section-fi-closed-plugins" style="color: #d63638;"><?php esc_html_e( 'Closed + Removed Plugins', 'vigilante' ); ?></h3>
6057 6566 <p class="description" style="color: #d63638;">
6058 6567 <?php esc_html_e( '&#9888; Warning: These plugins have been closed in the WordPress.org repository. Closures usually indicate malware, security issues, guideline violations, or supply chain attacks. Uninstall and replace as soon as possible.', 'vigilante' ); ?>
6059 6568 </p>
6060 6569 <table class="wp-list-table widefat striped">
@@ -6263,8 +6772,15 @@
6263 6772 if ( ! current_user_can( 'manage_options' ) ) {
6264 6773 wp_die( esc_html__( 'Permission denied.', 'vigilante' ), 403 );
6265 6774 }
6266 6775
6776 + // The archive carries wp-config.php, which a whole network shares. On a
6777 + // network manage_options is held by every subsite administrator, so the
6778 + // same gate the writers use applies here.
6779 + if ( ! Vigilante_Settings::can_write_shared_files() ) {
6780 + wp_die( esc_html( Vigilante_Settings::get_shared_files_notice() ), 403 );
6781 + }
6782 +
6267 6783 $backup_manager = new Vigilante_Backup_Manager();
6268 6784 $result = $backup_manager->stream_files_zip();
6269 6785
6270 6786 // stream_files_zip() exits on success; only a WP_Error returns here.
@@ -6353,8 +6869,29 @@
6353 6869
6354 6870 // Read ONLY saved options from database (not merged with defaults)
6355 6871 $saved_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
6356 6872
6873 + // What is stored before this request changes anything: the shared file
6874 + // settings this user may not change are put back from here (2.11.6).
6875 + $stored_options = $saved_options;
6876 + $locked = Vigilante_Settings::get_locked_file_settings();
6877 +
6878 + if ( isset( $locked[ $section ] ) && true === $locked[ $section ] ) {
6879 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
6880 + }
6881 +
6882 + // A module switch is a single key, so refusing says more than a success
6883 + // that changed nothing, and the dashboard puts the toggle back.
6884 + if ( 'modules' === $section && isset( $locked['modules'], $data['modules'] ) && is_array( $locked['modules'] ) && is_array( $data['modules'] ) ) {
6885 + foreach ( array_keys( $data['modules'] ) as $vg_module ) {
6886 + if ( in_array( sanitize_key( $vg_module ), $locked['modules'], true ) ) {
6887 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
6888 + }
6889 + }
6890 + }
6891 +
6892 + $rejected_ips = array();
6893 +
6357 6894 // Handle modules
6358 6895 if ( 'modules' === $section && isset( $data['modules'] ) ) {
6359 6896 if ( ! isset( $saved_options['modules'] ) ) {
6360 6897 $saved_options['modules'] = array();
@@ -6372,9 +6909,16 @@
6372 6909 $current_section = isset( $saved_options[ $section ] ) ? $saved_options[ $section ] : array();
6373 6910
6374 6911 // Process the submitted data
6375 6912 $processed = $this->process_section_data( $data[ $section ], $section_defaults, $current_section );
6376 -
6913 +
6914 + // The IP boxes are free text and, until 2.9.9, whatever was typed
6915 + // went straight into the option. An entry the matcher can never
6916 + // match still sits in a security list looking like protection,
6917 + // so the ones that cannot match are dropped and reported back
6918 + // instead of being stored in silence.
6919 + $rejected_ips = $this->filter_ip_lists( $section, $processed );
6920 +
6377 6921 // Save the processed section
6378 6922 $saved_options[ $section ] = $processed;
6379 6923
6380 6924 // Clear active preset when any section settings change
@@ -6384,8 +6928,10 @@
6384 6928
6385 6929 // Clear cache before saving
6386 6930 wp_cache_delete( Vigilante_Settings::OPTION_NAME, 'options' );
6387 6931
6932 + $saved_options = Vigilante_Settings::keep_locked_file_settings( $saved_options, $stored_options );
6933 +
6388 6934 // Save to database
6389 6935 update_option( Vigilante_Settings::OPTION_NAME, $saved_options );
6390 6936
6391 6937 // Clear the settings cache
@@ -6434,12 +6980,59 @@
6434 6980 $login_url_result['sent']
6435 6981 );
6436 6982 }
6437 6983
6984 + if ( ! empty( $rejected_ips ) ) {
6985 + $message .= ' ' . sprintf(
6986 + /* translators: %s: comma separated list of the entries that were not saved. */
6987 + _n(
6988 + 'This entry is not a valid IP, CIDR range or wildcard, so it was not saved: %s',
6989 + 'These entries are not valid IPs, CIDR ranges or wildcards, so they were not saved: %s',
6990 + count( $rejected_ips ),
6991 + 'vigilante'
6992 + ),
6993 + implode( ', ', array_map( 'esc_html', $rejected_ips ) )
6994 + );
6995 + }
6996 +
6438 6997 wp_send_json_success( $message );
6439 6998 }
6440 -
6999 +
6441 7000 /**
7001 + * Keep only the IP patterns the matcher can actually match
7002 + *
7003 + * @since 2.9.9
7004 + *
7005 + * @param string $section Section being saved.
7006 + * @param array $processed Section data, edited in place.
7007 + * @return array Entries that were dropped, for the message back to the user.
7008 + */
7009 + private function filter_ip_lists( $section, &$processed ) {
7010 + $lists = array(
7011 + 'firewall' => array( 'ip_whitelist', 'ip_blacklist' ),
7012 + 'login_security' => array( 'ip_whitelist' ),
7013 + );
7014 +
7015 + if ( ! isset( $lists[ $section ] ) ) {
7016 + return array();
7017 + }
7018 +
7019 + $rejected = array();
7020 +
7021 + foreach ( $lists[ $section ] as $key ) {
7022 + if ( ! isset( $processed[ $key ] ) || ! is_array( $processed[ $key ] ) ) {
7023 + continue;
7024 + }
7025 +
7026 + $split = Vigilante_IP_Utils::split_list( $processed[ $key ] );
7027 + $processed[ $key ] = $split['valid'];
7028 + $rejected = array_merge( $rejected, $split['rejected'] );
7029 + }
7030 +
7031 + return array_values( array_unique( $rejected ) );
7032 + }
7033 +
7034 + /**
6442 7035 * Send 2FA enable notifications to users
6443 7036 *
6444 7037 * @return array Result with 'sent' and 'failed' counts.
6445 7038 */
@@ -6761,13 +7354,27 @@
6761 7354
6762 7355 // Sanitize imported data recursively
6763 7356 $imported = map_deep( $imported, 'sanitize_text_field' );
6764 7357
6765 - // Validate structure
6766 - $defaults = $this->settings->get_default_options();
6767 - $merged = array_replace_recursive( $defaults, $imported );
7358 + // Validate structure: only sections and keys of the schema survive, and
7359 + // every value takes the type of its default. Until 2.11.0 this was an
7360 + // array_replace_recursive() of the file over the defaults, so any key in
7361 + // the file, known or not, landed in vigilante_options (S7). Sections
7362 + // the file does not carry keep their defaults; a section it does carry
7363 + // replaces the default one whole, because validate_options() has
7364 + // already filled in whatever the file left out.
7365 + $defaults = $this->settings->get_default_options();
7366 + $validated = $this->settings->validate_options( $imported );
7367 + $merged = $defaults;
6768 7368
7369 + foreach ( $validated as $section => $data ) {
7370 + if ( is_array( $data ) ) {
7371 + $merged[ $section ] = $data;
7372 + }
7373 + }
7374 +
6769 7375 // Save
7376 + $merged = Vigilante_Settings::keep_locked_file_settings( $merged, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
6770 7377 update_option( Vigilante_Settings::OPTION_NAME, $merged );
6771 7378 $this->settings->clear_cache();
6772 7379
6773 7380 // Re-evaluate the active preset marker. The imported config may match
@@ -6790,9 +7397,9 @@
6790 7397 if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
6791 7398 wp_schedule_single_event( time() + 5, 'vigilante_under_attack_post_scan' );
6792 7399 }
6793 7400
6794 - wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) );
7401 + wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
6795 7402 }
6796 7403
6797 7404 /**
6798 7405 * Detect whether a vigilante_options array matches a known preset.
@@ -6895,9 +7502,11 @@
6895 7502 $preset = isset( $_POST['preset'] ) ? sanitize_key( $_POST['preset'] ) : '';
6896 7503
6897 7504 // Handle reset to defaults
6898 7505 if ( 'reset' === $preset ) {
6899 - $defaults = Vigilante_Settings::get_defaults_preserving_user_data( get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7506 + $stored_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
7507 + $defaults = Vigilante_Settings::get_defaults_preserving_user_data( $stored_options );
7508 + $defaults = Vigilante_Settings::keep_locked_file_settings( $defaults, $stored_options );
6900 7509 update_option( Vigilante_Settings::OPTION_NAME, $defaults );
6901 7510 $this->settings->clear_cache();
6902 7511
6903 7512 // Clear active preset
@@ -6905,9 +7514,9 @@
6905 7514
6906 7515 // Apply file changes after reset
6907 7516 $this->apply_all_file_changes( $defaults );
6908 7517
6909 - wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) );
7518 + wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) . $this->locked_file_settings_message() );
6910 7519 return;
6911 7520 }
6912 7521
6913 7522 $presets = $this->settings->get_presets();
@@ -6933,8 +7542,9 @@
6933 7542 // invent keys that are missing on both sides.
6934 7543 $current = Vigilante_Settings::merge_preset( $this->settings->get_default_options(), $current );
6935 7544
6936 7545 $merged = Vigilante_Settings::merge_preset( $current, $preset_options );
7546 + $merged = Vigilante_Settings::keep_locked_file_settings( $merged, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
6937 7547
6938 7548 update_option( Vigilante_Settings::OPTION_NAME, $merged );
6939 7549 $this->settings->clear_cache();
6940 7550
@@ -6943,9 +7553,9 @@
6943 7553
6944 7554 // Apply file changes after preset
6945 7555 $this->apply_all_file_changes( $merged );
6946 7556
6947 - wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) );
7557 + wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
6948 7558 }
6949 7559
6950 7560 /**
6951 7561 * AJAX: Reset a specific section to defaults
@@ -6980,27 +7590,19 @@
6980 7590 * On a subsite, the settings written to wp-config.php and .htaccess are
6981 7591 * the main site's business. Resetting the local copy of those would only
6982 7592 * make this screen disagree with the file, so they are carried over
6983 7593 * untouched, and a section that is nothing but shared settings is not
6984 - * reset at all.
7594 + * reset at all. On the main site, a user without network rights keeps
7595 + * the ones the shared files are built from as well (2.11.6).
6985 7596 */
6986 - if ( ! Vigilante_Settings::can_write_shared_files() ) {
6987 - $shared = Vigilante_Settings::get_shared_file_settings();
7597 + $locked = Vigilante_Settings::get_locked_file_settings();
6988 7598
6989 - if ( isset( $shared[ $section ] ) ) {
6990 - if ( true === $shared[ $section ] ) {
6991 - wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
6992 - }
6993 -
6994 - foreach ( $shared[ $section ] as $shared_key ) {
6995 - if ( array_key_exists( $shared_key, (array) $current_options[ $section ] ) ) {
6996 - $new_values[ $shared_key ] = $current_options[ $section ][ $shared_key ];
6997 - }
6998 - }
6999 - }
7599 + if ( isset( $locked[ $section ] ) && true === $locked[ $section ] ) {
7600 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7000 7601 }
7001 7602
7002 7603 $current_options[ $section ] = $new_values;
7604 + $current_options = Vigilante_Settings::keep_locked_file_settings( $current_options, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7003 7605
7004 7606 // Save
7005 7607 update_option( Vigilante_Settings::OPTION_NAME, $current_options );
7006 7608 $this->settings->clear_cache();
@@ -7083,8 +7685,19 @@
7083 7685 // Save new results
7084 7686 update_option( 'vigilante_last_integrity_scan', time() );
7085 7687 update_option( 'vigilante_last_integrity_results', $results );
7086 7688
7689 + // On the main site the scan does compute the lines of wp-config.php and
7690 + // .htaccess, for the network administrator. Somebody without network
7691 + // rights gets the change and its sizes, not the lines.
7692 + if ( $this->critical_approval_locked() && ! empty( $results['modified'] ) && is_array( $results['modified'] ) ) {
7693 + foreach ( $results['modified'] as $index => $item ) {
7694 + if ( is_array( $item ) && 'critical_config' === ( $item['type'] ?? '' ) ) {
7695 + $results['modified'][ $index ]['diff'] = Vigilante_File_Integrity::network_only_diff();
7696 + }
7697 + }
7698 + }
7699 +
7087 7700 wp_send_json_success( array(
7088 7701 'message' => __( 'Scan completed.', 'vigilante' ),
7089 7702 'results' => $results,
7090 7703 'ignored_count' => count( get_option( 'vigilante_ignored_files', array() ) ),
@@ -7118,11 +7731,41 @@
7118 7731 if ( ! current_user_can( 'manage_options' ) ) {
7119 7732 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
7120 7733 }
7121 7734
7735 + $results = get_option( 'vigilante_last_integrity_results' );
7736 + $scanned_at = get_option( 'vigilante_last_integrity_scan' );
7737 +
7122 7738 delete_option( 'vigilante_last_integrity_results' );
7123 7739 delete_option( 'vigilante_last_integrity_scan' );
7124 7740
7741 + /*
7742 + * A pending change to wp-config.php or the root .htaccess is closed by
7743 + * approving it, which takes the network. Clearing the results was one
7744 + * more way to close it without, until the next scan: the ignore list was
7745 + * shut in 2.11.8 and this button was left open, found by the cross
7746 + * review of 2.11.8. So for somebody who cannot approve, those entries
7747 + * stay and everything else goes.
7748 + */
7749 + if ( $this->critical_approval_locked() && is_array( $results ) && ! empty( $results['modified'] ) && is_array( $results['modified'] ) ) {
7750 + $critical = array_values(
7751 + array_filter(
7752 + $results['modified'],
7753 + function ( $item ) {
7754 + return is_array( $item ) && 'critical_config' === ( $item['type'] ?? '' );
7755 + }
7756 + )
7757 + );
7758 +
7759 + if ( $critical ) {
7760 + $results['modified'] = $critical;
7761 + $results['suspicious'] = array();
7762 + $results['extra'] = array();
7763 + update_option( 'vigilante_last_integrity_results', $results );
7764 + update_option( 'vigilante_last_integrity_scan', $scanned_at ? $scanned_at : time() );
7765 + }
7766 + }
7767 +
7125 7768 if ( $this->database ) {
7126 7769 $this->database->clear_file_hashes();
7127 7770 }
7128 7771
@@ -7148,8 +7791,14 @@
7148 7791 if ( empty( $file ) ) {
7149 7792 wp_send_json_error( __( 'No file specified.', 'vigilante' ) );
7150 7793 }
7151 7794
7795 + // A change to a shared file is closed by approving it, and approving it
7796 + // takes the network. Ignoring it would close the same warning without.
7797 + if ( $this->critical_approval_locked() && in_array( $file, array( 'wp-config.php', '.htaccess' ), true ) ) {
7798 + wp_send_json_error( $this->critical_approval_notice() );
7799 + }
7800 +
7152 7801 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database );
7153 7802 $file_integrity->ignore_file( $file );
7154 7803
7155 7804 // Also remove the file from stored scan results so UI updates
@@ -7213,12 +7862,14 @@
7213 7862 if ( ! is_array( $raw_files ) ) {
7214 7863 wp_send_json_error( __( 'Invalid request.', 'vigilante' ) );
7215 7864 }
7216 7865
7217 - $files = array();
7866 + $files = array();
7867 + $shared = $this->critical_approval_locked() ? array( 'wp-config.php', '.htaccess' ) : array();
7218 7868 foreach ( $raw_files as $f ) {
7219 7869 $clean = sanitize_text_field( $f );
7220 - if ( '' !== $clean ) {
7870 + // Same rule as ajax_ignore_file() for the two shared files.
7871 + if ( '' !== $clean && ! in_array( $clean, $shared, true ) ) {
7221 7872 $files[] = $clean;
7222 7873 }
7223 7874 }
7224 7875