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 +964 -141 2.9.72.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
@@ -343,8 +369,103 @@
343 369 }
344 370
345 371 update_option( 'vigilante_db_version', '2.9.3' );
346 372 }
373 +
374 + /*
375 + * 2.9.8: the mixed content handling changes shape. "Upgrade Insecure
376 + * Requests" becomes a setting of its own, and Fix Mixed Content ships
377 + * off, where before it shipped on and carried the directive with it.
378 + * Both have to be written down for sites that are updating, so their
379 + * pages keep loading exactly what they loaded yesterday.
380 + *
381 + * Read the RAW stored options, not get_section(): that one merges the
382 + * defaults, so a site that never stored the key would be read with the
383 + * new default and silently lose the behaviour it had. Absent means the
384 + * site was running on the old default, which was on.
385 + */
386 + if ( version_compare( $db_version, '2.9.8', '<' ) ) {
387 + $raw = get_option( Vigilante_Settings::OPTION_NAME, array() );
388 + $stored = ( is_array( $raw ) && isset( $raw['security_headers'] ) && is_array( $raw['security_headers'] ) ) ? $raw['security_headers'] : array();
389 + $had_fix = array_key_exists( 'fix_mixed_content', $stored ) ? ! empty( $stored['fix_mixed_content'] ) : true;
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 + */
398 + $this->settings->update_section(
399 + 'security_headers',
400 + array_merge(
401 + $stored,
402 + array(
403 + 'fix_mixed_content' => $had_fix,
404 + 'upgrade_insecure_requests' => $had_fix,
405 + )
406 + )
407 + );
408 +
409 + update_option( 'vigilante_db_version', '2.9.8' );
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 + }
347 468 }
348 469
349 470 /**
350 471 * Migration: Remove orphaned email fields from saved options
@@ -1149,8 +1270,12 @@
1149 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' ) ),
1150 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' ) ),
1151 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' ) ),
1152 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' ) ),
1153 1278 // Login Security
1154 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' ) ),
1155 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' ) ),
1156 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' ) ),
@@ -1243,8 +1368,9 @@
1243 1368 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Security Headers', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Enable CSP', 'vigilante' ), 'label_en' => 'Enable CSP', 'keywords' => _x( 'enable csp content security policy', 'settings search keywords', 'vigilante' ) ),
1244 1369 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Security Headers', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Report Only Mode', 'vigilante' ), 'label_en' => 'Report Only Mode', 'keywords' => _x( 'report only mode', 'settings search keywords', 'vigilante' ) ),
1245 1370 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Security Headers', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Redirect HTTP to HTTPS', 'vigilante' ), 'label_en' => 'Redirect HTTP to HTTPS', 'keywords' => _x( 'redirect http to https redirection forward ssl tls secure', 'settings search keywords', 'vigilante' ) ),
1246 1371 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Security Headers', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Fix Mixed Content', 'vigilante' ), 'label_en' => 'Fix Mixed Content', 'keywords' => _x( 'fix mixed content insecure http', 'settings search keywords', 'vigilante' ) ),
1372 + array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Security Headers', 'vigilante' ), 'anchor' => 'field-upgrade-insecure-requests', 'label' => __( 'Upgrade Insecure Requests', 'vigilante' ), 'label_en' => 'Upgrade Insecure Requests', 'keywords' => _x( 'upgrade insecure requests mixed content csp https external resources', 'settings search keywords', 'vigilante' ) ),
1247 1373 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Security Headers', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Rewrite Site Address on Activation', 'vigilante' ), 'label_en' => 'Rewrite Site Address on Activation', 'keywords' => _x( 'rewrite site address on activation', 'settings search keywords', 'vigilante' ) ),
1248 1374 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Security Headers', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Enable HSTS', 'vigilante' ), 'label_en' => 'Enable HSTS', 'keywords' => _x( 'enable hsts strict transport security', 'settings search keywords', 'vigilante' ) ),
1249 1375 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Security Headers', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Max Age', 'vigilante' ), 'label_en' => 'Max Age', 'keywords' => _x( 'max age', 'settings search keywords', 'vigilante' ) ),
1250 1376 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Security Headers', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Include Subdomains', 'vigilante' ), 'label_en' => 'Include Subdomains', 'keywords' => _x( 'include subdomains', 'settings search keywords', 'vigilante' ) ),
@@ -1342,8 +1468,11 @@
1342 1468 'currentUserId' => get_current_user_id(),
1343 1469 'logoutUrl' => wp_logout_url( wp_login_url() ),
1344 1470 'adminUrl' => admin_url( 'admin.php?page=vigilante' ),
1345 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(),
1346 1475 'underAttack' => array(
1347 1476 'active' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->is_active(),
1348 1477 'remaining' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->get_remaining_time(),
1349 1478 ),
@@ -1410,13 +1539,17 @@
1410 1539 'criticalConfigTitle' => __( 'Critical config files modified', 'vigilante' ),
1411 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' ),
1412 1541 'approve' => __( 'Approve', 'vigilante' ),
1413 1542 'approving' => __( 'Approving...', 'vigilante' ),
1543 + 'approvalLockedNotice' => $this->critical_approval_notice(),
1414 1544 'criticalApproved' => __( 'Change approved. Next scan will use the current state as baseline.', 'vigilante' ),
1415 1545 'reviewChanges' => __( 'Review changes', 'vigilante' ),
1416 1546 'hideChanges' => __( 'Hide changes', 'vigilante' ),
1417 1547 'changes' => __( 'Changes', 'vigilante' ),
1418 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' ),
1419 1552 'diffEmpty' => __( 'No line-level changes detected (may be whitespace or reordering).', 'vigilante' ),
1420 1553 'diffLines' => __( 'lines', 'vigilante' ),
1421 1554 // Under Attack mode strings
1422 1555 'underAttackConfirmActivate' => __( 'Activate Under Attack mode? All visitors will see a verification page for the next 4 hours.', 'vigilante' ),
@@ -1472,8 +1605,9 @@
1472 1605 'logType' => __( 'Type', 'vigilante' ),
1473 1606 'logAction' => __( 'Action', 'vigilante' ),
1474 1607 'logSeverity' => __( 'Severity', 'vigilante' ),
1475 1608 'logMessage' => __( 'Message', 'vigilante' ),
1609 + 'logRequestUri' => __( 'Address', 'vigilante' ),
1476 1610 'logClient' => __( 'Client', 'vigilante' ),
1477 1611 'logUser' => __( 'User', 'vigilante' ),
1478 1612 'logIpAddress' => __( 'IP Address', 'vigilante' ),
1479 1613 'logUserAgent' => __( 'User Agent', 'vigilante' ),
@@ -1508,8 +1642,10 @@
1508 1642 /* translators: 1: selected count, 2: human-readable size */
1509 1643 'dbTablesSelected' => __( '%1$d tables selected (%2$s)', 'vigilante' ),
1510 1644 // Settings search strings
1511 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' ),
1512 1648 'searchInTab' => __( 'in', 'vigilante' ),
1513 1649 // Modules string
1514 1650 /* translators: 1: enabled count, 2: total count */
1515 1651 'modulesEnabled' => __( '%1$d / %2$d modules enabled', 'vigilante' ),
@@ -1676,8 +1812,21 @@
1676 1812 </p>
1677 1813 <p>
1678 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>
1679 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; ?>
1680 1829 </div>
1681 1830 <?php
1682 1831 }
1683 1832 }
@@ -1779,9 +1928,9 @@
1779 1928 </h1>
1780 1929 <div class="vigilante-search-wrapper">
1781 1930 <div class="vigilante-search-input-wrap">
1782 1931 <span class="vigilante-search-icon dashicons dashicons-search" aria-hidden="true"></span>
1783 - <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">
1784 1933 <span class="vigilante-search-shortcut" aria-hidden="true">/</span>
1785 1934 </div>
1786 1935 <div id="vigilante-settings-search-results" class="vigilante-search-results" hidden role="listbox"></div>
1787 1936 </div>
@@ -1863,8 +2012,204 @@
1863 2012 <?php
1864 2013 }
1865 2014
1866 2015 /**
2016 + * Values to display for a section that this site does not control
2017 + *
2018 + * On a subsite the stored options are its own copy, which nothing acts on:
2019 + * wp-config.php and .htaccess are written from the main site. Painting the
2020 + * local copy describes a configuration that is not running, so a subsite
2021 + * admin sees a box ticked here and the constant absent from the file, or the
2022 + * other way round. Read the main site's values instead, which are the ones in
2023 + * force, and fall back to the local ones if they cannot be read.
2024 + *
2025 + * @since 2.9.8
2026 + *
2027 + * @param string $section Settings section.
2028 + * @return array
2029 + */
2030 + private function get_section_for_display( $section ) {
2031 + $local = $this->settings->get_section( $section );
2032 +
2033 + if ( ! $this->shared_files_locked() ) {
2034 + return $local;
2035 + }
2036 +
2037 + // shared_files_locked() is only true on multisite, where get_blog_option() exists.
2038 + $main = get_blog_option( get_main_site_id(), Vigilante_Settings::OPTION_NAME, array() );
2039 +
2040 + if ( ! is_array( $main ) || empty( $main[ $section ] ) || ! is_array( $main[ $section ] ) ) {
2041 + return $local;
2042 + }
2043 +
2044 + return wp_parse_args( $main[ $section ], $local );
2045 + }
2046 +
2047 + /**
2048 + * Whether the sections that write wp-config.php and .htaccess are read-only here
2049 + *
2050 + * True on a network when this is not the main site, or the user is not a
2051 + * network administrator. See Vigilante_Settings::can_write_shared_files().
2052 + *
2053 + * @since 2.9.8
2054 + *
2055 + * @return bool
2056 + */
2057 + private function shared_files_locked() {
2058 + return ! Vigilante_Settings::can_write_shared_files();
2059 + }
2060 +
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 + /**
2095 + * Print the shared-files notice for a section that cannot be edited here
2096 + *
2097 + * @since 2.9.8
2098 + */
2099 + private function render_shared_files_notice() {
2100 + if ( ! $this->shared_files_locked() ) {
2101 + return;
2102 + }
2103 + ?>
2104 + <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;">
2105 + <p style="margin:0;"><?php echo esc_html( Vigilante_Settings::get_shared_files_notice() ); ?></p>
2106 + </div>
2107 + <?php
2108 + }
2109 +
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 + /**
1867 2212 * Check if module is disabled and render warning
1868 2213 *
1869 2214 * @param string $module_key Module key.
1870 2215 * @return bool True if disabled.
@@ -2408,14 +2753,15 @@
2408 2753
2409 2754 <?php $this->render_analyzer_widget( $analyzer_last_scan, $analyzer_history, $analyzer_categories_def, $analyzer_settings ); ?>
2410 2755
2411 2756 <div class="vigilante-modules-grid">
2412 - <h2><?php esc_html_e( 'Security Modules', 'vigilante' ); ?></h2>
2757 + <h2 id="vigilante-section-dashboard-modules"><?php esc_html_e( 'Security Modules', 'vigilante' ); ?></h2>
2413 2758 <p class="description"><?php esc_html_e( 'Enable or disable security modules. Each module controls a tab with detailed settings.', 'vigilante' ); ?></p>
2414 2759 <div class="vigilante-modules-list">
2415 2760 <?php foreach ( $options['modules'] as $module => $enabled ) :
2416 2761 $label = isset( $module_labels[ $module ] ) ? $module_labels[ $module ] : ucwords( str_replace( '_', ' ', $module ) );
2417 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 );
2418 2764 ?>
2419 2765 <div class="vigilante-module-item <?php echo $enabled ? 'enabled' : 'disabled'; ?>">
2420 2766 <div class="vigilante-module-header">
2421 2767 <span class="vigilante-module-status"></span>
@@ -2428,8 +2774,9 @@
2428 2774 <input type="checkbox"
2429 2775 name="modules[<?php echo esc_attr( $module ); ?>]"
2430 2776 value="1"
2431 2777 <?php checked( $enabled ); ?>
2778 + <?php disabled( $vg_module_locked ); ?>
2432 2779 aria-label="<?php echo esc_attr( $toggle_label ); ?>"
2433 2780 data-module="<?php echo esc_attr( $module ); ?>">
2434 2781 <span class="vigilante-toggle-slider"></span>
2435 2782 </label>
@@ -2436,8 +2783,11 @@
2436 2783 </div>
2437 2784 <?php if ( $description ) : ?>
2438 2785 <p class="vigilante-module-desc"><?php echo esc_html( $description ); ?></p>
2439 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; ?>
2440 2790 </div>
2441 2791 <?php endforeach; ?>
2442 2792 </div>
2443 2793 </div>
@@ -2469,9 +2819,9 @@
2469 2819 $ua_remaining_hours = floor( $ua_remaining / 3600 );
2470 2820 $ua_remaining_mins = floor( ( $ua_remaining % 3600 ) / 60 );
2471 2821 ?>
2472 2822 <div class="vigilante-preset-card vigilante-under-attack-card <?php echo $ua_active ? 'vigilante-under-attack-active' : ''; ?>">
2473 - <h3>
2823 + <h3 id="vigilante-section-dashboard-under-attack">
2474 2824 <span class="dashicons dashicons-shield"></span>
2475 2825 <?php esc_html_e( 'Under Attack', 'vigilante' ); ?>
2476 2826 </h3>
2477 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>
@@ -2549,11 +2899,11 @@
2549 2899 </label>
2550 2900 </td>
2551 2901 </tr>
2552 2902 <tr>
2553 - <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>
2554 2904 <td>
2555 - <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>
2556 2906 <p class="description"><?php esc_html_e( 'One email per line.', 'vigilante' ); ?></p>
2557 2907 </td>
2558 2908 </tr>
2559 2909 <tr>
@@ -2728,9 +3078,9 @@
2728 3078
2729 3079 <div class="vigilante-tool-card">
2730 3080 <h3><?php esc_html_e( 'Import Settings', 'vigilante' ); ?></h3>
2731 3081 <p><?php esc_html_e( 'Import settings from a previously exported JSON file.', 'vigilante' ); ?></p>
2732 - <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;">
2733 3083 <button type="button" class="button vigilante-import-settings">
2734 3084 <?php esc_html_e( 'Import Settings', 'vigilante' ); ?>
2735 3085 </button>
2736 3086 </div>
@@ -2736,9 +3086,9 @@
2736 3086 </div>
2737 3087
2738 3088 <div class="vigilante-tool-card">
2739 3089 <h3><?php esc_html_e( 'Reset to Defaults', 'vigilante' ); ?></h3>
2740 - <p><?php esc_html_e( 'Reset all the Vigilant security settings to default values.', 'vigilante' ); ?></p>
3090 + <p><?php esc_html_e( 'Reset all the Vigilant security settings to default values. Your IP lists, custom login address, two-factor setup, scan exclusions and extra alert recipients are kept.', 'vigilante' ); ?></p>
2741 3091 <button type="button" class="button vigilante-reset-settings" style="color: #a00;">
2742 3092 <?php esc_html_e( 'Reset All Settings', 'vigilante' ); ?>
2743 3093 </button>
2744 3094 </div>
@@ -2745,13 +3095,19 @@
2745 3095
2746 3096 <div class="vigilante-tool-card">
2747 3097 <h3><?php esc_html_e( 'Download Config Backup', 'vigilante' ); ?></h3>
2748 3098 <p><?php esc_html_e( 'Download a ZIP backup of your wp-config.php and .htaccess (plus robots.txt if present) before making security changes. The archive is built on the fly and sent to your browser, so nothing is left on the server.', 'vigilante' ); ?></p>
3099 + <?php if ( $this->shared_files_locked() ) : ?>
3100 + <p class="description"><?php esc_html_e( 'Both files belong to the whole network, and wp-config.php carries the database credentials and the authentication salts of every site. The copy is taken from the main site.', 'vigilante' ); ?></p>
3101 + <?php else : ?>
2749 3102 <button type="button" class="button vigilante-create-backup">
2750 3103 <?php esc_html_e( 'Download Backup', 'vigilante' ); ?>
2751 3104 </button>
3105 + <?php endif; ?>
2752 3106 </div>
2753 3107
3108 + <?php if ( ! $this->shared_files_locked() ) : ?>
3109 +
2754 3110 <div class="vigilante-tool-card vigilante-tool-card-wide">
2755 3111 <h3><?php esc_html_e( 'Database Backup', 'vigilante' ); ?></h3>
2756 3112 <p><?php esc_html_e( 'Download a backup of your database as a ZIP file. Select which tables to include.', 'vigilante' ); ?></p>
2757 3113 <button type="button" class="button vigilante-db-backup-toggle">
@@ -2790,8 +3146,15 @@
2790 3146 </div>
2791 3147 </div>
2792 3148 </div>
2793 3149 </div>
3150 + <?php else : ?>
3151 + <div class="vigilante-tool-card vigilante-tool-card-wide">
3152 + <h3><?php esc_html_e( 'Database Backup', 'vigilante' ); ?></h3>
3153 + <p><?php esc_html_e( 'Download a backup of your database as a ZIP file. Select which tables to include.', 'vigilante' ); ?></p>
3154 + <p class="description"><?php esc_html_e( 'The database is shared by the whole network, so a backup taken here would carry every other site and all of the network users. The copy is taken from the main site.', 'vigilante' ); ?></p>
3155 + </div>
3156 + <?php endif; ?>
2794 3157 </div>
2795 3158 <?php
2796 3159 }
2797 3160
@@ -2814,14 +3177,21 @@
2814 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' ); ?>
2815 3178 </p>
2816 3179 </div>
2817 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 +
2818 3188 <table class="form-table">
2819 3189 <tr>
2820 3190 <th scope="row"><?php esc_html_e( 'Block Bad Query Strings', 'vigilante' ); ?></th>
2821 3191 <td>
2822 3192 <label>
2823 - <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'] ) ); ?>>
2824 3194 <?php esc_html_e( 'Block malicious query string patterns', 'vigilante' ); ?>
2825 3195 </label>
2826 3196 </td>
2827 3197 </tr>
@@ -2864,9 +3234,9 @@
2864 3234 <tr>
2865 3235 <th scope="row"><?php esc_html_e( 'Block Bad Bots', 'vigilante' ); ?></th>
2866 3236 <td>
2867 3237 <label>
2868 - <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'] ) ); ?>>
2869 3239 <?php esc_html_e( 'Block known malicious bots and scanners', 'vigilante' ); ?>
2870 3240 </label>
2871 3241 </td>
2872 3242 </tr>
@@ -2883,11 +3253,11 @@
2883 3253 </label>
2884 3254 </td>
2885 3255 </tr>
2886 3256 <tr>
2887 - <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>
2888 3258 <td>
2889 - <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">
2890 3260 <p class="description">
2891 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' ); ?>
2892 3262 </p>
2893 3263 </td>
@@ -2892,11 +3262,11 @@
2892 3262 </p>
2893 3263 </td>
2894 3264 </tr>
2895 3265 <tr>
2896 - <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>
2897 3267 <td>
2898 - <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">
2899 3269 </td>
2900 3270 </tr>
2901 3271 <tr>
2902 3272 <th scope="row"><?php esc_html_e( 'Progressive Blocking', 'vigilante' ); ?></th>
@@ -2919,11 +3289,11 @@
2919 3289 </p>
2920 3290 </td>
2921 3291 </tr>
2922 3292 <tr>
2923 - <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>
2924 3294 <td>
2925 - <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]">
2926 3296 <?php
2927 3297 $max_options = array(
2928 3298 3600 => __( '1 hour', 'vigilante' ),
2929 3299 21600 => __( '6 hours', 'vigilante' ),
@@ -2980,8 +3350,29 @@
2980 3350 </table>
2981 3351 </div>
2982 3352 <?php endif; ?>
2983 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 +
2984 3375 <h3><?php esc_html_e( 'IP Lists', 'vigilante' ); ?></h3>
2985 3376 <p class="description">
2986 3377 <?php
2987 3378 printf(
@@ -2992,12 +3383,12 @@
2992 3383 ?>
2993 3384 </p>
2994 3385 <table class="form-table">
2995 3386 <tr>
2996 - <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>
2997 3388 <td>
2998 3389 <?php $proxy_header = $options['trusted_proxy_header'] ?? ''; ?>
2999 - <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 ); ?>>
3000 3391 <option value="" <?php selected( $proxy_header, '' ); ?>><?php esc_html_e( 'Direct connection, only REMOTE_ADDR (recommended)', 'vigilante' ); ?></option>
3001 3392 <option value="cf-connecting-ip" <?php selected( $proxy_header, 'cf-connecting-ip' ); ?>><?php esc_html_e( 'Behind Cloudflare (CF-Connecting-IP)', 'vigilante' ); ?></option>
3002 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>
3003 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>
@@ -3007,13 +3398,13 @@
3007 3398 </p>
3008 3399 </td>
3009 3400 </tr>
3010 3401 <tr>
3011 - <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>
3012 3403 <td>
3013 - <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>
3014 3405 <p class="description">
3015 - <?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' ); ?>
3016 3407 <br>
3017 3408 <?php
3018 3409 printf(
3019 3410 /* translators: 1: opening <code>, 2: closing </code>. Placeholders wrap the IP, CIDR and wildcard examples. */
@@ -3025,11 +3416,11 @@
3025 3416 </p>
3026 3417 </td>
3027 3418 </tr>
3028 3419 <tr>
3029 - <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>
3030 3421 <td>
3031 - <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>
3032 3423 <p class="description">
3033 3424 <?php esc_html_e( 'One IP per line. These IPs will be blocked immediately.', 'vigilante' ); ?>
3034 3425 <br>
3035 3426 <?php
@@ -3048,18 +3439,18 @@
3048 3439 <h3><?php esc_html_e( 'User-Agent Lists', 'vigilante' ); ?></h3>
3049 3440 <p><?php esc_html_e( 'Partial matching: enter a keyword and any User-Agent containing it will be matched.', 'vigilante' ); ?></p>
3050 3441 <table class="form-table">
3051 3442 <tr>
3052 - <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>
3053 3444 <td>
3054 - <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>
3055 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>
3056 3447 </td>
3057 3448 </tr>
3058 3449 <tr>
3059 - <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>
3060 3451 <td>
3061 - <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>
3062 3453 <p class="description"><?php esc_html_e( 'One User-Agent per line. These will be blocked immediately.', 'vigilante' ); ?></p>
3063 3454 </td>
3064 3455 </tr>
3065 3456 </table>
@@ -3064,9 +3455,16 @@
3064 3455 </tr>
3065 3456 </table>
3066 3457 </div>
3067 3458
3068 - <div id="vigilante-section-firewall-server" class="vigilante-settings-section">
3459 + <?php
3460 + $vg_shared_locked = $this->shared_files_locked();
3461 + // Paint what is actually in force, not this site's unused copy.
3462 + $vg_local_options = $options;
3463 + $options = $this->get_section_for_display( 'firewall' );
3464 + ?>
3465 + <?php $this->render_shared_files_notice(); ?>
3466 + <div id="vigilante-section-firewall-server" class="vigilante-settings-section <?php echo $vg_shared_locked ? 'vigilante-form-disabled' : ''; ?>" <?php echo $vg_shared_locked ? 'inert' : ''; ?>>
3069 3467 <h2>
3070 3468 <?php esc_html_e( 'Server Protection', 'vigilante' ); ?>
3071 3469 <span class="vigilante-method-badge htaccess"><?php esc_html_e( 'HTACCESS', 'vigilante' ); ?></span>
3072 3470 </h2>
@@ -3147,8 +3545,9 @@
3147 3545 </td>
3148 3546 </tr>
3149 3547 </table>
3150 3548 </div>
3549 + <?php $options = $vg_local_options; ?>
3151 3550
3152 3551 <p class="submit vigilante-submit-buttons">
3153 3552 <button type="submit" class="button button-primary vigilante-save-btn" data-original-text="<?php esc_attr_e( 'Save Settings', 'vigilante' ); ?>">
3154 3553 <?php esc_html_e( 'Save Settings', 'vigilante' ); ?>
@@ -3179,18 +3578,18 @@
3179 3578 <p><?php esc_html_e( 'Brute force protection and WordPress login hardening.', 'vigilante' ); ?></p>
3180 3579
3181 3580 <table class="form-table">
3182 3581 <tr id="field-max-attempts">
3183 - <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>
3184 3583 <td>
3185 - <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">
3186 3585 <p class="description"><?php esc_html_e( 'Number of failed attempts before lockout.', 'vigilante' ); ?></p>
3187 3586 </td>
3188 3587 </tr>
3189 3588 <tr>
3190 - <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>
3191 3590 <td>
3192 - <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">
3193 3592 <?php esc_html_e( 'minutes', 'vigilante' ); ?>
3194 3593 </td>
3195 3594 </tr>
3196 3595 <tr>
@@ -3239,8 +3638,11 @@
3239 3638 </p>
3240 3639 <p class="description">
3241 3640 <?php esc_html_e( 'Direct access to wp-login.php and wp-admin will return a 404 error for non-logged users.', 'vigilante' ); ?>
3242 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>
3243 3645 </div>
3244 3646 </td>
3245 3647 </tr>
3246 3648 </table>
@@ -3329,9 +3731,9 @@
3329 3731 $two_factor = $options['two_factor'] ?? array();
3330 3732 $two_factor_enabled = ! empty( $two_factor['enabled'] );
3331 3733 ?>
3332 3734 <div class="vigilante-settings-section vigilante-lockout-section">
3333 - <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>
3334 3736
3335 3737 <table class="form-table">
3336 3738 <tr>
3337 3739 <th scope="row"><?php esc_html_e( 'Current settings', 'vigilante' ); ?></th>
@@ -3491,9 +3893,9 @@
3491 3893 $excluded = $two_factor['excluded_users'] ?? array();
3492 3894 $method = $two_factor['method'] ?? 'email';
3493 3895 $grace_days = $two_factor['grace_period_days'] ?? 3;
3494 3896 ?>
3495 - <h3>
3897 + <h3 id="vigilante-section-login-2fa">
3496 3898 <?php esc_html_e( 'Two-Factor Authentication (2FA)', 'vigilante' ); ?>
3497 3899 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
3498 3900 <span class="vigilante-method-badge database"><?php esc_html_e( 'Database', 'vigilante' ); ?></span>
3499 3901 </h3>
@@ -3607,11 +4009,11 @@
3607 4009 </tr>
3608 4010
3609 4011 <!-- TOTP-specific: Grace period -->
3610 4012 <tr class="vigilante-2fa-totp-only" <?php echo 'totp' !== $method ? 'style="display:none;"' : ''; ?>>
3611 - <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>
3612 4014 <td>
3613 - <input type="number"
4015 + <input id="vigilante-f-login-security-two-factor-grace-period-days" type="number"
3614 4016 name="login_security[two_factor][grace_period_days]"
3615 4017 value="<?php echo esc_attr( $grace_days ); ?>"
3616 4018 min="0" max="30" class="small-text">
3617 4019 <?php esc_html_e( 'days', 'vigilante' ); ?>
@@ -3620,11 +4022,11 @@
3620 4022 </tr>
3621 4023
3622 4024 <!-- Email-specific: Sender name -->
3623 4025 <tr class="vigilante-2fa-email-only" <?php echo 'email' !== $method ? 'style="display:none;"' : ''; ?>>
3624 - <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>
3625 4027 <td>
3626 - <input type="text"
4028 + <input id="vigilante-f-login-security-two-factor-email-from-name" type="text"
3627 4029 name="login_security[two_factor][email_from_name]"
3628 4030 value="<?php echo esc_attr( $two_factor['email_from_name'] ?? '' ); ?>"
3629 4031 class="regular-text vigilante-2fa-email-from"
3630 4032 placeholder="<?php echo esc_attr( get_bloginfo( 'name' ) ); ?>">
@@ -3692,14 +4094,140 @@
3692 4094
3693 4095 /**
3694 4096 * Render security headers tab
3695 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 +
3696 4218 private function render_tab_headers() {
3697 4219 $is_disabled = $this->render_module_disabled_notice( 'security_headers' );
3698 - $options = $this->settings->get_section( 'security_headers' );
4220 + // Every setting on this tab ends up in .htaccess, so on a subsite the
4221 + // whole tab is somebody else's, values included.
4222 + $vg_shared_locked = $this->shared_files_locked();
4223 + $options = $this->get_section_for_display( 'security_headers' );
3699 4224 ?>
4225 + <?php $this->render_headers_recovery_offer(); ?>
4226 +
3700 4227 <form class="vigilante-settings-form <?php echo $is_disabled ? 'vigilante-form-disabled' : ''; ?>" data-section="security_headers" <?php echo $is_disabled ? 'inert' : ''; ?>>
3701 - <div id="vigilante-section-headers-main" class="vigilante-settings-section">
4228 + <?php $this->render_shared_files_notice(); ?>
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' : ''; ?>>
3702 4230 <h2>
3703 4231 <?php esc_html_e( 'Security Headers', 'vigilante' ); ?>
3704 4232 <span class="vigilante-method-badge htaccess"><?php esc_html_e( 'HTACCESS', 'vigilante' ); ?></span>
3705 4233 </h2>
@@ -3706,11 +4234,11 @@
3706 4234 <p><?php esc_html_e( 'HTTP headers sent with every response via .htaccess (mod_headers).', 'vigilante' ); ?></p>
3707 4235
3708 4236 <table class="form-table">
3709 4237 <tr>
3710 - <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>
3711 4239 <td>
3712 - <select name="security_headers[x_frame_options]">
4240 + <select id="vigilante-f-security-headers-x-frame-options" name="security_headers[x_frame_options]">
3713 4241 <option value="" <?php selected( empty( $options['x_frame_options'] ) ); ?>><?php esc_html_e( 'Disabled', 'vigilante' ); ?></option>
3714 4242 <option value="SAMEORIGIN" <?php selected( $options['x_frame_options'] ?? '', 'SAMEORIGIN' ); ?>>SAMEORIGIN</option>
3715 4243 <option value="DENY" <?php selected( $options['x_frame_options'] ?? '', 'DENY' ); ?>>DENY</option>
3716 4244 </select>
@@ -3726,11 +4254,11 @@
3726 4254 </label>
3727 4255 </td>
3728 4256 </tr>
3729 4257 <tr>
3730 - <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>
3731 4259 <td>
3732 - <select name="security_headers[referrer_policy]">
4260 + <select id="vigilante-f-security-headers-referrer-policy" name="security_headers[referrer_policy]">
3733 4261 <option value="" <?php selected( empty( $options['referrer_policy'] ) ); ?>><?php esc_html_e( 'Disabled', 'vigilante' ); ?></option>
3734 4262 <option value="no-referrer" <?php selected( $options['referrer_policy'] ?? '', 'no-referrer' ); ?>>no-referrer</option>
3735 4263 <option value="strict-origin-when-cross-origin" <?php selected( $options['referrer_policy'] ?? '', 'strict-origin-when-cross-origin' ); ?>>strict-origin-when-cross-origin</option>
3736 4264 <option value="same-origin" <?php selected( $options['referrer_policy'] ?? '', 'same-origin' ); ?>>same-origin</option>
@@ -3738,9 +4266,9 @@
3738 4266 </td>
3739 4267 </tr>
3740 4268 </table>
3741 4269
3742 - <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>
3743 4271 <table class="form-table">
3744 4272 <tr>
3745 4273 <th scope="row"><?php esc_html_e( 'Enable CSP', 'vigilante' ); ?></th>
3746 4274 <td>
@@ -3760,9 +4288,9 @@
3760 4288 </td>
3761 4289 </tr>
3762 4290 </table>
3763 4291
3764 - <h3><?php esc_html_e( 'HTTPS', 'vigilante' ); ?></h3>
4292 + <h3 id="vigilante-section-headers-force-https"><?php esc_html_e( 'HTTPS', 'vigilante' ); ?></h3>
3765 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>
3766 4294 <table class="form-table">
3767 4295 <tr>
3768 4296 <th scope="row"><?php esc_html_e( 'Redirect HTTP to HTTPS', 'vigilante' ); ?></th>
@@ -3778,12 +4306,23 @@
3778 4306 <th scope="row"><?php esc_html_e( 'Fix Mixed Content', 'vigilante' ); ?></th>
3779 4307 <td>
3780 4308 <label>
3781 4309 <input type="checkbox" name="security_headers[fix_mixed_content]" value="1" <?php checked( ! empty( $options['fix_mixed_content'] ) ); ?>>
3782 - <?php esc_html_e( 'Rewrite http:// resources to https:// and ask browsers to upgrade the rest', 'vigilante' ); ?>
4310 + <?php esc_html_e( 'Rewrite this site http:// resources to https://', 'vigilante' ); ?>
3783 4311 </label>
4312 + <p class="description"><?php esc_html_e( 'Off by default. Only touches addresses of this same site, and only when the site is already served over HTTPS, so it cannot break an external resource. Useful right after moving a site to HTTPS, when old content still points at http:// addresses.', 'vigilante' ); ?></p>
3784 4313 </td>
3785 4314 </tr>
4315 + <tr id="field-upgrade-insecure-requests">
4316 + <th scope="row"><?php esc_html_e( 'Upgrade Insecure Requests', 'vigilante' ); ?></th>
4317 + <td>
4318 + <label>
4319 + <input type="checkbox" name="security_headers[upgrade_insecure_requests]" value="1" <?php checked( ! empty( $options['upgrade_insecure_requests'] ) ); ?>>
4320 + <?php esc_html_e( 'Ask browsers to upgrade every http:// request to https://', 'vigilante' ); ?>
4321 + </label>
4322 + <p class="description"><?php esc_html_e( '&#9888; Off by default. This one also covers resources hosted elsewhere: anything served from a domain with no HTTPS stops loading instead of loading insecurely. Turn it on once you know every external resource the site uses is available over HTTPS.', 'vigilante' ); ?></p>
4323 + </td>
4324 + </tr>
3786 4325 <tr>
3787 4326 <th scope="row"><?php esc_html_e( 'Rewrite Site Address on Activation', 'vigilante' ); ?></th>
3788 4327 <td>
3789 4328 <label>
@@ -3794,9 +4333,9 @@
3794 4333 </td>
3795 4334 </tr>
3796 4335 </table>
3797 4336
3798 - <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>
3799 4338 <?php $vig_home_https = ( 0 === strpos( (string) get_option( 'home' ), 'https://' ) ); ?>
3800 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>
3801 4340 <?php if ( ! $vig_home_https ) : ?>
3802 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>
@@ -3816,11 +4355,11 @@
3816 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>
3817 4356 </td>
3818 4357 </tr>
3819 4358 <tr>
3820 - <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>
3821 4360 <td>
3822 - <select name="security_headers[hsts][max_age]">
4361 + <select id="vigilante-f-security-headers-hsts-max-age" name="security_headers[hsts][max_age]">
3823 4362 <option value="86400" <?php selected( $options['hsts']['max_age'] ?? 31536000, 86400 ); ?>><?php esc_html_e( '1 day (testing)', 'vigilante' ); ?></option>
3824 4363 <option value="2592000" <?php selected( $options['hsts']['max_age'] ?? 31536000, 2592000 ); ?>><?php esc_html_e( '30 days', 'vigilante' ); ?></option>
3825 4364 <option value="31536000" <?php selected( $options['hsts']['max_age'] ?? 31536000, 31536000 ); ?>><?php esc_html_e( '1 year (recommended)', 'vigilante' ); ?></option>
3826 4365 <option value="63072000" <?php selected( $options['hsts']['max_age'] ?? 31536000, 63072000 ); ?>><?php esc_html_e( '2 years', 'vigilante' ); ?></option>
@@ -3837,9 +4376,9 @@
3837 4376 </td>
3838 4377 </tr>
3839 4378 </table>
3840 4379
3841 - <h3><?php esc_html_e( 'Server Identity', 'vigilante' ); ?></h3>
4380 + <h3 id="vigilante-section-headers-fingerprint"><?php esc_html_e( 'Server Identity', 'vigilante' ); ?></h3>
3842 4381 <p class="description"><?php esc_html_e( 'Hide identifying information that servers expose in responses.', 'vigilante' ); ?></p>
3843 4382 <table class="form-table">
3844 4383 <tr>
3845 4384 <th scope="row"><?php esc_html_e( 'Server Signature', 'vigilante' ); ?></th>
@@ -3861,9 +4400,57 @@
3861 4400 </tr>
3862 4401 </table>
3863 4402 </div>
3864 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 +
3865 4451 <p class="submit vigilante-submit-buttons">
4452 + <?php if ( ! $vg_shared_locked ) : ?>
3866 4453 <button type="submit" class="button button-primary vigilante-save-btn" data-original-text="<?php esc_attr_e( 'Save Settings', 'vigilante' ); ?>">
3867 4454 <?php esc_html_e( 'Save Settings', 'vigilante' ); ?>
3868 4455 </button>
3869 4456 <button type="button" class="button vigilante-reset-section-btn" data-original-text="<?php esc_attr_e( 'Reset to Defaults', 'vigilante' ); ?>">
@@ -3868,8 +4455,10 @@
3868 4455 </button>
3869 4456 <button type="button" class="button vigilante-reset-section-btn" data-original-text="<?php esc_attr_e( 'Reset to Defaults', 'vigilante' ); ?>">
3870 4457 <?php esc_html_e( 'Reset to Defaults', 'vigilante' ); ?>
3871 4458 </button>
4459 + <?php endif; ?>
4460 + <?php /* Testing what the server actually sends is read-only and useful from any site of a network. */ ?>
3872 4461 <button type="button" class="button vigilante-test-headers">
3873 4462 <?php esc_html_e( 'Test Headers', 'vigilante' ); ?>
3874 4463 </button>
3875 4464 </p>
@@ -3895,11 +4484,11 @@
3895 4484 <p><?php esc_html_e( 'Control access to WordPress REST API endpoints.', 'vigilante' ); ?></p>
3896 4485
3897 4486 <table class="form-table">
3898 4487 <tr>
3899 - <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>
3900 4489 <td>
3901 - <select name="rest_api_security[mode]">
4490 + <select id="vigilante-f-rest-api-security-mode" name="rest_api_security[mode]">
3902 4491 <option value="open" <?php selected( $options['mode'] ?? 'selective', 'open' ); ?>><?php esc_html_e( 'Open - Allow all requests', 'vigilante' ); ?></option>
3903 4492 <option value="selective" <?php selected( $options['mode'] ?? 'selective', 'selective' ); ?>><?php esc_html_e( 'Selective - Protect sensitive endpoints', 'vigilante' ); ?></option>
3904 4493 <option value="authenticated_only" <?php selected( $options['mode'] ?? 'selective', 'authenticated_only' ); ?>><?php esc_html_e( 'Authenticated - Require login for all', 'vigilante' ); ?></option>
3905 4494 </select>
@@ -3978,14 +4567,14 @@
3978 4567 <?php
3979 4568 $pw_policy = wp_parse_args(
3980 4569 ( isset( $options['password_policy'] ) && is_array( $options['password_policy'] ) ) ? $options['password_policy'] : array(),
3981 4570 array(
3982 - 'require_uppercase' => true,
3983 - 'require_lowercase' => true,
3984 - 'require_number' => true,
3985 - 'require_special' => true,
4571 + 'require_uppercase' => false,
4572 + 'require_lowercase' => false,
4573 + 'require_number' => false,
4574 + 'require_special' => false,
3986 4575 'block_common' => true,
3987 - 'block_username' => false,
4576 + 'block_username' => true,
3988 4577 'affected_roles' => array(),
3989 4578 )
3990 4579 );
3991 4580 $pw_policy_roles = (array) $pw_policy['affected_roles'];
@@ -3999,11 +4588,11 @@
3999 4588 </label>
4000 4589 </td>
4001 4590 </tr>
4002 4591 <tr>
4003 - <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>
4004 4593 <td>
4005 - <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">
4006 4595 <?php esc_html_e( 'characters', 'vigilante' ); ?>
4007 4596 </td>
4008 4597 </tr>
4009 4598 <tr>
@@ -4156,11 +4745,11 @@
4156 4745 <p class="description"><?php esc_html_e( 'Disable on high-traffic sites to avoid email overload.', 'vigilante' ); ?></p>
4157 4746 </td>
4158 4747 </tr>
4159 4748 <tr>
4160 - <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>
4161 4750 <td>
4162 - <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">
4163 4752 <?php esc_html_e( 'days (0 = never)', 'vigilante' ); ?>
4164 4753 <p class="description"><?php esc_html_e( 'Automatically reject pending registrations after this many days.', 'vigilante' ); ?></p>
4165 4754 </td>
4166 4755 </tr>
@@ -4185,18 +4774,18 @@
4185 4774 </label>
4186 4775 </td>
4187 4776 </tr>
4188 4777 <tr>
4189 - <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>
4190 4779 <td>
4191 - <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">
4192 4781 <?php esc_html_e( 'sessions per user', 'vigilante' ); ?>
4193 4782 </td>
4194 4783 </tr>
4195 4784 <tr>
4196 - <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>
4197 4786 <td>
4198 - <select name="user_security[session_limits][behavior]">
4787 + <select id="vigilante-f-user-security-session-limits-behavior" name="user_security[session_limits][behavior]">
4199 4788 <option value="block_new" <?php selected( ( $session_limits['behavior'] ?? 'close_oldest' ), 'block_new' ); ?>><?php esc_html_e( 'Block new login', 'vigilante' ); ?></option>
4200 4789 <option value="close_oldest" <?php selected( ( $session_limits['behavior'] ?? 'close_oldest' ), 'close_oldest' ); ?>><?php esc_html_e( 'Close oldest session', 'vigilante' ); ?></option>
4201 4790 </select>
4202 4791 <p class="description"><?php esc_html_e( '"Close oldest" is recommended for security - ensures attackers cannot lock out legitimate users.', 'vigilante' ); ?></p>
@@ -4232,27 +4821,27 @@
4232 4821 </label>
4233 4822 </td>
4234 4823 </tr>
4235 4824 <tr>
4236 - <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>
4237 4826 <td>
4238 - <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">
4239 4828 <?php esc_html_e( 'days', 'vigilante' ); ?>
4240 4829 <p class="description"><?php esc_html_e( 'PCI-DSS recommends 90 days.', 'vigilante' ); ?></p>
4241 4830 </td>
4242 4831 </tr>
4243 4832 <tr>
4244 - <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>
4245 4834 <td>
4246 - <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">
4247 4836 <?php esc_html_e( 'days before expiration', 'vigilante' ); ?>
4248 4837 <p class="description"><?php esc_html_e( 'Show warning notice this many days before password expires.', 'vigilante' ); ?></p>
4249 4838 </td>
4250 4839 </tr>
4251 4840 <tr>
4252 - <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>
4253 4842 <td>
4254 - <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">
4255 4844 <?php esc_html_e( 'passwords to remember', 'vigilante' ); ?>
4256 4845 <p class="description"><?php esc_html_e( 'Prevent reusing recent passwords. Set to 0 to disable.', 'vigilante' ); ?></p>
4257 4846 </td>
4258 4847 </tr>
@@ -4334,11 +4923,11 @@
4334 4923 </label>
4335 4924 </td>
4336 4925 </tr>
4337 4926 <tr>
4338 - <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>
4339 4928 <td>
4340 - <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">
4341 4930 <?php esc_html_e( 'hours', 'vigilante' ); ?>
4342 4931 </td>
4343 4932 </tr>
4344 4933 <tr>
@@ -4350,11 +4939,11 @@
4350 4939 </label>
4351 4940 </td>
4352 4941 </tr>
4353 4942 <tr>
4354 - <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>
4355 4944 <td>
4356 - <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">
4357 4946 <?php esc_html_e( 'days (0 = never)', 'vigilante' ); ?>
4358 4947 <p class="description"><?php esc_html_e( 'Automatically delete users who never verify their email.', 'vigilante' ); ?></p>
4359 4948 </td>
4360 4949 </tr>
@@ -4378,8 +4967,11 @@
4378 4967 <h2 class="vigilante-tools-header">
4379 4968 <?php esc_html_e( 'User security tools', 'vigilante' ); ?>
4380 4969 </h2>
4381 4970
4971 + <?php $this->render_user_actions_notice(); ?>
4972 + <?php if ( ! $this->user_actions_locked() ) : ?>
4973 +
4382 4974 <!-- Force Password Reset -->
4383 4975 <div class="vigilante-tool-box">
4384 4976 <h3><?php esc_html_e( 'Force password reset', 'vigilante' ); ?></h3>
4385 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>
@@ -4515,9 +5107,9 @@
4515 5107 <?php
4516 5108 $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log );
4517 5109 $pending_users = $user_security->get_pending_users();
4518 5110 ?>
4519 - <div class="vigilante-tool-box vigilante-pending-users-section">
5111 + <div id="vigilante-section-users-pending" class="vigilante-tool-box vigilante-pending-users-section">
4520 5112 <h3>
4521 5113 <?php esc_html_e( 'Pending registrations', 'vigilante' ); ?>
4522 5114 <?php if ( count( $pending_users ) > 0 ) : ?>
4523 5115 <span class="vigilante-badge vigilante-badge-warning"><?php echo esc_html( count( $pending_users ) ); ?></span>
@@ -4534,8 +5126,9 @@
4534 5126 <span class="dashicons dashicons-yes-alt"></span>
4535 5127 <p><?php esc_html_e( 'No pending registrations.', 'vigilante' ); ?></p>
4536 5128 </div>
4537 5129 <?php else : ?>
5130 + <?php $this->render_user_actions_notice(); ?>
4538 5131 <table class="wp-list-table widefat fixed striped vigilante-pending-users-table">
4539 5132 <thead>
4540 5133 <tr>
4541 5134 <th><?php esc_html_e( 'User', 'vigilante' ); ?></th>
@@ -4564,12 +5157,12 @@
4564 5157 }
4565 5158 ?>
4566 5159 </td>
4567 5160 <td>
4568 - <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() ); ?>>
4569 5162 <?php esc_html_e( 'Approve', 'vigilante' ); ?>
4570 5163 </button>
4571 - <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() ); ?>>
4572 5165 <?php esc_html_e( 'Reject', 'vigilante' ); ?>
4573 5166 </button>
4574 5167 </td>
4575 5168 </tr>
@@ -4691,8 +5284,10 @@
4691 5284 </button>
4692 5285 </p>
4693 5286 </div>
4694 5287 </div>
5288 +
5289 + <?php endif; ?>
4695 5290 </div>
4696 5291 <?php
4697 5292 }
4698 5293
@@ -4704,9 +5299,11 @@
4704 5299 $options = $this->settings->get_section( 'wp_hardening' );
4705 5300 ?>
4706 5301 <form class="vigilante-settings-form <?php echo $is_disabled ? 'vigilante-form-disabled' : ''; ?>" data-section="wp_hardening" <?php echo $is_disabled ? 'inert' : ''; ?>>
4707 5302 <!-- Database Hardening (outside form save flow - uses its own AJAX action) -->
4708 - <div id="vigilante-section-hardening-database" class="vigilante-settings-section">
5303 + <?php $vg_shared_locked = $this->shared_files_locked(); ?>
5304 + <?php $this->render_shared_files_notice(); ?>
5305 + <div id="vigilante-section-hardening-database" class="vigilante-settings-section <?php echo $vg_shared_locked ? 'vigilante-form-disabled' : ''; ?>" <?php echo $vg_shared_locked ? 'inert' : ''; ?>>
4709 5306 <h2>
4710 5307 <?php esc_html_e( 'Database Hardening', 'vigilante' ); ?>
4711 5308 <span class="vigilante-method-badge database"><?php esc_html_e( 'Database', 'vigilante' ); ?></span>
4712 5309 <span class="vigilante-method-badge config"><?php esc_html_e( 'WP-CONFIG', 'vigilante' ); ?></span>
@@ -4718,8 +5315,14 @@
4718 5315 $current_prefix = $db_prefix->get_current_prefix();
4719 5316 $is_default = $db_prefix->is_default_prefix();
4720 5317 ?>
4721 5318
5319 + <?php if ( is_multisite() && ! $vg_shared_locked ) : ?>
5320 + <div class="notice notice-warning inline" style="margin:10px 0 16px;padding:8px 12px;">
5321 + <p style="margin:0;"><?php esc_html_e( 'Network-wide operation: it renames the tables of every site in the network and rewrites the wp-config.php they all share. Back up the whole database first, not just the main site.', 'vigilante' ); ?></p>
5322 + </div>
5323 + <?php endif; ?>
5324 +
4722 5325 <table class="form-table">
4723 5326 <tr>
4724 5327 <th scope="row"><?php esc_html_e( 'Current prefix', 'vigilante' ); ?></th>
4725 5328 <td>
@@ -4774,9 +5377,16 @@
4774 5377 </table>
4775 5378 </div>
4776 5379
4777 5380 <!-- wp-config Security -->
4778 - <div id="vigilante-section-hardening-wpconfig" class="vigilante-settings-section">
5381 + <?php
5382 + $vg_shared_locked = $this->shared_files_locked();
5383 + // Paint what is actually in force, not this site's unused copy.
5384 + $vg_local_options = $options;
5385 + $options = $this->get_section_for_display( 'wp_hardening' );
5386 + ?>
5387 + <?php $this->render_shared_files_notice(); ?>
5388 + <div id="vigilante-section-hardening-wpconfig" class="vigilante-settings-section <?php echo $vg_shared_locked ? 'vigilante-form-disabled' : ''; ?>" <?php echo $vg_shared_locked ? 'inert' : ''; ?>>
4779 5389 <h2>
4780 5390 <?php esc_html_e( 'wp-config.php Security', 'vigilante' ); ?>
4781 5391 <span class="vigilante-method-badge config"><?php esc_html_e( 'WP-CONFIG', 'vigilante' ); ?></span>
4782 5392 </h2>
@@ -4841,8 +5451,9 @@
4841 5451 </td>
4842 5452 </tr>
4843 5453 </table>
4844 5454 </div>
5455 + <?php $options = $vg_local_options; ?>
4845 5456
4846 5457 <!-- Comment Security -->
4847 5458 <div id="vigilante-section-hardening-xmlrpc" class="vigilante-settings-section">
4848 5459 <h2>
@@ -4852,12 +5463,12 @@
4852 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>
4853 5464
4854 5465 <table class="form-table">
4855 5466 <tr id="field-disable-xmlrpc">
4856 - <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>
4857 5468 <td>
4858 5469 <?php $vig_xmlrpc_mode = Vigilante_Comment_Security::resolve_xmlrpc_mode( $this->settings ); ?>
4859 - <select name="wp_hardening[xmlrpc_mode]">
5470 + <select id="vigilante-f-wp-hardening-xmlrpc-mode" name="wp_hardening[xmlrpc_mode]">
4860 5471 <option value="none" <?php selected( $vig_xmlrpc_mode, 'none' ); ?>>
4861 5472 <?php esc_html_e( 'Leave XML-RPC enabled', 'vigilante' ); ?>
4862 5473 </option>
4863 5474 <option value="pingback" <?php selected( $vig_xmlrpc_mode, 'pingback' ); ?>>
@@ -4915,10 +5526,10 @@
4915 5526 <label>
4916 5527 <input type="checkbox" name="wp_hardening[close_old_comments]" value="1" <?php checked( ! empty( $options['close_old_comments'] ) ); ?>>
4917 5528 <?php esc_html_e( 'Automatically close comments on old posts after', 'vigilante' ); ?>
4918 5529 </label>
4919 - <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">
4920 - <?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>
4921 5532 </td>
4922 5533 </tr>
4923 5534 <tr>
4924 5535 <th scope="row"><?php esc_html_e( 'Honeypot Protection', 'vigilante' ); ?></th>
@@ -5070,13 +5681,13 @@
5070 5681 <table class="form-table">
5071 5682 <tr>
5072 5683 <th scope="row"><?php esc_html_e( 'Retention', 'vigilante' ); ?></th>
5073 5684 <td>
5074 - <input type="number" name="activity_log[retention_days]" value="<?php echo esc_attr( $options['retention_days'] ?? 30 ); ?>" min="7" max="365" class="small-text">
5075 - <?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>
5076 5687 &nbsp;&nbsp;
5077 - <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">
5078 - <?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>
5079 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>
5080 5691 </td>
5081 5692 </tr>
5082 5693 <tr>
@@ -5101,14 +5712,14 @@
5101 5712 </div>
5102 5713 </td>
5103 5714 </tr>
5104 5715 <tr>
5105 - <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>
5106 5717 <td>
5107 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>
5108 5719 <br>
5109 5720 <label><?php esc_html_e( 'Additional options to track:', 'vigilante' ); ?></label><br>
5110 - <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>
5111 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>
5112 5723 </td>
5113 5724 </tr>
5114 5725 <tr>
@@ -5115,15 +5726,15 @@
5115 5726 <th scope="row"><?php esc_html_e( 'Exclusions', 'vigilante' ); ?></th>
5116 5727 <td>
5117 5728 <div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:16px; max-width:600px;">
5118 5729 <div>
5119 - <label><?php esc_html_e( 'Excluded user IDs:', 'vigilante' ); ?></label><br>
5120 - <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>
5121 5732 <p class="description"><?php esc_html_e( 'One user ID per line. Actions by these users will not be logged.', 'vigilante' ); ?></p>
5122 5733 </div>
5123 5734 <div>
5124 - <label><?php esc_html_e( 'Excluded IPs:', 'vigilante' ); ?></label><br>
5125 - <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>
5126 5737 <p class="description"><?php esc_html_e( 'One IP per line. Requests from these IPs will not be logged.', 'vigilante' ); ?></p>
5127 5738 </div>
5128 5739 </div>
5129 5740 </td>
@@ -5172,11 +5783,11 @@
5172 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>
5173 5784 </td>
5174 5785 </tr>
5175 5786 <tr>
5176 - <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>
5177 5788 <td>
5178 - <select name="audit_alerts[immediate][min_severity]">
5789 + <select id="vigilante-f-audit-alerts-immediate-min-severity" name="audit_alerts[immediate][min_severity]">
5179 5790 <option value="critical" <?php selected( $alert_severity, 'critical' ); ?>><?php esc_html_e( 'Critical only (recommended)', 'vigilante' ); ?></option>
5180 5791 <option value="warning" <?php selected( $alert_severity, 'warning' ); ?>><?php esc_html_e( 'Warning and Critical', 'vigilante' ); ?></option>
5181 5792 </select>
5182 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>
@@ -5192,11 +5803,11 @@
5192 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>
5193 5804 </td>
5194 5805 </tr>
5195 5806 <tr>
5196 - <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>
5197 5808 <td>
5198 - <select name="audit_alerts[threshold][window]">
5809 + <select id="vigilante-f-audit-alerts-threshold-window" name="audit_alerts[threshold][window]">
5199 5810 <option value="30m" <?php selected( $alert_window, '30m' ); ?>><?php esc_html_e( '30 minutes', 'vigilante' ); ?></option>
5200 5811 <option value="1h" <?php selected( $alert_window, '1h' ); ?>><?php esc_html_e( '1 hour', 'vigilante' ); ?></option>
5201 5812 <option value="6h" <?php selected( $alert_window, '6h' ); ?>><?php esc_html_e( '6 hours', 'vigilante' ); ?></option>
5202 5813 <option value="24h" <?php selected( $alert_window, '24h' ); ?>><?php esc_html_e( '24 hours', 'vigilante' ); ?></option>
@@ -5223,10 +5834,10 @@
5223 5834 </tr>
5224 5835 <tr>
5225 5836 <th scope="row"><?php esc_html_e( "Don't repeat alerts", 'vigilante' ); ?></th>
5226 5837 <td>
5227 - <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">
5228 - <?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>
5229 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>
5230 5841 </td>
5231 5842 </tr>
5232 5843
@@ -5299,10 +5910,10 @@
5299 5910 $ua_blacklist = $firewall_options['ua_blacklist'] ?? array();
5300 5911 ?>
5301 5912
5302 5913 <div class="vigilante-log-filters">
5303 - <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">
5304 - <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' ); ?>">
5305 5916 <option value=""><?php esc_html_e( 'All Types', 'vigilante' ); ?></option>
5306 5917 <option value="login"><?php esc_html_e( 'Login', 'vigilante' ); ?></option>
5307 5918 <option value="user"><?php esc_html_e( 'User', 'vigilante' ); ?></option>
5308 5919 <option value="content"><?php esc_html_e( 'Content', 'vigilante' ); ?></option>
@@ -5315,15 +5926,15 @@
5315 5926 <option value="file"><?php esc_html_e( 'File', 'vigilante' ); ?></option>
5316 5927 <option value="security"><?php esc_html_e( 'Security', 'vigilante' ); ?></option>
5317 5928 <option value="system"><?php esc_html_e( 'System', 'vigilante' ); ?></option>
5318 5929 </select>
5319 - <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' ); ?>">
5320 5931 <option value=""><?php esc_html_e( 'All Severities', 'vigilante' ); ?></option>
5321 5932 <option value="info"><?php esc_html_e( 'Info', 'vigilante' ); ?></option>
5322 5933 <option value="warning"><?php esc_html_e( 'Warning', 'vigilante' ); ?></option>
5323 5934 <option value="critical"><?php esc_html_e( 'Critical', 'vigilante' ); ?></option>
5324 5935 </select>
5325 - <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' ); ?>">
5326 5937 <option value=""><?php esc_html_e( 'All Methods', 'vigilante' ); ?></option>
5327 5938 <option value="GET">GET</option>
5328 5939 <option value="POST">POST</option>
5329 5940 <option value="PUT">PUT</option>
@@ -5390,8 +6001,9 @@
5390 6001 'user' => (string) ( $log->user_login ?? '' ),
5391 6002 'ip' => $ip_val,
5392 6003 'user_agent' => $ua_val,
5393 6004 'request_method' => (string) $request_method,
6005 + 'request_uri' => Vigilante_Activity_Log::extract_request_uri( $log->extra_data ?? '' ),
5394 6006 'date' => (string) ( $log->created_at ?? '' ),
5395 6007 'severity' => (string) ( $log->severity ?? 'info' ),
5396 6008 'is_ip_whitelisted' => ( '' !== $ip_val && in_array( $ip_val, $ip_whitelist, true ) ),
5397 6009 'is_ip_blacklisted' => ( '' !== $ip_val && in_array( $ip_val, $ip_blacklist, true ) ),
@@ -5444,8 +6056,13 @@
5444 6056 */
5445 6057 private function render_tab_file_integrity() {
5446 6058 $is_disabled = $this->render_module_disabled_notice( 'file_integrity' );
5447 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();
5448 6065 $last_scan = get_option( 'vigilante_last_integrity_scan' );
5449 6066 $last_results = get_option( 'vigilante_last_integrity_results' );
5450 6067 $ignored_files = get_option( 'vigilante_ignored_files', array() );
5451 6068
@@ -5498,11 +6115,11 @@
5498 6115 </label>
5499 6116 </td>
5500 6117 </tr>
5501 6118 <tr>
5502 - <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>
5503 6120 <td>
5504 - <select name="file_integrity[scan_frequency]">
6121 + <select id="vigilante-f-file-integrity-scan-frequency" name="file_integrity[scan_frequency]">
5505 6122 <option value="daily" <?php selected( $options['scan_frequency'] ?? 'daily', 'daily' ); ?>><?php esc_html_e( 'Daily', 'vigilante' ); ?></option>
5506 6123 <option value="weekly" <?php selected( $options['scan_frequency'] ?? 'daily', 'weekly' ); ?>><?php esc_html_e( 'Weekly', 'vigilante' ); ?></option>
5507 6124 </select>
5508 6125 </td>
@@ -5507,11 +6124,11 @@
5507 6124 </select>
5508 6125 </td>
5509 6126 </tr>
5510 6127 <tr>
5511 - <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>
5512 6129 <td>
5513 - <select name="file_integrity[notify_level]">
6130 + <select id="vigilante-f-file-integrity-notify-level" name="file_integrity[notify_level]">
5514 6131 <option value="all" <?php selected( $notify_level, 'all' ); ?>><?php esc_html_e( 'All issues (modified + suspicious)', 'vigilante' ); ?></option>
5515 6132 <option value="suspicious_only" <?php selected( $notify_level, 'suspicious_only' ); ?>><?php esc_html_e( 'Suspicious files only', 'vigilante' ); ?></option>
5516 6133 <option value="disabled" <?php selected( $notify_level, 'disabled' ); ?>><?php esc_html_e( 'Disabled', 'vigilante' ); ?></option>
5517 6134 </select>
@@ -5571,10 +6188,13 @@
5571 6188 <?php esc_html_e( 'Uploads directory (detect PHP files, double extensions, .htaccess)', 'vigilante' ); ?>
5572 6189 </label>
5573 6190 <br>
5574 6191 <label>
5575 - <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 ); ?>>
5576 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; ?>
5577 6197 </label>
5578 6198 <br>
5579 6199 <label>
5580 6200 <input type="checkbox" name="file_integrity[check_closed_plugins]" value="1" <?php checked( $options['check_closed_plugins'] ?? true ); ?>>
@@ -5583,19 +6203,30 @@
5583 6203 </fieldset>
5584 6204 </td>
5585 6205 </tr>
5586 6206 <tr>
5587 - <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>
5588 6208 <td>
5589 - <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>
5590 - <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>
5591 6211 </td>
5592 6212 </tr>
5593 6213 <tr>
5594 - <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>
5595 6215 <td>
5596 - <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>
5597 - <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>
5598 6229 </td>
5599 6230 </tr>
5600 6231 </table>
5601 6232 </div>
@@ -5842,9 +6473,15 @@
5842 6473 $crit_diff = $crit_item['diff'] ?? array();
5843 6474 $crit_id = sanitize_html_class( $crit_file );
5844 6475 $added_count = is_array( $crit_diff ) ? count( $crit_diff['added'] ?? array() ) : 0;
5845 6476 $removed_count = is_array( $crit_diff ) ? count( $crit_diff['removed'] ?? array() ) : 0;
5846 - $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'] ) );
5847 6484 ?>
5848 6485 <tr>
5849 6486 <td><code style="color: #e36210;"><?php echo esc_html( $crit_file ); ?></code></td>
5850 6487 <td>
@@ -5867,18 +6504,36 @@
5867 6504 <td>
5868 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' ); ?>">
5869 6506 <?php esc_html_e( 'Review changes', 'vigilante' ); ?>
5870 6507 </button>
5871 - <button type="button" class="button button-small button-primary vigilante-approve-critical-file" data-file="<?php echo esc_attr( $crit_file ); ?>">
5872 - <?php esc_html_e( 'Approve', 'vigilante' ); ?>
5873 - </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; ?>
5874 6517 </td>
5875 6518 </tr>
5876 6519 <tr id="vigilante-critical-content-<?php echo esc_attr( $crit_id ); ?>" class="vigilante-critical-content-row" style="display:none;">
5877 6520 <td colspan="3" style="padding: 0;">
5878 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;">
5879 - <?php if ( $diff_unavailable ) : ?>
6522 + <?php if ( $diff_network ) : ?>
5880 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;">
5881 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' ); ?>
5882 6537 </p>
5883 6538 <?php elseif ( empty( $crit_diff['added'] ) && empty( $crit_diff['removed'] ) ) : ?>
5884 6539 <p style="color: #50575e; font-style: italic; margin: 0;">
@@ -5906,9 +6561,9 @@
5906 6561 <?php endif; ?>
5907 6562
5908 6563 <?php if ( $has_closed ) : ?>
5909 6564 <div class="vigilante-file-list vigilante-closed-plugins">
5910 - <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>
5911 6566 <p class="description" style="color: #d63638;">
5912 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' ); ?>
5913 6568 </p>
5914 6569 <table class="wp-list-table widefat striped">
@@ -6117,8 +6772,15 @@
6117 6772 if ( ! current_user_can( 'manage_options' ) ) {
6118 6773 wp_die( esc_html__( 'Permission denied.', 'vigilante' ), 403 );
6119 6774 }
6120 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 +
6121 6783 $backup_manager = new Vigilante_Backup_Manager();
6122 6784 $result = $backup_manager->stream_files_zip();
6123 6785
6124 6786 // stream_files_zip() exits on success; only a WP_Error returns here.
@@ -6207,8 +6869,29 @@
6207 6869
6208 6870 // Read ONLY saved options from database (not merged with defaults)
6209 6871 $saved_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
6210 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 +
6211 6894 // Handle modules
6212 6895 if ( 'modules' === $section && isset( $data['modules'] ) ) {
6213 6896 if ( ! isset( $saved_options['modules'] ) ) {
6214 6897 $saved_options['modules'] = array();
@@ -6226,9 +6909,16 @@
6226 6909 $current_section = isset( $saved_options[ $section ] ) ? $saved_options[ $section ] : array();
6227 6910
6228 6911 // Process the submitted data
6229 6912 $processed = $this->process_section_data( $data[ $section ], $section_defaults, $current_section );
6230 -
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 +
6231 6921 // Save the processed section
6232 6922 $saved_options[ $section ] = $processed;
6233 6923
6234 6924 // Clear active preset when any section settings change
@@ -6238,8 +6928,10 @@
6238 6928
6239 6929 // Clear cache before saving
6240 6930 wp_cache_delete( Vigilante_Settings::OPTION_NAME, 'options' );
6241 6931
6932 + $saved_options = Vigilante_Settings::keep_locked_file_settings( $saved_options, $stored_options );
6933 +
6242 6934 // Save to database
6243 6935 update_option( Vigilante_Settings::OPTION_NAME, $saved_options );
6244 6936
6245 6937 // Clear the settings cache
@@ -6288,12 +6980,59 @@
6288 6980 $login_url_result['sent']
6289 6981 );
6290 6982 }
6291 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 +
6292 6997 wp_send_json_success( $message );
6293 6998 }
6294 -
6999 +
6295 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 + /**
6296 7035 * Send 2FA enable notifications to users
6297 7036 *
6298 7037 * @return array Result with 'sent' and 'failed' counts.
6299 7038 */
@@ -6615,13 +7354,27 @@
6615 7354
6616 7355 // Sanitize imported data recursively
6617 7356 $imported = map_deep( $imported, 'sanitize_text_field' );
6618 7357
6619 - // Validate structure
6620 - $defaults = $this->settings->get_default_options();
6621 - $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;
6622 7368
7369 + foreach ( $validated as $section => $data ) {
7370 + if ( is_array( $data ) ) {
7371 + $merged[ $section ] = $data;
7372 + }
7373 + }
7374 +
6623 7375 // Save
7376 + $merged = Vigilante_Settings::keep_locked_file_settings( $merged, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
6624 7377 update_option( Vigilante_Settings::OPTION_NAME, $merged );
6625 7378 $this->settings->clear_cache();
6626 7379
6627 7380 // Re-evaluate the active preset marker. The imported config may match
@@ -6644,9 +7397,9 @@
6644 7397 if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
6645 7398 wp_schedule_single_event( time() + 5, 'vigilante_under_attack_post_scan' );
6646 7399 }
6647 7400
6648 - wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) );
7401 + wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
6649 7402 }
6650 7403
6651 7404 /**
6652 7405 * Detect whether a vigilante_options array matches a known preset.
@@ -6749,9 +7502,11 @@
6749 7502 $preset = isset( $_POST['preset'] ) ? sanitize_key( $_POST['preset'] ) : '';
6750 7503
6751 7504 // Handle reset to defaults
6752 7505 if ( 'reset' === $preset ) {
6753 - $defaults = $this->settings->get_default_options();
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 );
6754 7509 update_option( Vigilante_Settings::OPTION_NAME, $defaults );
6755 7510 $this->settings->clear_cache();
6756 7511
6757 7512 // Clear active preset
@@ -6759,9 +7514,9 @@
6759 7514
6760 7515 // Apply file changes after reset
6761 7516 $this->apply_all_file_changes( $defaults );
6762 7517
6763 - wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) );
7518 + wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) . $this->locked_file_settings_message() );
6764 7519 return;
6765 7520 }
6766 7521
6767 7522 $presets = $this->settings->get_presets();
@@ -6782,13 +7537,14 @@
6782 7537 $current = get_option( Vigilante_Settings::OPTION_NAME, array() );
6783 7538 if ( ! is_array( $current ) ) {
6784 7539 $current = array();
6785 7540 }
6786 - // Make sure all known keys exist before merging — array_replace_recursive
6787 - // does not invent keys that are missing on both sides.
6788 - $current = array_replace_recursive( $this->settings->get_default_options(), $current );
7541 + // Make sure all known keys exist before merging — the merge does not
7542 + // invent keys that are missing on both sides.
7543 + $current = Vigilante_Settings::merge_preset( $this->settings->get_default_options(), $current );
6789 7544
6790 - $merged = array_replace_recursive( $current, $preset_options );
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() ) );
6791 7547
6792 7548 update_option( Vigilante_Settings::OPTION_NAME, $merged );
6793 7549 $this->settings->clear_cache();
6794 7550
@@ -6797,9 +7553,9 @@
6797 7553
6798 7554 // Apply file changes after preset
6799 7555 $this->apply_all_file_changes( $merged );
6800 7556
6801 - wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) );
7557 + wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
6802 7558 }
6803 7559
6804 7560 /**
6805 7561 * AJAX: Reset a specific section to defaults
@@ -6816,11 +7572,13 @@
6816 7572 if ( empty( $section ) ) {
6817 7573 wp_send_json_error( __( 'No section specified.', 'vigilante' ) );
6818 7574 }
6819 7575
6820 - // Get current options and defaults
7576 + // Get current options and defaults. get_defaults_preserving_user_data()
7577 + // applies the tweaks a fresh installation gets, so the button and a new
7578 + // install agree, and keeps whatever the owner typed in.
6821 7579 $current_options = $this->settings->get_all_options();
6822 - $defaults = $this->settings->get_default_options();
7580 + $defaults = Vigilante_Settings::get_defaults_preserving_user_data( $current_options );
6823 7581
6824 7582 // Check if section exists in defaults
6825 7583 if ( ! isset( $defaults[ $section ] ) ) {
6826 7584 wp_send_json_error( __( 'Invalid section.', 'vigilante' ) );
@@ -6825,11 +7583,27 @@
6825 7583 if ( ! isset( $defaults[ $section ] ) ) {
6826 7584 wp_send_json_error( __( 'Invalid section.', 'vigilante' ) );
6827 7585 }
6828 7586
6829 - // Reset only this section to defaults
6830 - $current_options[ $section ] = $defaults[ $section ];
7587 + $new_values = $defaults[ $section ];
6831 7588
7589 + /*
7590 + * On a subsite, the settings written to wp-config.php and .htaccess are
7591 + * the main site's business. Resetting the local copy of those would only
7592 + * make this screen disagree with the file, so they are carried over
7593 + * untouched, and a section that is nothing but shared settings is not
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).
7596 + */
7597 + $locked = Vigilante_Settings::get_locked_file_settings();
7598 +
7599 + if ( isset( $locked[ $section ] ) && true === $locked[ $section ] ) {
7600 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7601 + }
7602 +
7603 + $current_options[ $section ] = $new_values;
7604 + $current_options = Vigilante_Settings::keep_locked_file_settings( $current_options, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7605 +
6832 7606 // Save
6833 7607 update_option( Vigilante_Settings::OPTION_NAME, $current_options );
6834 7608 $this->settings->clear_cache();
6835 7609
@@ -6911,8 +7685,19 @@
6911 7685 // Save new results
6912 7686 update_option( 'vigilante_last_integrity_scan', time() );
6913 7687 update_option( 'vigilante_last_integrity_results', $results );
6914 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 +
6915 7700 wp_send_json_success( array(
6916 7701 'message' => __( 'Scan completed.', 'vigilante' ),
6917 7702 'results' => $results,
6918 7703 'ignored_count' => count( get_option( 'vigilante_ignored_files', array() ) ),
@@ -6946,11 +7731,41 @@
6946 7731 if ( ! current_user_can( 'manage_options' ) ) {
6947 7732 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
6948 7733 }
6949 7734
7735 + $results = get_option( 'vigilante_last_integrity_results' );
7736 + $scanned_at = get_option( 'vigilante_last_integrity_scan' );
7737 +
6950 7738 delete_option( 'vigilante_last_integrity_results' );
6951 7739 delete_option( 'vigilante_last_integrity_scan' );
6952 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 +
6953 7768 if ( $this->database ) {
6954 7769 $this->database->clear_file_hashes();
6955 7770 }
6956 7771
@@ -6976,8 +7791,14 @@
6976 7791 if ( empty( $file ) ) {
6977 7792 wp_send_json_error( __( 'No file specified.', 'vigilante' ) );
6978 7793 }
6979 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 +
6980 7801 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database );
6981 7802 $file_integrity->ignore_file( $file );
6982 7803
6983 7804 // Also remove the file from stored scan results so UI updates
@@ -7041,12 +7862,14 @@
7041 7862 if ( ! is_array( $raw_files ) ) {
7042 7863 wp_send_json_error( __( 'Invalid request.', 'vigilante' ) );
7043 7864 }
7044 7865
7045 - $files = array();
7866 + $files = array();
7867 + $shared = $this->critical_approval_locked() ? array( 'wp-config.php', '.htaccess' ) : array();
7046 7868 foreach ( $raw_files as $f ) {
7047 7869 $clean = sanitize_text_field( $f );
7048 - if ( '' !== $clean ) {
7870 + // Same rule as ajax_ignore_file() for the two shared files.
7871 + if ( '' !== $clean && ! in_array( $clean, $shared, true ) ) {
7049 7872 $files[] = $clean;
7050 7873 }
7051 7874 }
7052 7875