PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.0
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.0
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 | includes/class-file-integrity.php +534 -26 2.11.83.0.0 View file →
@@ -205,8 +205,13 @@
205 205 'WP_ENVIRONMENT_TYPE', 'WP_DEVELOPMENT_MODE', 'WP_AUTO_UPDATE_CORE', 'FS_METHOD',
206 206 'DB_CHARSET', 'DB_COLLATE', 'DOMAIN_CURRENT_SITE', 'PATH_CURRENT_SITE', 'NOBLOGREDIRECT',
207 207 'COOKIE_DOMAIN', 'COOKIEPATH', 'SITECOOKIEPATH', 'ADMIN_COOKIE_PATH', 'PLUGINS_COOKIE_PATH',
208 208 'WP_DEFAULT_THEME', 'WPLANG',
209 + // Numeric core settings. Since 2.11.10 a number in the value of a
210 + // define() is redacted like any other value, so the ones that are known
211 + // not to be credentials are listed here to keep the diff useful.
212 + 'AUTOSAVE_INTERVAL', 'WP_POST_REVISIONS', 'EMPTY_TRASH_DAYS', 'WP_CRON_LOCK_TIMEOUT',
213 + 'FS_CHMOD_DIR', 'FS_CHMOD_FILE', 'SITE_ID_CURRENT_SITE', 'BLOG_ID_CURRENT_SITE',
209 214 );
210 215
211 216 /**
212 217 * Read the critical files baseline, from where it belongs
@@ -384,9 +389,12 @@
384 389 if ( 'wp-config.php' !== $filename ) {
385 390 return $normalized;
386 391 }
387 392
388 - $redacted = $this->redact_secrets( $normalized );
393 + // Calculados una sola vez: los usa la redaccion (para no conservar un
394 + // numero que ademas este en vigor) y el control de salida de abajo.
395 + $live_values = $this->values_in_force( $normalized );
396 + $redacted = $this->redact_secrets( $normalized, $live_values );
389 397
390 398 /*
391 399 * Belt and braces, and this is the part that matters: the redaction
392 400 * above is the thing most likely to miss a shape nobody thought of,
@@ -418,9 +426,9 @@
418 426 }
419 427
420 428 $shown = $this->readable_values_in_force();
421 429
422 - foreach ( $this->values_in_force( $normalized ) as $value ) {
430 + foreach ( $live_values as $value ) {
423 431 foreach ( $shown as $readable ) {
424 432 if ( false !== strpos( $readable, $value ) ) {
425 433 continue 2;
426 434 }
@@ -463,9 +471,9 @@
463 471 *
464 472 * @param string $content Normalized wp-config.php content.
465 473 * @return string Redacted content, or '' when it cannot be read as tokens.
466 474 */
467 - private function redact_secrets( $content ) {
475 + private function redact_secrets( $content, $live_values = array() ) {
468 476 if ( ! function_exists( 'token_get_all' ) ) {
469 477 return '';
470 478 }
471 479
@@ -524,8 +532,75 @@
524 532 $out .= ( '' === trim( $text ) ) ? $text : $marker;
525 533 continue;
526 534 }
527 535
536 + /*
537 + * A value that is not a quoted string is still a value. Until
538 + * 2.11.10 only string tokens were looked at, so
539 + * define( 'SERVICE_TOKEN', 12345678 ) put the live token in the copy
540 + * kept in the database. Reported by the wp.org automated review of
541 + * 2.11.9.
542 + *
543 + * The first fix here redacted a number only in the value position of
544 + * a define(), which is the shape that was reported and not the shape
545 + * of the problem. The second cross review of 2.11.10 measured nine
546 + * more: a negative number, one in parentheses, one inside
547 + * array( ... ), one in a ternary, a const, and four that are not
548 + * constants at all and so the output check cannot catch either, the
549 + * worst of them the documented way of configuring Redis,
550 + * $redis_server = array( 'auth' => 12345678 ). So a number is now
551 + * treated like a string: redacted unless the place it sits in is one
552 + * of the few that cannot hold a credential, which is how the rest of
553 + * this function has been written since 2.11.8 (a list of what may be
554 + * shown, never a list of what is secret).
555 + *
556 + * Losing a number from the diff costs little and buys the same trade
557 + * as everywhere else: the hash still covers the whole file, so a
558 + * change is detected even where the diff can no longer show it. The
559 + * numeric core settings are in $readable_constants so the diff of a
560 + * normal wp-config.php keeps saying what it used to.
561 + */
562 + if ( T_LNUMBER === $type || T_DNUMBER === $type ) {
563 + $nprev = self::significant_token( $tokens, $i, -1 );
564 + $nnext = self::significant_token( $tokens, $i, 1 );
565 + $nptype = ( null === $nprev ) ? null : $tokens[ $nprev ][0];
566 + $nntype = ( null === $nnext ) ? null : $tokens[ $nnext ][0];
567 + $nbefore = ( '[' === $nptype ) ? self::significant_token( $tokens, $nprev, -1 ) : null;
568 + $nbtoken = ( null === $nbefore ) ? array( null, null ) : $tokens[ $nbefore ];
569 + $nvalue = ( -1 !== $define_at && $depth === $define_at && ',' === $nptype );
570 +
571 + $nkeep = $keep_until >= 0
572 + || T_DOUBLE_ARROW === $nntype
573 + || ( '[' === $nptype && ']' === $nntype && in_array( $nbtoken[0], array( T_VARIABLE, T_STRING, ']', ')', '}' ), true ) )
574 + || ( strlen( $text ) <= 1 && ! $nvalue );
575 +
576 + /*
577 + * Except when that same number is a value actually in force. The
578 + * positions kept above are kept because a credential does not live
579 + * in them, which is true, but it says nothing about the number
580 + * itself: with
581 + * define( 'SERVICE_TOKEN', 12345678 );
582 + * $a = $config[12345678];
583 + * the value was redacted in the define and kept in the index, so it
584 + * survived, and the output check below did what it is there for and
585 + * threw the whole copy away. No leak, but the diff of that
586 + * wp-config.php was lost for good, which is the regression 2.11.8
587 + * fixed, coming back through the numbers added in 2.11.10. Found by
588 + * the third cross review.
589 + *
590 + * Strings are deliberately NOT treated this way: there, a value in
591 + * force sitting in a kept position (an include path, an array key)
592 + * can BE the secret, and losing the diff is the right answer. It is
593 + * what poc/wpconfig-baseline-secretos.sh checks and it stays.
594 + */
595 + if ( $nkeep && in_array( $text, $live_values, true ) ) {
596 + $nkeep = false;
597 + }
598 +
599 + $out .= $nkeep ? $text : $marker;
600 + continue;
601 + }
602 +
528 603 if ( 'string' !== $type ) {
529 604 $out .= $text;
530 605 continue;
531 606 }
@@ -749,13 +824,15 @@
749 824 *
750 825 * Every user constant whose name appears in the file, the twelve of
751 826 * WordPress wherever they were defined, and the environment variables the
752 827 * file reads or sets. Arrays are walked to their leaves, since define()
753 - * takes arrays. Only strings: numbers are never redacted and are not
754 - * secrets, and the first version of this counted them and wiped the diff
755 - * of any file with a large number in force (cross review of 2.11.8). The
756 - * readable constants are left out, and so is anything shorter than eight
757 - * characters.
828 + * takes arrays. Strings and numbers both: the first version of this counted
829 + * numbers and wiped the diff of any file with a large number in force (cross
830 + * review of 2.11.8), so they were dropped, and 2.11.10 had to bring them
831 + * back because a credential written as a number, which the wp.org review of
832 + * 2.11.9 reported, is exactly what this check has to be able to see. The
833 + * eight character floor is what keeps the old problem away. The readable
834 + * constants are left out, and so is anything shorter than that.
758 835 *
759 836 * @since 2.11.8
760 837 *
761 838 * @param string $content Normalized wp-config.php content.
@@ -816,9 +893,20 @@
816 893
817 894 return $leaves;
818 895 }
819 896
820 - return is_string( $value ) ? array( $value ) : array();
897 + if ( is_string( $value ) ) {
898 + return array( $value );
899 + }
900 +
901 + /*
902 + * A number is a value too. Until 2.11.10 this returned nothing for one,
903 + * so the output check had no way to see a credential written as
904 + * define( 'SERVICE_TOKEN', 12345678 ) and the redaction was left without
905 + * its safety net there. Booleans and null stay out on purpose: as text
906 + * they are '1' and '', which would match half the file.
907 + */
908 + return ( is_int( $value ) || is_float( $value ) ) ? array( (string) $value ) : array();
821 909 }
822 910
823 911 /**
824 912 * Values in force of the readable constants, the ones kept in the copy
@@ -1610,9 +1698,9 @@
1610 1698 */
1611 1699 public function init_cleanup_hooks() {
1612 1700 add_action( 'admin_init', array( $this, 'maybe_redact_stored_baseline' ) );
1613 1701 add_action( 'admin_init', array( $this, 'maybe_sweep_network_baselines' ) );
1614 - add_action( 'admin_init', array( $this, 'maybe_claim_owned_blocks' ) );
1702 + add_action( 'admin_init', array( $this, 'maybe_claim_owned_blocks_on_admin' ) );
1615 1703 }
1616 1704
1617 1705 /**
1618 1706 * Check if scan time limit has been exceeded
@@ -1683,8 +1771,16 @@
1683 1771 $files = array( $hook_extra['plugin'] );
1684 1772 }
1685 1773 foreach ( $files as $file ) {
1686 1774 $slug = dirname( (string) $file );
1775 + // Vigilant itself is verified immediately (not after 90 s) by
1776 + // Vigilante_Self_Integrity::handle_upgrader() when the
1777 + // self-check is on; do not also open a grace window for it.
1778 + // Compared with the folder it really lives in, not the literal
1779 + // slug, so a renamed folder is skipped the same way.
1780 + if ( dirname( VIGILANTE_PLUGIN_BASENAME ) === $slug && Vigilante_Self_Integrity::is_on() ) {
1781 + continue;
1782 + }
1687 1783 if ( '.' !== $slug && '' !== $slug ) {
1688 1784 $targets['plugin'][] = $slug;
1689 1785 }
1690 1786 }
@@ -1753,8 +1849,17 @@
1753 1849 */
1754 1850 private function verify_updated_slug( $type, $slug ) {
1755 1851 $grace_key = 'vigilante_fi_grace_' . $type . '_' . md5( $slug );
1756 1852
1853 + // Defensive skip for the first 2.11.x -> 3.0.x update: the OLD code in
1854 + // memory scheduled this event including Vigilant's own slug, and by the
1855 + // time it fires the NEW code (this one) is running with the self-check
1856 + // handling Vigilant on its own.
1857 + if ( 'plugin' === $type && dirname( VIGILANTE_PLUGIN_BASENAME ) === $slug && Vigilante_Self_Integrity::is_on() ) {
1858 + delete_transient( $grace_key );
1859 + return;
1860 + }
1861 +
1757 1862 if ( 'plugin' === $type ) {
1758 1863 if ( ! function_exists( 'get_plugins' ) ) {
1759 1864 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1760 1865 }
@@ -1905,8 +2010,41 @@
1905 2010
1906 2011 // Use settings from options page
1907 2012 $options = is_array( $this->options ) ? $this->options : array();
1908 2013
2014 + // Vigilant self-check runs FIRST and exempt from the time budget:
2015 + // ~60 small-file hashes cost < 50 ms and the guardian must never be
2016 + // dropped by the budget on plugin-heavy sites. User exclusions do not
2017 + // apply to it (see Vigilante_Self_Integrity::run_check()).
2018 + if ( ! class_exists( 'Vigilante_Self_Integrity' ) ) {
2019 + require_once VIGILANTE_INCLUDES_DIR . 'class-self-integrity.php';
2020 + }
2021 + if ( Vigilante_Self_Integrity::is_on() ) {
2022 + if ( ! class_exists( 'Vigilante_Self_Integrity' ) ) {
2023 + require_once VIGILANTE_INCLUDES_DIR . 'class-self-integrity.php';
2024 + }
2025 + $self = new Vigilante_Self_Integrity( $this->settings, $this->activity_log );
2026 + $self_result = $self->run_check( 'scan' );
2027 + // run_check() above updates this site's own state (status line and
2028 + // the analyzer check keep working everywhere). The plugin files are
2029 + // shared by the whole installation, so the self findings are folded
2030 + // Self-protection has its own alert, and it does not travel in the
2031 + // scan digest any more. That digest is governed by a notification
2032 + // setting that can be switched off, and switching off "tell me about
2033 + // changed files" was also switching off the alarm of the plugin
2034 + // itself. So the findings stay out of the scan results (they have
2035 + // their own block in File Integrity, with what each one means and how
2036 + // to repair it) and a critical one sends its own email from here,
2037 + // wherever the scan runs. maybe_send_self_alert() keeps it to the
2038 + // site that owns the shared files and dedupes by set of findings.
2039 + foreach ( (array) $self_result['findings'] as $self_finding ) {
2040 + if ( 'critical' === ( $self_finding['severity'] ?? '' ) ) {
2041 + $self->maybe_send_self_alert( $self_result['findings'], 'scan' );
2042 + break;
2043 + }
2044 + }
2045 + }
2046 +
1909 2047 // Scan uploads for suspicious files FIRST (highest security priority)
1910 2048 // PHP files in uploads are almost always malware
1911 2049 if ( ! empty( $options['scan_uploads'] ) && ! $this->is_time_exceeded() ) {
1912 2050 $upload_results = $this->scan_uploads();
@@ -2311,8 +2449,51 @@
2311 2449 * silently — there is nothing to compare against.
2312 2450 *
2313 2451 * @return array Array of modified file entries (same format as core modified).
2314 2452 */
2453 + /**
2454 + * Where a critical root file actually lives
2455 + *
2456 + * WordPress supports wp-config.php one directory above ABSPATH, guarded by
2457 + * wp-settings.php not being there: that is literally what the installed core
2458 + * does in wp-load.php, and it is a common hardening layout. Until 2.11.10
2459 + * this module only looked inside ABSPATH, so on those installations
2460 + * wp-config.php was never added to the baseline, never compared and never
2461 + * mentioned: the module reported the site clean without having opened the
2462 + * one file it most needs to watch. A zero is justified, never assumed. The
2463 + * plugin already resolved both locations elsewhere
2464 + * (Vigilante_Database_Prefix::find_wpconfig_path()), just not here. Found by
2465 + * the file-by-file review of 2.11.10.
2466 + *
2467 + * @since 2.11.10
2468 + *
2469 + * @param string $filename Name of the file, such as wp-config.php.
2470 + * @return string|false Absolute path, or false when it cannot be found.
2471 + */
2472 + private function critical_file_path( $filename ) {
2473 + $root = untrailingslashit( ABSPATH );
2474 + $path = $root . '/' . $filename;
2475 +
2476 + if ( file_exists( $path ) ) {
2477 + return $path;
2478 + }
2479 +
2480 + if ( 'wp-config.php' === $filename ) {
2481 + $above = dirname( $root ) . '/wp-config.php';
2482 +
2483 + // Suppressed like the core does in wp-load.php: the directory above
2484 + // the install is often outside open_basedir on shared hosting, and
2485 + // without the @ every scan emits a warning that can land in front of
2486 + // the JSON of an AJAX scan.
2487 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- The same @ the core uses for this same check in wp-load.php:52, where a wp-config.php one directory up is looked for: open_basedir makes file_exists() warn on a path outside it, and this must not print.
2488 + if ( @file_exists( $above ) && ! @file_exists( dirname( $root ) . '/wp-settings.php' ) ) {
2489 + return $above;
2490 + }
2491 + }
2492 +
2493 + return false;
2494 + }
2495 +
2315 2496 private function scan_critical_root_files() {
2316 2497 // Before reading anything: the scan is the only thing that reaches
2317 2498 // every site of a network on its own, through wp-cron and front-end
2318 2499 // traffic. Hooking the cleanup to admin_init alone left every subsite
@@ -2326,14 +2507,13 @@
2326 2507
2327 2508 $modified = array();
2328 2509 $baseline = $this->get_critical_files_baseline();
2329 2510 $baseline_changed = false;
2330 - $root_path = untrailingslashit( ABSPATH );
2331 2511
2332 2512 foreach ( $this->critical_root_files as $filename ) {
2333 - $full_path = $root_path . '/' . $filename;
2513 + $full_path = $this->critical_file_path( $filename );
2334 2514
2335 - if ( ! file_exists( $full_path ) ) {
2515 + if ( false === $full_path ) {
2336 2516 continue;
2337 2517 }
2338 2518
2339 2519 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
@@ -2610,8 +2790,222 @@
2610 2790 *
2611 2791 * @param string $line One line of wp-config.php.
2612 2792 * @return bool
2613 2793 */
2794 + /**
2795 + * Whether every marked line of a file can run nothing at all
2796 + *
2797 + * The question the re-base has to answer before adopting a file is whether
2798 + * the lines that carry the marker are only comments. Asking a stricter one
2799 + * was wrong in both directions: the first version of the guard used
2800 + * is_vigilant_original_line(), which also requires the commented define to
2801 + * match a known harmless shape, so it refused to re-base a perfectly inert
2802 + * line carrying an unusual define, which is exactly the case the re-base
2803 + * exists for, leaving the function unable to act at all. Found by the cross
2804 + * review of 2.11.10.
2805 + *
2806 + * The second version read one line at a time and reasoned that the marker
2807 + * begins with //, so a line with nothing but whitespace before it is wholly
2808 + * a comment. That is true only where PHP is already reading code, and the
2809 + * second cross review of 2.11.10 built three files where it is not, all of
2810 + * them valid PHP, all of them passing that test and all of them running or
2811 + * printing something:
2812 + *
2813 + * - the marked line placed BEFORE the opening <?php, so it is inline HTML
2814 + * that the server prints verbatim to the browser;
2815 + * - the same after a ?> that the file already had;
2816 + * - the marked line ending a block comment opened on an earlier line and
2817 + * opening another one at its end, with a statement in between, which
2818 + * runs like any other statement.
2819 + *
2820 + * So the file is read the way PHP reads it, not the way the line looks. A
2821 + * marked line is inert when every token touching it is a comment or
2822 + * whitespace, which answers the three at once: inline HTML is not a comment,
2823 + * and neither is a statement. The shape the guard was written for, code
2824 + * BEFORE the marker, is the same question from the other side.
2825 + *
2826 + * The three shapes are in the harness as cells X2, X3 and X4 of
2827 + * matriz-escondite-marcadores.sh, written out in full there. They are not
2828 + * written out here on purpose: a literal payload in a shipped file is
2829 + * signature surface for the scanners this plugin is read by, and a comment
2830 + * is a bad place to pay for it.
2831 + *
2832 + * @since 2.11.10
2833 + *
2834 + * @param string $content Whole file content.
2835 + * @return bool True when no marked line can run or print anything.
2836 + */
2837 + /**
2838 + * The lines of a file that carry the original-value marker
2839 + *
2840 + * @since 2.11.10
2841 + *
2842 + * @param string $content Whole file content, newlines already normalised.
2843 + * @return string[]
2844 + */
2845 + private function marked_lines_of( $content ) {
2846 + $out = array();
2847 +
2848 + foreach ( explode( "\n", $content ) as $text ) {
2849 + if ( false !== strpos( $text, $this->wpconfig_original_marker ) ) {
2850 + $out[] = $text;
2851 + }
2852 + }
2853 +
2854 + return $out;
2855 + }
2856 +
2857 + /**
2858 + * Whether what a marked line carries would still be harmless uncommented
2859 + *
2860 + * Only the part after the marker matters: what comes before it is answered by
2861 + * the token pass, which refuses anything that is not comment or whitespace.
2862 + * Here the question is what comes BACK when uncomment_original_constants()
2863 + * removes the marker, so the body has to be a single define() and nothing
2864 + * else, with at most a trailing line comment. Deliberately says nothing about
2865 + * WHICH constant it is: asking that was the first version of this guard, and
2866 + * it refused every define it did not recognise, which is exactly the case the
2867 + * re-base exists for.
2868 + *
2869 + * @since 2.11.10
2870 + *
2871 + * @param string $line One line carrying the marker.
2872 + * @return bool
2873 + */
2874 + private function marked_line_body_is_harmless( $line ) {
2875 + $at = strpos( $line, $this->wpconfig_original_marker );
2876 +
2877 + if ( false === $at ) {
2878 + return true;
2879 + }
2880 +
2881 + $body = trim( substr( $line, $at + strlen( $this->wpconfig_original_marker ) ) );
2882 +
2883 + if ( '' === $body ) {
2884 + return true;
2885 + }
2886 +
2887 + // Tokenised as PHP so the trailing comment, the strings and the nesting
2888 + // are read the way PHP reads them and not with a regular expression.
2889 + $tokens = @token_get_all( '<?php ' . $body ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A marked line can carry anything; a warning here must not be printed, and an unreadable body is refused below.
2890 +
2891 + if ( empty( $tokens ) ) {
2892 + return false;
2893 + }
2894 +
2895 + $statements = 0;
2896 + $depth = 0;
2897 +
2898 + foreach ( $tokens as $token ) {
2899 + $type = is_array( $token ) ? $token[0] : $token;
2900 +
2901 + if ( in_array( $type, array( T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) {
2902 + continue;
2903 + }
2904 +
2905 + if ( '(' === $type ) {
2906 + $depth++;
2907 + continue;
2908 + }
2909 +
2910 + if ( ')' === $type ) {
2911 + $depth--;
2912 + continue;
2913 + }
2914 +
2915 + // A semicolon at the top level closes a statement. More than one, or
2916 + // anything after the first, means the line carries something else.
2917 + if ( ';' === $type && 0 === $depth ) {
2918 + $statements++;
2919 + continue;
2920 + }
2921 +
2922 + if ( $statements > 0 ) {
2923 + return false;
2924 + }
2925 + }
2926 +
2927 + return ( $statements <= 1 );
2928 + }
2929 +
2930 + private function marked_lines_are_inert( $content ) {
2931 + $content = str_replace( "\r\n", "\n", (string) $content );
2932 + $marker = $this->wpconfig_original_marker;
2933 +
2934 + if ( '' === $content || false === strpos( $content, $marker ) ) {
2935 + return true;
2936 + }
2937 +
2938 + $marked = array();
2939 +
2940 + foreach ( explode( "\n", $content ) as $index => $text ) {
2941 + if ( false !== strpos( $text, $marker ) ) {
2942 + $marked[ $index + 1 ] = true;
2943 + }
2944 + }
2945 +
2946 + // Lenient on purpose (no TOKEN_PARSE): a tampered file still has to be
2947 + // read, and a file that cannot be tokenised is never adopted.
2948 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- The file read here may have been tampered with, which is the whole point, and PHP 8 emits a warning when it cannot tokenise: printing it would put a parse error on whatever page ran the scan. An unreadable file is refused four lines below.
2949 + $tokens = @token_get_all( $content );
2950 +
2951 + if ( empty( $tokens ) ) {
2952 + return false;
2953 + }
2954 +
2955 + /*
2956 + * And the other half of the question, which the first token version left
2957 + * out: a marked line is a COMMENTED OUT value, and uncommenting it is what
2958 + * the feature exists for, so "runs nothing today" is not enough. Anything
2959 + * sharing the line after the define comes back with it. The shape is real
2960 + * and needs no attacker: comment_existing_constants() takes a define and
2961 + * everything on its line, so
2962 + * define( 'WP_DEBUG', false ); @ini_set( 'display_errors', 0 );
2963 + * is commented whole, and re-basing it would adopt as approved something
2964 + * that runs the moment the value is restored. The old rule refused this
2965 + * too, but along with every define whose NAME it did not recognise, which
2966 + * is what left the function unable to act at all. Found by the third cross
2967 + * review of 2.11.10.
2968 + */
2969 + foreach ( $this->marked_lines_of( $content ) as $text ) {
2970 + if ( ! $this->marked_line_body_is_harmless( $text ) ) {
2971 + return false;
2972 + }
2973 + }
2974 +
2975 + $inocuos = array( T_COMMENT, T_DOC_COMMENT, T_WHITESPACE );
2976 + $linea = 1;
2977 +
2978 + foreach ( $tokens as $token ) {
2979 + $texto = is_array( $token ) ? $token[1] : $token;
2980 + $tipo = is_array( $token ) ? $token[0] : null;
2981 + $saltos = substr_count( $texto, "\n" );
2982 + $desde = $linea;
2983 + $hasta = $linea + $saltos;
2984 +
2985 + /*
2986 + * A token whose text ends in a newline puts nothing on the line that
2987 + * newline opens. Counting it would make the "<?php\n" of every file
2988 + * touch line 2 and refuse the legitimate case, which is what the
2989 + * first version of this did.
2990 + */
2991 + $ultima = ( $saltos > 0 && "\n" === substr( $texto, -1 ) ) ? $hasta - 1 : $hasta;
2992 + $linea = $hasta;
2993 +
2994 + if ( null !== $tipo && in_array( $tipo, $inocuos, true ) ) {
2995 + continue;
2996 + }
2997 +
2998 + for ( $l = $desde; $l <= $ultima; $l++ ) {
2999 + if ( isset( $marked[ $l ] ) ) {
3000 + return false;
3001 + }
3002 + }
3003 + }
3004 +
3005 + return true;
3006 + }
3007 +
2614 3008 private function is_vigilant_original_line( $line ) {
2615 3009 if ( false !== strpos( $line, '<?' ) || false !== strpos( $line, '?>' ) ) {
2616 3010 return false;
2617 3011 }
@@ -2722,8 +3116,36 @@
2722 3116 * it has run, normalize_critical_file() keeps the old rule on every site.
2723 3117 *
2724 3118 * @since 2.11.5
2725 3119 */
3120 + /**
3121 + * The admin_init entry point of the claim, which does ask for an administrator
3122 + *
3123 + * admin-ajax.php fires admin_init before it decides who is asking
3124 + * (wp-admin/admin-ajax.php:45), so without this an anonymous request chose
3125 + * the moment the claim runs. Unlike its two neighbours in
3126 + * init_cleanup_hooks(), which only drop the plugin's own copy out of the
3127 + * database, the claim writes two network options, changes for the whole
3128 + * network the rule normalize_critical_file() applies, and re-bases the
3129 + * approved baseline.
3130 + *
3131 + * The gate lives here and not inside maybe_claim_owned_blocks() because the
3132 + * scan calls that one directly and the scan runs from wp-cron, with no user:
3133 + * putting the capability check inside left the claim unable to complete on
3134 + * any site whose dashboard nobody opens, and until it completes the older,
3135 + * permissive rule is the one in force, which is the hiding place 2.11.5 was
3136 + * written to close. Found by the cross review of 2.11.10.
3137 + *
3138 + * @since 2.11.10
3139 + */
3140 + public function maybe_claim_owned_blocks_on_admin() {
3141 + if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {
3142 + return;
3143 + }
3144 +
3145 + $this->maybe_claim_owned_blocks();
3146 + }
3147 +
2726 3148 public function maybe_claim_owned_blocks() {
2727 3149 if ( $this->owned_blocks_claimed() || ! Vigilante_Settings::owns_shared_files() ) {
2728 3150 return;
2729 3151 }
@@ -2734,16 +3156,15 @@
2734 3156 if ( $sync_due && ! get_option( 'vigilante_server_files_retry_after' ) ) {
2735 3157 return;
2736 3158 }
2737 3159
2738 - $root_path = untrailingslashit( ABSPATH );
2739 3160 $expected = null;
2740 3161 $unclaimed = array();
2741 3162
2742 3163 foreach ( $this->critical_root_files as $filename ) {
2743 - $full_path = $root_path . '/' . $filename;
3164 + $full_path = $this->critical_file_path( $filename );
2744 3165
2745 - if ( ! file_exists( $full_path ) ) {
3166 + if ( false === $full_path ) {
2746 3167 continue;
2747 3168 }
2748 3169
2749 3170 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
@@ -2834,15 +3255,14 @@
2834 3255 * @since 2.11.5
2835 3256 */
2836 3257 private function rebase_original_line_shift() {
2837 3258 $baseline = $this->get_critical_files_baseline();
2838 - $root = untrailingslashit( ABSPATH );
2839 3259 $changed = false;
2840 3260
2841 3261 foreach ( $this->critical_root_files as $filename ) {
2842 - $full_path = $root . '/' . $filename;
3262 + $full_path = $this->critical_file_path( $filename );
2843 3263
2844 - if ( ! file_exists( $full_path ) || empty( $baseline[ $filename ]['hash'] ) ) {
3264 + if ( false === $full_path || empty( $baseline[ $filename ]['hash'] ) ) {
2845 3265 continue;
2846 3266 }
2847 3267
2848 3268 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
@@ -2850,8 +3270,33 @@
2850 3270 if ( false === $content ) {
2851 3271 continue;
2852 3272 }
2853 3273
3274 + /*
3275 + * Never re-base a file that carries a marked line which is not
3276 + * wholly a comment. The test below only establishes that the
3277 + * difference lies in lines carrying the marker, and the old rule
3278 + * dropped the WHOLE line, so a line with a statement in front of the
3279 + * marker satisfies it (cell X1 of matriz-escondite-marcadores.sh,
3280 + * where the shape is written out): re-basing would write that line
3281 + * into the approved baseline and rewrite the stored content, so the
3282 + * diff would stop showing it. Adopting as approved what the previous rule
3283 + * hid is the one thing an integrity scanner must never do, and the
3284 + * log entry of maybe_claim_owned_blocks() already promises the
3285 + * opposite ("the scan reports it as a change for you to review").
3286 + * Those files are left to be reported. Found by the file-by-file
3287 + * review of 2.11.10.
3288 + *
3289 + * What counts as "wholly a comment" is decided by reading the file
3290 + * as PHP reads it, not by the shape of the line: see
3291 + * marked_lines_are_inert(). A line that is not recognised is not the
3292 + * same thing as a line that can run something, and the first
3293 + * wording of this guard confused the two.
3294 + */
3295 + if ( ! $this->marked_lines_are_inert( $content ) ) {
3296 + continue;
3297 + }
3298 +
2854 3299 $current = md5( $this->normalize_critical_file( $filename, $content ) );
2855 3300
2856 3301 // Already in step, or a real change to something other than the
2857 3302 // original lines: nothing to re-base here.
@@ -2942,11 +3387,11 @@
2942 3387 * @param string $filename File name relative to ABSPATH (e.g. 'wp-config.php').
2943 3388 * @return bool True on success.
2944 3389 */
2945 3390 public function update_critical_file_baseline( $filename ) {
2946 - $full_path = untrailingslashit( ABSPATH ) . '/' . $filename;
3391 + $full_path = $this->critical_file_path( $filename );
2947 3392
2948 - if ( ! file_exists( $full_path ) ) {
3393 + if ( false === $full_path ) {
2949 3394 return false;
2950 3395 }
2951 3396
2952 3397 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
@@ -3003,14 +3448,13 @@
3003 3448 * @return array Updated baseline data.
3004 3449 */
3005 3450 public function regenerate_all_baselines() {
3006 3451 $baseline = array();
3007 - $root_path = untrailingslashit( ABSPATH );
3008 3452
3009 3453 foreach ( $this->critical_root_files as $filename ) {
3010 - $full_path = $root_path . '/' . $filename;
3454 + $full_path = $this->critical_file_path( $filename );
3011 3455
3012 - if ( ! file_exists( $full_path ) ) {
3456 + if ( false === $full_path ) {
3013 3457 continue;
3014 3458 }
3015 3459
3016 3460 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
@@ -3120,8 +3564,17 @@
3120 3564 if ( '.' === $plugin_slug ) {
3121 3565 continue;
3122 3566 }
3123 3567
3568 + // With the self-check on, Vigilant itself is verified by the
3569 + // sha256 triple-anchor block at the start of run_scan(): scanning
3570 + // it here again would duplicate findings and the md5 fetch. With
3571 + // the check off by filter, Vigilant is a regular plugin (legacy
3572 + // behaviour).
3573 + if ( dirname( VIGILANTE_PLUGIN_BASENAME ) === $plugin_slug && Vigilante_Self_Integrity::is_on() ) {
3574 + continue;
3575 + }
3576 +
3124 3577 // Skip slugs in their post-update grace window: wp.org may still be
3125 3578 // publishing the new version's checksums, so a scheduled scan here
3126 3579 // would raise benign "modified/extra" noise. The dedicated post-update
3127 3580 // verifier (vigilante_fi_postupdate_verify) handles these instead.
@@ -4368,8 +4821,26 @@
4368 4821 if ( is_multisite() && is_array( $item ) && 'critical_config' === ( $item['type'] ?? '' ) ) {
4369 4822 return true;
4370 4823 }
4371 4824
4825 + // Findings about the manifest and the version of Vigilant
4826 + // itself are not about one file, so no entry of the list may
4827 + // hide them, on a single site either: ignoring the row of
4828 + // MANIFEST.sha256 took a replaced manifest out of the email.
4829 + // Vigilante_Self_Integrity::filter_ignored_findings() keeps
4830 + // them the same way.
4831 + if ( is_array( $item ) && 'vigilante_self' === ( $item['type'] ?? '' ) && in_array( $item['self_finding'] ?? '', array( 'manifest_replaced', 'manifest_unverified', 'manifest_missing', 'manifest_invalid', 'self_downgraded' ), true ) ) {
4832 + return true;
4833 + }
4834 + // Nor the findings of the walk of Vigilant's folder (a folder
4835 + // that cannot be listed, the folder that could not be walked):
4836 + // their path ends in a slash, and ignoring that row left the
4837 + // scan with no row and no email while the self-protection
4838 + // status stayed critical.
4839 + if ( is_array( $item ) && 'vigilante_self' === ( $item['type'] ?? '' ) && '/' === substr( (string) ( $item['file'] ?? '' ), -1 ) ) {
4840 + return true;
4841 + }
4842 +
4372 4843 $file = is_array( $item ) && isset( $item['file'] ) ? $item['file'] : '';
4373 4844 return ! in_array( $file, $this->ignored_files, true );
4374 4845 }
4375 4846 )
@@ -4406,9 +4877,17 @@
4406 4877 // a security-critical finding, same tier as a suspicious file.
4407 4878 $closed_plugins = $this->collect_closed_plugins_for_email();
4408 4879 $has_closed = ! empty( $closed_plugins );
4409 4880
4881 + /*
4882 + * Self-protection is not part of this decision any more. Its alert is
4883 + * its own and no setting switches it off, so this email is again about
4884 + * the files of the site: core, plugins, themes, uploads and the two
4885 + * shared configuration files.
4886 + */
4410 4887 $has_suspicious = ! empty( $results['suspicious'] ) || ! empty( $results['extra'] ) || $has_critical_config || $has_closed;
4888 + // Missing files of core, plugins or themes still do not send the email on
4889 + // their own: it has no section to list them in, so it would arrive empty.
4411 4890 $has_modified = ! empty( $results['modified'] );
4412 4891
4413 4892 // Instant alert: send for suspicious, extra, critical_config, modified
4414 4893 // files, or closed plugins.
@@ -4527,8 +5006,37 @@
4527 5006 $regular_modified[] = $item;
4528 5007 }
4529 5008 }
4530 5009
5010 + /*
5011 + * Self-protection does not travel in this email any more: it has its own
5012 + * alert, which no setting switches off (Vigilante_Self_Integrity::
5013 + * maybe_send_self_alert()). Older stored results can still carry its
5014 + * rows, so they are dropped here instead of being listed as ordinary
5015 + * files.
5016 + */
5017 + foreach ( array( 'suspicious', 'extra', 'missing' ) as $self_bucket ) {
5018 + if ( empty( $results[ $self_bucket ] ) || ! is_array( $results[ $self_bucket ] ) ) {
5019 + continue;
5020 + }
5021 + $results[ $self_bucket ] = array_values(
5022 + array_filter(
5023 + $results[ $self_bucket ],
5024 + function ( $item ) {
5025 + return ! ( is_array( $item ) && 'vigilante_self' === ( $item['type'] ?? '' ) );
5026 + }
5027 + )
5028 + );
5029 + }
5030 + $regular_modified = array_values(
5031 + array_filter(
5032 + $regular_modified,
5033 + function ( $item ) {
5034 + return ! ( is_array( $item ) && 'vigilante_self' === ( $item['type'] ?? '' ) );
5035 + }
5036 + )
5037 + );
5038 +
4531 5039 $suspicious_count = count( $results['suspicious'] ?? array() );
4532 5040 $extra_count = count( $results['extra'] ?? array() );
4533 5041 $critical_config_count = count( $critical_config );
4534 5042 $modified_count = count( $regular_modified );
@@ -4533,10 +5041,10 @@
4533 5041 $critical_config_count = count( $critical_config );
4534 5042 $modified_count = count( $regular_modified );
4535 5043 $closed_count = count( $closed_plugins );
4536 5044
4537 - // Use more urgent subject when suspicious files, critical config changes
4538 - // or closed plugins are found (all three are security-critical).
5045 + // Use more urgent subject when suspicious files, critical config changes,
5046 + // closed plugins or self-integrity findings are found (all security-critical).
4539 5047 if ( $suspicious_count > 0 || $critical_config_count > 0 || $closed_count > 0 ) {
4540 5048 $subject = sprintf(
4541 5049 /* translators: %s: Site name */
4542 5050 __( '[%s] SECURITY ALERT: File integrity issues detected', 'vigilante' ),