PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.2
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.2
3.0.5 3.0.4 3.0.3 3.0.2 3.0.1 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 All 93 releases
← All changes | includes/class-file-integrity.php +280 -3051 3.0.3 → 3.0.2 View file →
@@ -68,50 +68,15 @@
68 68 */
69 69 private $scan_start_time = 0;
70 70
71 71 /**
72 - * Seconds a scan runs for at most
73 - *
74 - * A scan is one request, and it starts from the beginning every time. On
75 - * a site too large for this, what comes last is left unchecked on every
76 - * scan, so the results say what was not reached (see not_checked_lines()).
77 - */
78 - const MAX_SCAN_SECONDS = 60;
79 -
80 - /**
81 72 * Maximum scan time in seconds (default 60s for thorough scanning)
82 73 *
83 74 * @var int
84 75 */
85 - private $max_scan_time = self::MAX_SCAN_SECONDS;
76 + private $max_scan_time = 60;
86 77
87 78 /**
88 - * Largest file the search for hidden code reads, in bytes
89 - *
90 - * It used to be 512,000, and a larger file was not opened at all: in a
91 - * plugin with no official copy that left it out of the results as if it
92 - * were clean. There is still a cap because the file is read whole, but
93 - * what is above it is reported as not searched instead of being passed
94 - * over (see search_file()).
95 - */
96 - const MAX_SEARCHED_BYTES = 8388608;
97 -
98 - /**
99 - * Seconds a scan gives to the folders of wp-content that belong to nothing
100 - *
101 - * @var int
102 - */
103 - private $content_folders_time = 10;
104 -
105 - /**
106 - * Whether an expression of the search for hidden code gave up on the file
107 - * being searched (see found())
108 - *
109 - * @var bool
110 - */
111 - private $search_failed = false;
112 -
113 - /**
114 79 * Option name for critical files baseline hashes.
115 80 *
116 81 * @var string
117 82 */
@@ -1587,25 +1552,16 @@
1587 1552 array( '# BEGIN Vigilante Security Headers', '# END Vigilante Security Headers' ),
1588 1553 );
1589 1554
1590 1555 /**
1591 - * Whether the core is being compared with the English checksums because
1592 - * WordPress.org has none for the language of the site. Set by
1593 - * get_core_checksums().
1594 - *
1595 - * @var bool
1596 - */
1597 - private $core_checksums_are_english = false;
1598 -
1599 - /**
1600 1556 * Core files known to produce false positives in checksum comparison.
1601 - * These are skipped during core scanning: the readme and the license vary
1602 - * by locale and are not code. version.php used to be here too, and is now
1603 - * compared (see matches_core_of_other_locale()).
1557 + * These are skipped during core scanning (e.g. version.php is rewritten
1558 + * during auto-updates and localized installs, readme files vary by locale).
1604 1559 *
1605 1560 * @var array
1606 1561 */
1607 1562 private $core_known_false_positives = array(
1563 + 'wp-includes/version.php',
1608 1564 'readme.html',
1609 1565 'license.txt',
1610 1566 'licencia.txt',
1611 1567 );
@@ -2087,23 +2043,8 @@
2087 2043 }
2088 2044 }
2089 2045 }
2090 2046
2091 - // What the time does not reach. A scan that runs out of time used to
2092 - // say only that it was partial, and it starts from the beginning
2093 - // every time, so on a large site the same plugins and themes were
2094 - // left out on every scan with nobody told which. They are named.
2095 - $not_checked = array(
2096 - 'areas' => array(),
2097 - 'plugins' => array(),
2098 - 'themes' => array(),
2099 - );
2100 -
2101 - // And the folders of the plugins and themes it did check, which is
2102 - // what the tab offers to exclude so that a scan by hand reaches the
2103 - // rest (see folders_to_exclude()).
2104 - $checked_folders = array();
2105 -
2106 2047 // Scan uploads for suspicious files FIRST (highest security priority)
2107 2048 // PHP files in uploads are almost always malware
2108 2049 if ( ! empty( $options['scan_uploads'] ) && ! $this->is_time_exceeded() ) {
2109 2050 $upload_results = $this->scan_uploads();
@@ -2108,11 +2049,8 @@
2108 2049 if ( ! empty( $options['scan_uploads'] ) && ! $this->is_time_exceeded() ) {
2109 2050 $upload_results = $this->scan_uploads();
2110 2051 $results['suspicious'] = array_merge( $results['suspicious'], $upload_results['suspicious'] );
2111 2052 $results['extra'] = array_merge( $results['extra'], $upload_results['extra'] );
2112 - $results['errors'] = array_merge( $results['errors'], $upload_results['errors'] ?? array() );
2113 - } elseif ( ! empty( $options['scan_uploads'] ) ) {
2114 - $not_checked['areas'][] = 'uploads';
2115 2053 }
2116 2054
2117 2055 // Scan core files
2118 2056 if ( ! empty( $options['scan_core'] ) && ! $this->is_time_exceeded() ) {
@@ -2117,10 +2055,8 @@
2117 2055 // Scan core files
2118 2056 if ( ! empty( $options['scan_core'] ) && ! $this->is_time_exceeded() ) {
2119 2057 $core_results = $this->scan_core_files();
2120 2058 $results = $this->merge_results( $results, $core_results );
2121 - } elseif ( ! empty( $options['scan_core'] ) ) {
2122 - $not_checked['areas'][] = 'core';
2123 2059 }
2124 2060
2125 2061 // Scan root directory for non-core files (PHP = suspicious, others = additional)
2126 2062 // Runs after core scan so checksums are already cached
@@ -2127,61 +2063,20 @@
2127 2063 if ( ! empty( $options['scan_core'] ) && ! $this->is_time_exceeded() ) {
2128 2064 $root_results = $this->scan_root_files();
2129 2065 $results['suspicious'] = array_merge( $results['suspicious'], $root_results['suspicious'] );
2130 2066 $results['extra'] = array_merge( $results['extra'], $root_results['extra'] );
2131 - } elseif ( ! empty( $options['scan_core'] ) ) {
2132 - $not_checked['areas'][] = 'core';
2133 2067 }
2134 2068
2135 - // Files added to wp-admin or wp-includes, which the comparison with
2136 - // the checksums never visits because the checksums do not name them.
2137 - if ( ! empty( $options['scan_core'] ) && ! $this->is_time_exceeded() ) {
2138 - $folder_results = $this->scan_core_folders();
2139 - $results['suspicious'] = array_merge( $results['suspicious'], $folder_results['suspicious'] );
2140 - $results['extra'] = array_merge( $results['extra'], $folder_results['extra'] );
2141 - $results['errors'] = array_merge( $results['errors'], $folder_results['errors'] );
2142 - } elseif ( ! empty( $options['scan_core'] ) ) {
2143 - $not_checked['areas'][] = 'core';
2144 - }
2145 -
2146 2069 // Scan critical config files (wp-config.php, .htaccess) against stored baseline
2147 2070 if ( ! empty( $options['scan_critical_config'] ) && ! $this->is_time_exceeded() ) {
2148 2071 $critical_results = $this->scan_critical_root_files();
2149 2072 $results['modified'] = array_merge( $results['modified'], $critical_results );
2150 - } elseif ( ! empty( $options['scan_critical_config'] ) ) {
2151 - $not_checked['areas'][] = 'config';
2152 2073 }
2153 2074
2154 - // The other folders of wp-content (cache, languages, upgrade and
2155 - // whatever else is there), which belong to no plugin and no theme and
2156 - // had no turn. Before the plugins and the themes, which on a large
2157 - // site can use up the whole scan, and with a share of the time of
2158 - // their own, so a cache of a hundred thousand files cannot do the
2159 - // same to them.
2160 - $content_results = null;
2161 - if ( ! empty( $options['scan_plugins'] ) && ! $this->is_time_exceeded() ) {
2162 - $content_results = $this->scan_content_folders();
2163 - $results['suspicious'] = array_merge( $results['suspicious'], $content_results['suspicious'] );
2164 - $results['extra'] = array_merge( $results['extra'], $content_results['extra'] );
2165 - $results['errors'] = array_merge( $results['errors'], $content_results['errors'] );
2166 - } elseif ( ! empty( $options['scan_plugins'] ) ) {
2167 - $not_checked['areas'][] = 'content';
2168 - }
2169 -
2170 2075 // Scan plugins
2171 2076 if ( ! empty( $options['scan_plugins'] ) && ! $this->is_time_exceeded() ) {
2172 2077 $plugin_results = $this->scan_plugins();
2173 2078 $results = $this->merge_results( $results, $plugin_results );
2174 -
2175 - $not_checked['plugins'] = $plugin_results['not_checked'] ?? array();
2176 - $checked_folders = $plugin_results['checked'] ?? array();
2177 -
2178 - if ( empty( $plugin_results['unlisted_checked'] ) ) {
2179 - $not_checked['areas'][] = 'unlisted';
2180 - }
2181 - } elseif ( ! empty( $options['scan_plugins'] ) ) {
2182 - $not_checked['areas'][] = 'plugins';
2183 - $not_checked['areas'][] = 'unlisted';
2184 2079 }
2185 2080
2186 2081 // Scan themes
2187 2082 if ( ! empty( $options['scan_themes'] ) && ! $this->is_time_exceeded() ) {
@@ -2186,21 +2081,10 @@
2186 2081 // Scan themes
2187 2082 if ( ! empty( $options['scan_themes'] ) && ! $this->is_time_exceeded() ) {
2188 2083 $theme_results = $this->scan_themes();
2189 2084 $results = $this->merge_results( $results, $theme_results );
2190 -
2191 - $not_checked['themes'] = $theme_results['not_checked'] ?? array();
2192 - $checked_folders = array_merge( $checked_folders, $theme_results['checked'] ?? array() );
2193 - } elseif ( ! empty( $options['scan_themes'] ) ) {
2194 - $not_checked['areas'][] = 'themes';
2195 2085 }
2196 2086
2197 - // What could only be searched for hidden code: plugins and themes
2198 - // first, as they are found, and the folders of wp-content after them.
2199 - if ( null !== $content_results ) {
2200 - $results['unverified'] = array_values( array_unique( array_merge( $results['unverified'] ?? array(), $content_results['unverified'] ) ) );
2201 - }
2202 -
2203 2087 // Mark as incomplete if time was exceeded
2204 2088 if ( $this->is_time_exceeded() ) {
2205 2089 $results['incomplete'] = true;
2206 2090 $results['errors'][] = __( 'Scan was incomplete due to time limit. Results may be partial.', 'vigilante' );
@@ -2205,19 +2089,8 @@
2205 2089 $results['incomplete'] = true;
2206 2090 $results['errors'][] = __( 'Scan was incomplete due to time limit. Results may be partial.', 'vigilante' );
2207 2091 }
2208 2092
2209 - // Stored after the last merge: merge_results() keeps only the keys it
2210 - // knows.
2211 - $not_checked['areas'] = array_values( array_unique( $not_checked['areas'] ) );
2212 - $results['not_checked'] = $not_checked;
2213 -
2214 - // Only with plugins or themes left out: a scan that reached them all
2215 - // has nothing to make room for.
2216 - if ( ! empty( $not_checked['plugins'] ) || ! empty( $not_checked['themes'] ) || array_intersect( array( 'plugins', 'themes' ), $not_checked['areas'] ) ) {
2217 - $results['checked_folders'] = array_values( array_unique( $checked_folders ) );
2218 - }
2219 -
2220 2093 // Filter out ignored files from all result categories
2221 2094 $results['modified'] = $this->filter_ignored( $results['modified'] );
2222 2095 $results['suspicious'] = $this->filter_ignored( $results['suspicious'] );
2223 2096 $results['extra'] = $this->filter_ignored( $results['extra'] );
@@ -2228,21 +2101,14 @@
2228 2101 if ( $this->activity_log ) {
2229 2102 $has_issues = ! empty( $results['modified'] ) || ! empty( $results['suspicious'] ) || ! empty( $results['extra'] );
2230 2103 $severity = $has_issues ? 'warning' : 'info';
2231 2104
2232 - /* translators: 1: Scanned count, 2: Modified count, 3: Suspicious count, 4: Extra files count */
2233 - $log_text = __( 'File integrity scan completed: %1$d files scanned, %2$d modified, %3$d suspicious, %4$d extra', 'vigilante' );
2234 -
2235 - if ( $results['incomplete'] ) {
2236 - /* translators: 1: Scanned count, 2: Modified count, 3: Suspicious count, 4: Extra files count */
2237 - $log_text = __( 'File integrity scan ran out of time, so its result is partial: %1$d files scanned, %2$d modified, %3$d suspicious, %4$d extra', 'vigilante' );
2238 - }
2239 -
2240 2105 $this->activity_log->log(
2241 2106 'file',
2242 2107 'integrity_scan',
2243 2108 sprintf(
2244 - $log_text,
2109 + /* translators: 1: Scanned count, 2: Modified count, 3: Suspicious count, 4: Extra files count */
2110 + __( 'File integrity scan completed: %1$d files scanned, %2$d modified, %3$d suspicious, %4$d extra', 'vigilante' ),
2245 2111 $results['scanned'],
2246 2112 count( $results['modified'] ),
2247 2113 count( $results['suspicious'] ),
2248 2114 count( $results['extra'] )
@@ -2297,10 +2163,9 @@
2297 2163 'errors' => array(),
2298 2164 );
2299 2165
2300 2166 // Get official checksums from WordPress.org
2301 - $this->core_checksums_are_english = false;
2302 - $checksums = $this->get_core_checksums();
2167 + $checksums = $this->get_core_checksums();
2303 2168
2304 2169 if ( is_wp_error( $checksums ) ) {
2305 2170 $results['errors'][] = $checksums->get_error_message();
2306 2171 return $results;
@@ -2305,37 +2170,8 @@
2305 2170 $results['errors'][] = $checksums->get_error_message();
2306 2171 return $results;
2307 2172 }
2308 2173
2309 - // The plugins and themes that get a turn of their own in this scan.
2310 - $own_turn = array(
2311 - 'plugins' => array(),
2312 - 'themes' => array(),
2313 - );
2314 -
2315 - if ( ! empty( $this->options['scan_plugins'] ) ) {
2316 - if ( ! function_exists( 'get_plugins' ) ) {
2317 - require_once ABSPATH . 'wp-admin/includes/plugin.php';
2318 - }
2319 -
2320 - foreach ( array_keys( get_plugins() ) as $plugin_file ) {
2321 - $slash = strpos( $plugin_file, '/' );
2322 -
2323 - if ( false !== $slash ) {
2324 - $own_turn['plugins'][ substr( $plugin_file, 0, $slash ) ] = true;
2325 - }
2326 - }
2327 - }
2328 -
2329 - if ( ! empty( $this->options['scan_themes'] ) ) {
2330 - foreach ( array_keys( wp_get_themes() ) as $stylesheet ) {
2331 - $stylesheet = (string) $stylesheet;
2332 - $slash = strpos( $stylesheet, '/' );
2333 -
2334 - $own_turn['themes'][ false === $slash ? $stylesheet : substr( $stylesheet, 0, $slash ) ] = true;
2335 - }
2336 - }
2337 -
2338 2174 foreach ( $checksums as $file => $expected_hash ) {
2339 2175 // Check time limit
2340 2176 if ( $this->is_time_exceeded() ) {
2341 2177 break;
@@ -2352,9 +2188,9 @@
2352 2188 if ( $this->is_extension_excluded( $file_path ) ) {
2353 2189 continue;
2354 2190 }
2355 2191
2356 - // Skip known false positives (e.g. readme.html, license.txt)
2192 + // Skip known false positives (e.g. version.php, readme.html)
2357 2193 if ( in_array( $file, $this->core_known_false_positives, true ) ) {
2358 2194 continue;
2359 2195 }
2360 2196
@@ -2369,30 +2205,8 @@
2369 2205 || 0 === strpos( $file, 'wp-content/languages/themes/' ) ) {
2370 2206 continue;
2371 2207 }
2372 2208
2373 - // The bundled themes and plugins have their own turn. Akismet and
2374 - // the default themes are in this list at the version WordPress
2375 - // shipped, and they update on their own cycle: every one of those
2376 - // updates showed as a modified core file, and deleting one that is
2377 - // not in use as missing core files. The plugin and theme scans
2378 - // compare them with their own published copy, so they are left to
2379 - // those whenever those are on and have them in their list.
2380 - if ( preg_match( '#^wp-content/(plugins|themes)/([^/]+)/#', $file, $bundled ) ) {
2381 - // Deleted on purpose: nothing to compare and nothing missing.
2382 - if ( ! is_dir( ABSPATH . 'wp-content/' . $bundled[1] . '/' . $bundled[2] ) ) {
2383 - continue;
2384 - }
2385 -
2386 - // Its own scan compares it, but only what WordPress lists as a
2387 - // plugin or as a theme gets that turn. A folder that is there
2388 - // and is not listed (a plugin without its header, a theme
2389 - // without its style.css) stays with the core.
2390 - if ( isset( $own_turn[ $bundled[1] ][ $bundled[2] ] ) ) {
2391 - continue;
2392 - }
2393 - }
2394 -
2395 2209 $results['scanned']++;
2396 2210
2397 2211 if ( ! file_exists( $file_path ) ) {
2398 2212 $results['missing'][] = array(
@@ -2403,22 +2217,9 @@
2403 2217 }
2404 2218
2405 2219 $actual_hash = md5_file( $file_path );
2406 2220
2407 - if ( $actual_hash !== $expected_hash && ! $this->matches_core_of_other_locale( $file, $actual_hash ) ) {
2408 - // The two files a translated package changes cannot be told
2409 - // from a modified one while only the English list exists. Said
2410 - // as what it is, a file that could not be verified, instead of
2411 - // as a modified core file.
2412 - if ( $this->core_checksums_are_english && in_array( $file, array( 'wp-includes/version.php', 'wp-config-sample.php' ), true ) ) {
2413 - $results['errors'][] = sprintf(
2414 - /* translators: %s: File name */
2415 - __( '%s differs from the English package of WordPress, and WordPress.org has not published the checksums for the language of this site yet, so it could not be verified.', 'vigilante' ),
2416 - $file
2417 - );
2418 - continue;
2419 - }
2420 -
2221 + if ( $actual_hash !== $expected_hash ) {
2421 2222 $results['modified'][] = array(
2422 2223 'file' => $file,
2423 2224 'type' => 'core',
2424 2225 'expected_hash' => $expected_hash,
@@ -2532,9 +2333,9 @@
2532 2333
2533 2334 // Also add wp-config.php which is not in checksums but is core
2534 2335 $core_root_files[] = 'wp-config.php';
2535 2336
2536 - $php_extensions = self::php_extensions();
2337 + $php_extensions = array( 'php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar', 'phps' );
2537 2338
2538 2339 // Scan only direct children of ABSPATH (not recursive)
2539 2340 $root_path = untrailingslashit( ABSPATH );
2540 2341 $handle = opendir( $root_path );
@@ -2552,22 +2353,9 @@
2552 2353 if ( '.' === $entry || '..' === $entry ) {
2553 2354 continue;
2554 2355 }
2555 2356
2556 - // PHP reads .user.ini and php.ini from the folder of the script, and
2557 - // either can make it load a file before every request: the same
2558 - // door as .htaccess, for servers that run PHP outside Apache.
2559 - if ( in_array( strtolower( $entry ), array( '.user.ini', 'php.ini' ), true ) ) {
2560 - $config = $this->is_path_excluded( $root_path . '/' . $entry ) ? null : self::server_config_finding( $root_path . '/' . $entry, $entry );
2561 -
2562 - if ( $config ) {
2563 - $config['item']['type'] = 'php_config_in_root';
2564 - $found['extra'][] = $config['item'];
2565 - }
2566 - continue;
2567 - }
2568 -
2569 - // Skip dotfiles (.htaccess, .env, etc.): handled by firewall protection
2357 + // Skip dotfiles (.htaccess, .user.ini, .env, etc.) — handled by firewall protection
2570 2358 if ( 0 === strpos( $entry, '.' ) ) {
2571 2359 continue;
2572 2360 }
2573 2361
@@ -2592,13 +2380,8 @@
2592 2380 if ( in_array( strtolower( $entry ), $this->known_safe_root_files, true ) ) {
2593 2381 continue;
2594 2382 }
2595 2383
2596 - // Site verification files and sitemaps that are what their name says.
2597 - if ( self::is_harmless_root_file( $entry, $full_path ) ) {
2598 - continue;
2599 - }
2600 -
2601 2384 $extension = strtolower( pathinfo( $entry, PATHINFO_EXTENSION ) );
2602 2385
2603 2386 if ( in_array( $extension, $php_extensions, true ) ) {
2604 2387 // Check if this is a legacy WordPress core file (removed from newer versions)
@@ -2620,12 +2403,15 @@
2620 2403 // PHP file not in core = suspicious
2621 2404 $reason = __( 'Non-core PHP file in WordPress root directory', 'vigilante' );
2622 2405
2623 2406 // Scan content for specific patterns
2624 - $pattern = $this->search_file( $full_path );
2625 - if ( $pattern ) {
2626 - /* translators: %s: Suspicious pattern found */
2627 - $reason = sprintf( __( 'Non-core PHP in root with suspicious code: %s', 'vigilante' ), $pattern );
2407 + if ( filesize( $full_path ) < 512000 ) {
2408 + $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
2409 + $pattern = $this->detect_suspicious_pattern( $content );
2410 + if ( $pattern ) {
2411 + /* translators: %s: Suspicious pattern found */
2412 + $reason = sprintf( __( 'Non-core PHP in root with suspicious code: %s', 'vigilante' ), $pattern );
2413 + }
2628 2414 }
2629 2415
2630 2416 $found['suspicious'][] = array(
2631 2417 'file' => $entry,
@@ -2632,20 +2418,8 @@
2632 2418 'type' => 'php_in_root',
2633 2419 'reason' => $reason,
2634 2420 );
2635 2421 } else {
2636 - // A copy of the configuration, a database dump or an archive
2637 - // is not one more loose file: whoever guesses its name can
2638 - // download it.
2639 - if ( false !== stripos( $entry, 'wp-config' ) || preg_match( '/\.(?:sql|bak|old|orig|save|swp|dump|zip|gz|tgz|tar|7z|rar)$/i', $entry ) ) {
2640 - $found['extra'][] = array(
2641 - 'file' => $entry,
2642 - 'type' => 'backup_in_root',
2643 - 'reason' => __( 'Backup or copy of a sensitive file in the site root, where it can be downloaded', 'vigilante' ),
2644 - );
2645 - continue;
2646 - }
2647 -
2648 2422 // Non-PHP, non-known-safe file = additional (informational)
2649 2423 $found['extra'][] = array(
2650 2424 'file' => $entry,
2651 2425 'type' => 'extra_root',
@@ -2658,864 +2432,8 @@
2658 2432
2659 2433 return $found;
2660 2434 }
2661 2435
2662 - /**
2663 - * The line of a .user.ini or php.ini that makes PHP load a file by itself
2664 - *
2665 - * @param string $path Absolute path of the file.
2666 - * @return string|null The line, an empty string when there is none, or
2667 - * null when the file is too large to be read, which
2668 - * the caller reports instead of taking for clean.
2669 - */
2670 - private static function php_ini_loads( $path ) {
2671 - // PHP runs as the same user: what it cannot read here it cannot apply.
2672 - if ( ! is_readable( $path ) ) {
2673 - return '';
2674 - }
2675 -
2676 - if ( filesize( $path ) > MB_IN_BYTES ) {
2677 - return null;
2678 - }
2679 -
2680 - $content = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reads a local file to look at its directives; nothing is written.
2681 -
2682 - foreach ( preg_split( '/\r\n|\r|\n/', (string) $content ) as $line ) {
2683 - $line = trim( $line );
2684 -
2685 - if ( '' === $line || ';' === $line[0] || '#' === $line[0] ) {
2686 - continue;
2687 - }
2688 -
2689 - if ( preg_match( '/^(auto_prepend_file|auto_append_file)\s*=\s*(.*)$/i', $line, $parts ) ) {
2690 - $value = trim( $parts[2], " \t\"'" );
2691 -
2692 - if ( '' !== $value && 'none' !== strtolower( $value ) ) {
2693 - return strtolower( $parts[1] ) . ' = ' . substr( $value, 0, 200 );
2694 - }
2695 - }
2696 - }
2697 -
2698 - return '';
2699 - }
2700 -
2701 - /**
2702 - * What a .htaccess, .user.ini or php.ini outside uploads makes the server do
2703 - *
2704 - * Inside a plugin, a theme, wp-content or a WordPress folder, one of these
2705 - * files is a finding only when it makes the server run code or load a file.
2706 - * One that denies access, or does nothing of the kind, is left alone.
2707 - *
2708 - * @param string $file_path Absolute path.
2709 - * @param string $relative Path for display.
2710 - * @return array|null Array with 'category' and 'item', or null.
2711 - */
2712 - private static function server_config_finding( $file_path, $relative ) {
2713 - $item = array(
2714 - 'file' => $relative,
2715 - 'type' => 'server_config',
2716 - );
2717 -
2718 - if ( '.htaccess' === strtolower( basename( $file_path ) ) ) {
2719 - if ( ! is_readable( $file_path ) ) {
2720 - return null;
2721 - }
2722 -
2723 - if ( filesize( $file_path ) >= 65536 ) {
2724 - $item['reason'] = __( '.htaccess file that is too large to be read', 'vigilante' );
2725 -
2726 - return array(
2727 - 'category' => 'extra',
2728 - 'item' => $item,
2729 - );
2730 - }
2731 -
2732 - $facts = self::read_htaccess_rules( file_get_contents( $file_path ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reads a local file to look at its directives; nothing is written.
2733 -
2734 - if ( empty( $facts['runs'] ) ) {
2735 - return null;
2736 - }
2737 -
2738 - $item['reason'] = self::htaccess_run_reason( $facts );
2739 -
2740 - return array(
2741 - 'category' => 'suspicious',
2742 - 'item' => $item,
2743 - );
2744 - }
2745 -
2746 - $loads = self::php_ini_loads( $file_path );
2747 -
2748 - if ( '' === $loads ) {
2749 - return null;
2750 - }
2751 -
2752 - $item['reason'] = null === $loads
2753 - ? __( 'PHP configuration file that is too large to be read', 'vigilante' )
2754 - /* translators: %s: The line of the file that does it */
2755 - : sprintf( __( 'PHP configuration file that loads a file on every request: %s', 'vigilante' ), $loads );
2756 -
2757 - return array(
2758 - 'category' => 'extra',
2759 - 'item' => $item,
2760 - );
2761 - }
2762 -
2763 - /**
2764 - * Whether a loose file in the site root is a verification file or a sitemap
2765 - *
2766 - * By what it contains or by its kind, never by its name alone: a Google
2767 - * verification file is one line that repeats its own name, and a sitemap
2768 - * is XML, which no server runs.
2769 - *
2770 - * @param string $entry File name.
2771 - * @param string $full_path Absolute path.
2772 - * @return bool
2773 - */
2774 - private static function is_harmless_root_file( $entry, $full_path ) {
2775 - if ( preg_match( '/^[a-z0-9_-]*sitemap[a-z0-9_-]*\.xml(?:\.gz)?$/i', $entry ) ) {
2776 - return true;
2777 - }
2778 -
2779 - if ( preg_match( '/^google[0-9a-f]{16}\.html$/', $entry ) && is_readable( $full_path ) && filesize( $full_path ) < 200 ) {
2780 - $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reads a local file to compare it with its own name; nothing is written.
2781 -
2782 - return 'google-site-verification: ' . $entry === trim( (string) $content );
2783 - }
2784 -
2785 - return false;
2786 - }
2787 -
2788 - /**
2789 - * Walk a folder and everything under it without stopping at what cannot be read
2790 - *
2791 - * A folder PHP cannot open used to end the whole walk in silence, which is
2792 - * all it took to blind a scan: one unreadable folder early in the order,
2793 - * and nothing after it was looked at. Now the walk goes past it, and the
2794 - * caller reports it (see unreadable_folder_error()).
2795 - *
2796 - * @param string $directory Folder to walk.
2797 - * @param callable|null $skip Given the path of a folder, whether to leave
2798 - * it and everything under it out of the walk.
2799 - * @return RecursiveIteratorIterator Folders included: the caller skips them.
2800 - */
2801 - private static function walk_folder( $directory, $skip = null ) {
2802 - $folder = new RecursiveDirectoryIterator( $directory, RecursiveDirectoryIterator::SKIP_DOTS );
2803 -
2804 - if ( null !== $skip ) {
2805 - $folder = new RecursiveCallbackFilterIterator(
2806 - $folder,
2807 - static function ( $current ) use ( $skip ) {
2808 - return ! ( $current->isDir() && call_user_func( $skip, $current->getPathname() ) );
2809 - }
2810 - );
2811 - }
2812 -
2813 - return new RecursiveIteratorIterator(
2814 - $folder,
2815 - RecursiveIteratorIterator::SELF_FIRST,
2816 - RecursiveIteratorIterator::CATCH_GET_CHILD
2817 - );
2818 - }
2819 -
2820 - /**
2821 - * What the scan says about a folder it could not read
2822 - *
2823 - * @param string $path Absolute path of the folder.
2824 - * @return string
2825 - */
2826 - private static function unreadable_folder_error( $path ) {
2827 - return sprintf(
2828 - /* translators: %s: Folder path */
2829 - __( 'The folder %s could not be read, so what is inside it was not checked.', 'vigilante' ),
2830 - str_replace( ABSPATH, '', $path )
2831 - );
2832 - }
2833 -
2834 - /**
2835 - * Extensions a server may be set up to run as PHP
2836 - *
2837 - * One list for uploads, the site root, the core folders and the plugin and
2838 - * theme folders. Each had its own until now, and none had them all.
2839 - *
2840 - * @return array
2841 - */
2842 - private static function php_extensions() {
2843 - return array( 'php', 'php2', 'php3', 'php4', 'php5', 'php6', 'php7', 'php8', 'phtml', 'pht', 'phar', 'phps', 'inc' );
2844 - }
2845 -
2846 - /**
2847 - * Look in wp-admin and wp-includes for files WordPress does not ship
2848 - *
2849 - * The comparison with the official checksums only visits the files the
2850 - * checksums name, so a file added to either folder was never looked at. A
2851 - * PHP file there that is not part of the installed version is reported as
2852 - * suspicious. A file that changes how the server runs code (.htaccess,
2853 - * .user.ini, php.ini) is reported as extra, or as suspicious when it
2854 - * makes the server run code. Anything else that is not code, such as an
2855 - * error log the host writes there, is left alone.
2856 - *
2857 - * @return array Array with 'suspicious', 'extra' and 'errors' sub-arrays.
2858 - */
2859 - private function scan_core_folders() {
2860 - $found = array(
2861 - 'suspicious' => array(),
2862 - 'extra' => array(),
2863 - 'errors' => array(),
2864 - );
2865 -
2866 - $checksums = $this->get_core_checksums();
2867 - if ( is_wp_error( $checksums ) ) {
2868 - return $found;
2869 - }
2870 -
2871 - $php_extensions = self::php_extensions();
2872 - $config_files = array( '.htaccess', '.user.ini', 'php.ini' );
2873 -
2874 - foreach ( array( 'wp-admin', 'wp-includes' ) as $folder ) {
2875 - $directory = ABSPATH . $folder;
2876 -
2877 - if ( ! is_dir( $directory ) ) {
2878 - continue;
2879 - }
2880 -
2881 - try {
2882 - foreach ( self::walk_folder( $directory ) as $file ) {
2883 - if ( $this->is_time_exceeded() ) {
2884 - break 2;
2885 - }
2886 -
2887 - $file_path = $file->getPathname();
2888 -
2889 - if ( $file->isDir() ) {
2890 - if ( ! $file->isReadable() ) {
2891 - $found['errors'][] = self::unreadable_folder_error( $file_path );
2892 - }
2893 - continue;
2894 - }
2895 -
2896 - $relative = $this->normalize_rel_path( str_replace( '\\', '/', str_replace( ABSPATH, '', $file_path ) ) );
2897 -
2898 - if ( isset( $checksums[ $relative ] ) || $this->is_path_excluded( $file_path ) ) {
2899 - continue;
2900 - }
2901 -
2902 - $extension = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
2903 -
2904 - if ( in_array( $extension, $php_extensions, true ) ) {
2905 - // The placeholder some hosts and tools drop in every folder.
2906 - if ( $this->is_silence_golden_file( $file_path ) ) {
2907 - continue;
2908 - }
2909 -
2910 - // Translation files, on the installations old enough
2911 - // to keep them in wp-includes/languages: text and
2912 - // nothing else, which is checked and not assumed.
2913 - if ( self::is_inside_languages( $file_path ) && true === $this->is_literal_data( $file_path ) ) {
2914 - continue;
2915 - }
2916 -
2917 - /* translators: %s: Folder name, wp-admin or wp-includes */
2918 - $reason = sprintf( __( 'File in %s that is not part of the installed WordPress version', 'vigilante' ), $folder );
2919 -
2920 - $pattern = $this->search_file( $file_path );
2921 - if ( $pattern ) {
2922 - /* translators: 1: Folder name, wp-admin or wp-includes, 2: Suspicious pattern found */
2923 - $reason = sprintf( __( 'File in %1$s that is not part of WordPress, with suspicious code: %2$s', 'vigilante' ), $folder, $pattern );
2924 - }
2925 -
2926 - $found['suspicious'][] = array(
2927 - 'file' => $relative,
2928 - 'type' => 'php_in_core',
2929 - 'reason' => $reason,
2930 - );
2931 - } elseif ( in_array( strtolower( basename( $file_path ) ), $config_files, true ) ) {
2932 - $config = self::server_config_finding( $file_path, $relative );
2933 -
2934 - if ( $config && 'suspicious' === $config['category'] ) {
2935 - $found['suspicious'][] = $config['item'];
2936 - continue;
2937 - }
2938 -
2939 - $found['extra'][] = array(
2940 - 'file' => $relative,
2941 - 'type' => 'config_in_core',
2942 - /* translators: %s: Folder name, wp-admin or wp-includes */
2943 - 'reason' => $config ? $config['item']['reason'] : sprintf( __( 'Server configuration file in %s', 'vigilante' ), $folder ),
2944 - );
2945 - }
2946 - }
2947 - } catch ( Exception $e ) {
2948 - $found['errors'][] = self::unreadable_folder_error( $directory );
2949 - }
2950 - }
2951 -
2952 - return $found;
2953 - }
2954 -
2955 - /**
2956 - * Look for code where there is no official copy to compare with
2957 - *
2958 - * Must-use plugins, drop-ins, the loose PHP files of wp-content, of the
2959 - * plugins folder and of the themes folder, and the folders in those two
2960 - * that WordPress does not list as a plugin or as a theme, were not opened
2961 - * by any part of the scan, although WordPress loads some of them on every
2962 - * request and the rest can be reached by their address. They are searched
2963 - * the way a plugin from outside WordPress.org is, for code written the way
2964 - * malware hides itself. A loose PHP file or a folder with PHP that is
2965 - * neither a plugin, a theme nor a drop-in is listed as extra, and so is a
2966 - * .htaccess, .user.ini or php.ini there that makes the server run code or
2967 - * load a file.
2968 - *
2969 - * @return array Array with 'suspicious', 'extra', 'unverified' and 'errors' sub-arrays.
2970 - */
2971 - private function scan_unlisted_code() {
2972 - $found = array(
2973 - 'suspicious' => array(),
2974 - 'extra' => array(),
2975 - 'unverified' => array(),
2976 - 'errors' => array(),
2977 - );
2978 -
2979 - // Must-use plugins: the whole folder.
2980 - if ( defined( 'WPMU_PLUGIN_DIR' ) && is_dir( WPMU_PLUGIN_DIR ) ) {
2981 - $mu = $this->detect_extra_files( WPMU_PLUGIN_DIR, array(), 'plugin', __( 'Must-use plugins', 'vigilante' ), true );
2982 -
2983 - $found['suspicious'] = array_merge( $found['suspicious'], $mu['suspicious'] );
2984 - $found['extra'] = array_merge( $found['extra'], $mu['extra'] );
2985 - $found['errors'] = array_merge( $found['errors'], $mu['errors'] );
2986 -
2987 - if ( $mu['php_files'] > 0 ) {
2988 - $found['unverified'][] = __( 'Must-use plugins', 'vigilante' );
2989 - }
2990 - }
2991 -
2992 - if ( ! function_exists( '_get_dropins' ) || ! function_exists( 'get_plugins' ) ) {
2993 - require_once ABSPATH . 'wp-admin/includes/plugin.php';
2994 - }
2995 -
2996 - $php_extensions = self::php_extensions();
2997 - $config_files = array( '.htaccess', '.user.ini', 'php.ini' );
2998 - $dropins = array_keys( _get_dropins() );
2999 -
3000 - // What WordPress lists as a plugin or as a theme, by the file or the
3001 - // folder it lives in.
3002 - $single_plugins = array();
3003 - $plugin_folders = array();
3004 - foreach ( get_plugins() as $plugin_file => $plugin_data ) {
3005 - if ( false === strpos( $plugin_file, '/' ) ) {
3006 - $single_plugins[ $plugin_file ] = $plugin_data['Name'] ?? $plugin_file;
3007 - } else {
3008 - $plugin_folders[ substr( $plugin_file, 0, strpos( $plugin_file, '/' ) ) ] = true;
3009 - }
3010 - }
3011 -
3012 - // The themes by where they really are, because there can be more
3013 - // than one folder of themes and the same name in two of them.
3014 - $theme_folders = array();
3015 - foreach ( wp_get_themes() as $stylesheet => $theme ) {
3016 - $stylesheet = (string) $stylesheet;
3017 - $slash = strpos( $stylesheet, '/' );
3018 - $real = realpath( untrailingslashit( $theme->get_theme_root() ) . '/' . ( false === $slash ? $stylesheet : substr( $stylesheet, 0, $slash ) ) );
3019 -
3020 - if ( false !== $real ) {
3021 - $theme_folders[ wp_normalize_path( $real ) ] = true;
3022 - }
3023 - }
3024 -
3025 - $roots = array(
3026 - array( 'content', WP_CONTENT_DIR ),
3027 - array( 'plugins', WP_PLUGIN_DIR ),
3028 - );
3029 -
3030 - // Every folder themes are registered in, not only the usual one. One
3031 - // that is inside a plugin belongs to that plugin, and has its turn
3032 - // with it.
3033 - global $wp_theme_directories;
3034 -
3035 - $plugin_roots = array();
3036 - foreach ( array( WP_PLUGIN_DIR, defined( 'WPMU_PLUGIN_DIR' ) ? WPMU_PLUGIN_DIR : '' ) as $plugin_root ) {
3037 - $real = '' === (string) $plugin_root ? false : realpath( $plugin_root );
3038 -
3039 - if ( false !== $real ) {
3040 - $plugin_roots[ wp_normalize_path( $real ) ] = true;
3041 - }
3042 - }
3043 -
3044 - $theme_roots = array();
3045 - foreach ( array_merge( array( get_theme_root() ), is_array( $wp_theme_directories ) ? $wp_theme_directories : array() ) as $theme_root ) {
3046 - $real = '' === (string) $theme_root ? false : realpath( $theme_root );
3047 - $real = false === $real ? '' : wp_normalize_path( $real );
3048 -
3049 - if ( '' === $real || isset( $theme_roots[ $real ] ) || self::is_inside_any( $real, $plugin_roots ) ) {
3050 - continue;
3051 - }
3052 -
3053 - $theme_roots[ $real ] = true;
3054 - $roots[] = array( 'themes', untrailingslashit( $theme_root ) );
3055 - }
3056 -
3057 - foreach ( $roots as $root ) {
3058 - list( $kind, $directory ) = $root;
3059 -
3060 - $entries = is_dir( $directory ) ? scandir( $directory ) : false;
3061 -
3062 - if ( ! is_array( $entries ) ) {
3063 - continue;
3064 - }
3065 -
3066 - foreach ( $entries as $entry ) {
3067 - if ( '.' === $entry || '..' === $entry ) {
3068 - continue;
3069 - }
3070 -
3071 - if ( $this->is_time_exceeded() ) {
3072 - break 2;
3073 - }
3074 -
3075 - $path = $directory . '/' . $entry;
3076 -
3077 - if ( $this->is_path_excluded( $path ) ) {
3078 - continue;
3079 - }
3080 -
3081 - $relative = str_replace( ABSPATH, '', $path );
3082 -
3083 - // A folder among the plugins or the themes that WordPress does
3084 - // not list as one: no scan had a turn for it.
3085 - if ( is_dir( $path ) ) {
3086 - $real = realpath( $path );
3087 - $listed = 'plugins' === $kind ? isset( $plugin_folders[ $entry ] ) : ( 'themes' === $kind ? ( false !== $real && isset( $theme_folders[ wp_normalize_path( $real ) ] ) ) : true );
3088 -
3089 - // A link is followed like any other folder, because what
3090 - // is behind it is reached by its address just the same,
3091 - // unless it leads back to wp-content or above it, which
3092 - // would be walking the site a second time.
3093 - if ( $listed || ( is_link( $path ) && self::leads_back( $path ) ) ) {
3094 - continue;
3095 - }
3096 -
3097 - $inside = $this->detect_extra_files( $path, array(), 'themes' === $kind ? 'theme' : 'plugin', $entry, true );
3098 -
3099 - $found['suspicious'] = array_merge( $found['suspicious'], $inside['suspicious'] );
3100 - $found['extra'] = array_merge( $found['extra'], $inside['extra'] );
3101 - $found['errors'] = array_merge( $found['errors'], $inside['errors'] );
3102 -
3103 - if ( $inside['php_files'] > 0 ) {
3104 - $found['extra'][] = array(
3105 - 'file' => $relative,
3106 - 'type' => 'php_in_content',
3107 - 'reason' => 'themes' === $kind
3108 - ? __( 'Folder with PHP files that WordPress does not list as a theme', 'vigilante' )
3109 - : __( 'Folder with PHP files that WordPress does not list as a plugin', 'vigilante' ),
3110 - );
3111 - }
3112 - continue;
3113 - }
3114 -
3115 - if ( in_array( strtolower( $entry ), $config_files, true ) ) {
3116 - $config = self::server_config_finding( $path, $relative );
3117 -
3118 - if ( $config ) {
3119 - $found[ $config['category'] ][] = $config['item'];
3120 - }
3121 - continue;
3122 - }
3123 -
3124 - $extension = strtolower( pathinfo( $entry, PATHINFO_EXTENSION ) );
3125 -
3126 - if ( ! in_array( $extension, $php_extensions, true ) || $this->is_silence_golden_file( $path ) ) {
3127 - continue;
3128 - }
3129 -
3130 - // hello.php comes with WordPress and is compared with it.
3131 - if ( 'plugins' === $kind && 'hello.php' === $entry ) {
3132 - continue;
3133 - }
3134 -
3135 - $pattern = $this->search_file( $path, true );
3136 -
3137 - $known = ( 'plugins' === $kind && isset( $single_plugins[ $entry ] ) ) || ( 'content' === $kind && in_array( $entry, $dropins, true ) );
3138 -
3139 - if ( $known ) {
3140 - $found['unverified'][] = 'plugins' === $kind ? $single_plugins[ $entry ] : __( 'Drop-ins', 'vigilante' );
3141 - }
3142 -
3143 - if ( $pattern ) {
3144 - $found['suspicious'][] = array(
3145 - 'file' => $relative,
3146 - 'type' => 'suspicious_plugin',
3147 - /* translators: 1: Plugin or theme name, or the folder the file is in, 2: Suspicious pattern found */
3148 - 'reason' => sprintf( __( 'File in %1$s with suspicious code: %2$s', 'vigilante' ), dirname( $relative ), $pattern ),
3149 - );
3150 - } elseif ( ! $known ) {
3151 - $found['extra'][] = array(
3152 - 'file' => $relative,
3153 - 'type' => 'php_in_content',
3154 - 'reason' => __( 'PHP file that is not a plugin, a theme or a WordPress drop-in', 'vigilante' ),
3155 - );
3156 - } elseif ( null === $pattern ) {
3157 - $found['extra'][] = $this->not_searched_item( $path, $relative );
3158 - }
3159 - }
3160 - }
3161 -
3162 - $found['unverified'] = array_values( array_unique( $found['unverified'] ) );
3163 -
3164 - return $found;
3165 - }
3166 -
3167 - /**
3168 - * Look in the folders of wp-content that belong to no plugin and no theme
3169 - *
3170 - * cache, languages, upgrade and whatever else is there were not opened by
3171 - * any part of the scan, and a PHP file in any of them can be reached by
3172 - * its address like one anywhere else. Every PHP file in them is searched
3173 - * for hidden code, the way a plugin from outside WordPress.org is, and a
3174 - * .htaccess, .user.ini or php.ini is reported when it makes the server run
3175 - * code or load a file. A folder with PHP files that could only be
3176 - * searched is named among what has no official copy.
3177 - *
3178 - * Translation files are the exception, because what they must hold is
3179 - * known: text. WordPress loads the .l10n.php files of the site language
3180 - * with include on every request
3181 - * (wp-includes/l10n/class-wp-translation-file-php.php:24), so one that
3182 - * holds anything else is reported whatever the code looks like, and any
3183 - * other PHP file among the translations is listed. A PHP file that holds
3184 - * literal data and nothing else cannot run anything, and is left alone
3185 - * wherever it is (see is_literal_data()).
3186 - *
3187 - * Plugins, themes, must-use plugins and uploads have their own scans and
3188 - * are skipped here. The folders whose name says cache go last, and the
3189 - * whole walk has a share of the scan time of its own, so a cache with a
3190 - * hundred thousand files costs that share and no more.
3191 - *
3192 - * @return array Array with 'suspicious', 'extra', 'unverified' and 'errors' sub-arrays.
3193 - */
3194 - private function scan_content_folders() {
3195 - $found = array(
3196 - 'suspicious' => array(),
3197 - 'extra' => array(),
3198 - 'unverified' => array(),
3199 - 'errors' => array(),
3200 - );
3201 -
3202 - $entries = is_dir( WP_CONTENT_DIR ) ? scandir( WP_CONTENT_DIR ) : false;
3203 -
3204 - if ( ! is_array( $entries ) ) {
3205 - return $found;
3206 - }
3207 -
3208 - // The folders with a scan of their own, by where they really are.
3209 - global $wp_theme_directories;
3210 -
3211 - $uploads = wp_get_upload_dir();
3212 - $uploads_real = empty( $uploads['basedir'] ) ? false : realpath( $uploads['basedir'] );
3213 - $uploads_real = false === $uploads_real ? '' : wp_normalize_path( $uploads_real );
3214 - $own = array();
3215 - $roots = array_merge(
3216 - array( WP_PLUGIN_DIR, defined( 'WPMU_PLUGIN_DIR' ) ? WPMU_PLUGIN_DIR : '', get_theme_root() ),
3217 - is_array( $wp_theme_directories ) ? $wp_theme_directories : array()
3218 - );
3219 -
3220 - foreach ( $roots as $root ) {
3221 - $real = '' === (string) $root ? false : realpath( $root );
3222 -
3223 - if ( false !== $real ) {
3224 - $own[ wp_normalize_path( $real ) ] = true;
3225 - }
3226 - }
3227 -
3228 - if ( '' !== $uploads_real ) {
3229 - $own[ $uploads_real ] = true;
3230 - }
3231 -
3232 - // On a site of a network that is not the main one, the uploads of the
3233 - // site live inside the uploads of the network, which are the main
3234 - // site's turn.
3235 - $network_uploads = is_multisite() && ! is_main_site() ? $uploads_real : '';
3236 -
3237 - $folders = array();
3238 -
3239 - foreach ( $entries as $entry ) {
3240 - $path = WP_CONTENT_DIR . '/' . $entry;
3241 -
3242 - if ( '.' === $entry || '..' === $entry || ! is_dir( $path ) ) {
3243 - continue;
3244 - }
3245 -
3246 - $real = realpath( $path );
3247 - $real = false === $real ? '' : wp_normalize_path( $real );
3248 -
3249 - // A folder with a scan of its own, or a link into one. A link to
3250 - // anywhere else is walked like the folder it stands for: deploy
3251 - // tools keep languages or a cache behind one, and what is there
3252 - // is reached by its address all the same.
3253 - if ( '' === $real || self::is_inside_any( $real, $own ) || ( is_link( $path ) && self::leads_back( $path ) ) ) {
3254 - continue;
3255 - }
3256 -
3257 - if ( '' !== $network_uploads && 0 === strpos( $network_uploads, $real . '/' ) ) {
3258 - continue;
3259 - }
3260 -
3261 - if ( $this->is_path_excluded( $path . '/.' ) ) {
3262 - continue;
3263 - }
3264 -
3265 - $folders[] = $entry;
3266 - }
3267 -
3268 - usort(
3269 - $folders,
3270 - static function ( $a, $b ) {
3271 - $a_cache = false !== stripos( $a, 'cache' );
3272 - $b_cache = false !== stripos( $b, 'cache' );
3273 -
3274 - return $a_cache === $b_cache ? strcmp( $a, $b ) : ( $a_cache ? 1 : -1 );
3275 - }
3276 - );
3277 -
3278 - $php_extensions = self::php_extensions();
3279 - $config_files = array( '.htaccess', '.user.ini', 'php.ini' );
3280 - $deadline = microtime( true ) + $this->content_folders_time;
3281 -
3282 - // A folder with a scan of its own nested further down, or one the
3283 - // user left out, is not walked at all.
3284 - $skip = function ( $folder ) use ( $own ) {
3285 - $real = realpath( $folder );
3286 -
3287 - return ( false !== $real && isset( $own[ wp_normalize_path( $real ) ] ) ) || $this->is_path_excluded( $folder . '/.' );
3288 - };
3289 -
3290 - foreach ( $folders as $entry ) {
3291 - $path = WP_CONTENT_DIR . '/' . $entry;
3292 - $php_files = 0;
3293 -
3294 - try {
3295 - foreach ( self::walk_folder( $path, $skip ) as $file ) {
3296 - if ( $this->is_time_exceeded() ) {
3297 - break 2;
3298 - }
3299 -
3300 - if ( microtime( true ) > $deadline ) {
3301 - $found['errors'][] = sprintf(
3302 - /* translators: %s: Folder path */
3303 - __( 'The folders of wp-content outside plugins, themes and uploads were not checked completely: the time for them ran out in %s. If that folder only holds files that change all the time, such as a cache, add it to Excluded Paths.', 'vigilante' ),
3304 - str_replace( ABSPATH, '', $path )
3305 - );
3306 - break 2;
3307 - }
3308 -
3309 - $file_path = $file->getPathname();
3310 -
3311 - if ( $file->isDir() ) {
3312 - if ( ! $file->isReadable() ) {
3313 - $found['errors'][] = self::unreadable_folder_error( $file_path );
3314 - }
3315 - continue;
3316 - }
3317 -
3318 - $basename = strtolower( basename( $file_path ) );
3319 -
3320 - if ( in_array( $basename, $config_files, true ) ) {
3321 - $config = $this->is_path_excluded( $file_path ) ? null : self::server_config_finding( $file_path, str_replace( ABSPATH, '', $file_path ) );
3322 -
3323 - if ( $config ) {
3324 - $found[ $config['category'] ][] = $config['item'];
3325 - }
3326 - continue;
3327 - }
3328 -
3329 - if ( ! in_array( strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) ), $php_extensions, true ) ) {
3330 - continue;
3331 - }
3332 -
3333 - if ( $this->is_path_excluded( $file_path ) || $this->is_silence_golden_file( $file_path ) ) {
3334 - continue;
3335 - }
3336 -
3337 - $literal = $this->is_literal_data( $file_path );
3338 -
3339 - if ( true === $literal ) {
3340 - continue;
3341 - }
3342 -
3343 - $relative = str_replace( ABSPATH, '', $file_path );
3344 -
3345 - if ( false === $literal && '.l10n.php' === substr( $basename, -9 ) ) {
3346 - // Reported whatever the search finds, like a PHP file
3347 - // in uploads: WordPress loads it on every request, and
3348 - // no tool writes anything but the table of texts in
3349 - // it. What the search finds is said next to it.
3350 - $pattern = $this->search_file( $file_path, true );
3351 - $reason = __( 'Translation file that holds something other than translations', 'vigilante' );
3352 -
3353 - if ( $pattern ) {
3354 - /* translators: %s: Suspicious pattern found */
3355 - $reason = sprintf( __( 'Translation file with suspicious code: %s', 'vigilante' ), $pattern );
3356 - }
3357 -
3358 - $found['suspicious'][] = array(
3359 - 'file' => $relative,
3360 - 'type' => 'translation_with_code',
3361 - 'reason' => $reason,
3362 - );
3363 - continue;
3364 - }
3365 -
3366 - $pattern = $this->search_file( $file_path, true );
3367 -
3368 - if ( $pattern ) {
3369 - $found['suspicious'][] = array(
3370 - 'file' => $relative,
3371 - 'type' => 'suspicious_content',
3372 - /* translators: 1: Plugin or theme name, or the folder the file is in, 2: Suspicious pattern found */
3373 - 'reason' => sprintf( __( 'File in %1$s with suspicious code: %2$s', 'vigilante' ), str_replace( ABSPATH, '', $path ), $pattern ),
3374 - );
3375 - } elseif ( null === $pattern ) {
3376 - $found['extra'][] = $this->not_searched_item( $file_path, $relative );
3377 - } elseif ( self::is_inside_languages( $file_path ) ) {
3378 - $found['extra'][] = array(
3379 - 'file' => $relative,
3380 - 'type' => 'php_in_content',
3381 - 'reason' => __( 'PHP file among the translations that is not a translation file', 'vigilante' ),
3382 - );
3383 - } else {
3384 - $php_files++;
3385 - }
3386 - }
3387 - } catch ( Exception $e ) {
3388 - $found['errors'][] = self::unreadable_folder_error( $path );
3389 - }
3390 -
3391 - if ( $php_files > 0 ) {
3392 - $found['unverified'][] = str_replace( ABSPATH, '', $path );
3393 - }
3394 - }
3395 -
3396 - return $found;
3397 - }
3398 -
3399 - /**
3400 - * Whether a real path is one of a set of folders or lies inside one of them
3401 - *
3402 - * @param string $real Real path, with forward slashes.
3403 - * @param array $folders Real paths with forward slashes, as keys.
3404 - * @return bool
3405 - */
3406 - private static function is_inside_any( $real, $folders ) {
3407 - foreach ( array_keys( $folders ) as $folder ) {
3408 - if ( $real === $folder || 0 === strpos( $real, $folder . '/' ) ) {
3409 - return true;
3410 - }
3411 - }
3412 -
3413 - return false;
3414 - }
3415 -
3416 - /**
3417 - * Whether a link leads to wp-content itself or to a folder above it
3418 - *
3419 - * @param string $path Path of the link.
3420 - * @return bool True also when where it leads cannot be worked out.
3421 - */
3422 - private static function leads_back( $path ) {
3423 - $real = realpath( $path );
3424 - $content = realpath( WP_CONTENT_DIR );
3425 -
3426 - if ( false === $real || false === $content ) {
3427 - return true;
3428 - }
3429 -
3430 - return 0 === strpos( wp_normalize_path( $content ) . '/', wp_normalize_path( $real ) . '/' );
3431 - }
3432 -
3433 - /**
3434 - * Whether a path is inside the folder WordPress keeps its translations in
3435 - *
3436 - * @param string $path Absolute path.
3437 - * @return bool
3438 - */
3439 - private static function is_inside_languages( $path ) {
3440 - if ( ! defined( 'WP_LANG_DIR' ) ) {
3441 - return false;
3442 - }
3443 -
3444 - return 0 === strpos( wp_normalize_path( $path ), untrailingslashit( wp_normalize_path( WP_LANG_DIR ) ) . '/' );
3445 - }
3446 -
3447 - /**
3448 - * Whether a PHP file holds literal data and nothing else
3449 - *
3450 - * A file such as "<?php return [ 'a' => 'b' ];" cannot run anything. It is
3451 - * how WordPress, GlotPress, WP-CLI and Loco Translate write translation
3452 - * files, and how plugins keep small tables. The file is read one piece at
3453 - * a time, and every piece has to be a quoted string with nothing
3454 - * interpolated in it, a number, true, false, null, return, an array
3455 - * bracket, a comma, "=>", a dot or a semicolon. A parenthesis is
3456 - * accepted only as part of "array(" and to close it, so
3457 - * nothing can be called, and with those pieces there is no way to name a
3458 - * variable, a function, a class or a file.
3459 - *
3460 - * Anything else, a comment included, and the answer is no. Saying no to a
3461 - * harmless file only means it is searched like any other; saying yes to
3462 - * a file with code would hide it. So this reads less than PHP does, on
3463 - * purpose. Each piece is also one PHP would cut the same way: a minus
3464 - * sign only in front of a number and a dot never in front of "=", or
3465 - * "-=>" and ".=>" would be read here as an arrow and by PHP as an
3466 - * assignment. What it accepts is checked against PHP's own reading of
3467 - * the same file, at random, in the release checks.
3468 - *
3469 - * @param string $file_path Absolute path.
3470 - * @return bool|null True when it holds only data, false when it holds
3471 - * anything else, null when it could not be read whole.
3472 - */
3473 - private function is_literal_data( $file_path ) {
3474 - if ( ! is_file( $file_path ) || ! is_readable( $file_path ) ) {
3475 - return null;
3476 - }
3477 -
3478 - // With another source encoding set (zend.multibyte), a string does
3479 - // not end for PHP where it ends byte by byte: nothing is vouched for.
3480 - if ( ini_get( 'zend.multibyte' ) ) {
3481 - return null;
3482 - }
3483 -
3484 - $size = filesize( $file_path );
3485 -
3486 - if ( false === $size || $size > self::MAX_SEARCHED_BYTES ) {
3487 - return null;
3488 - }
3489 -
3490 - $content = file_get_contents( $file_path, false, null, 0, self::MAX_SEARCHED_BYTES + 1 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reads a local file to see whether it holds anything but data; nothing is written.
3491 -
3492 - if ( false === $content || strlen( $content ) > self::MAX_SEARCHED_BYTES ) {
3493 - return null;
3494 - }
3495 -
3496 - if ( 1 !== preg_match( '/\A<\?php[ \t\r\n]/', $content ) ) {
3497 - return false;
3498 - }
3499 -
3500 - $piece = '/\G(?:[ \t\r\n]++|\'[^\'\\\\]*+(?:\\\\.[^\'\\\\]*+)*+\'|"[^"\\\\$]*+(?:\\\\.[^"\\\\$]*+)*+"|-?+\d++(?:\.\d++)?+(?:e[+-]?+\d++)?+|=>|\.(?!=)|[\[\],;)]|(?:true|false|null|return)(?![a-z0-9_\x80-\xff])|array[ \t\r\n]*+\()/is';
3501 - $length = strlen( $content );
3502 - $offset = 6;
3503 -
3504 - while ( $offset < $length ) {
3505 - $match = array();
3506 - $result = preg_match( $piece, $content, $match, 0, $offset );
3507 -
3508 - if ( 1 !== $result ) {
3509 - return false === $result ? null : false;
3510 - }
3511 -
3512 - $offset += strlen( $match[0] );
3513 - }
3514 -
3515 - return true;
3516 - }
3517 -
3518 2436 // =========================================================================
3519 2437 // Critical config file baseline monitoring (wp-config.php, .htaccess)
3520 2438 // =========================================================================
3521 2439
@@ -4563,10 +3481,10 @@
4563 3481 *
4564 3482 * @param bool $force_refresh Skip the cached copy and ask wp.org again.
4565 3483 * @return array|WP_Error Checksums or error.
4566 3484 */
4567 - private function get_core_checksums( $force_refresh = false, $locale = null, $english_fallback = true ) {
4568 - $locale = null === $locale ? get_locale() : $locale;
3485 + private function get_core_checksums( $force_refresh = false ) {
3486 + $locale = get_locale();
4569 3487 $version = $this->wp_version;
4570 3488
4571 3489 // Check cache first
4572 3490 $cache_key = 'vigilante_core_checksums_' . md5( $version . $locale );
@@ -4595,26 +3513,8 @@
4595 3513
4596 3514 $body = json_decode( wp_remote_retrieve_body( $response ), true );
4597 3515
4598 3516 if ( empty( $body['checksums'] ) ) {
4599 - /*
4600 - * WordPress.org has no list for this version in this language. That
4601 - * is the normal state of every site that is not in English for the
4602 - * hours or days after a release, until the package of its language
4603 - * is built, and of the languages that never get one. Until now the
4604 - * core was then not compared at all. Those sites run the English
4605 - * files, so the English list is the one to compare with. It is not
4606 - * kept under this language, so the next scan asks again.
4607 - */
4608 - if ( 'en_US' !== $locale && $english_fallback ) {
4609 - $english = $this->get_core_checksums( $force_refresh, 'en_US' );
4610 -
4611 - if ( is_array( $english ) ) {
4612 - $this->core_checksums_are_english = true;
4613 - return $english;
4614 - }
4615 - }
4616 -
4617 3517 return new WP_Error( 'no_checksums', __( 'Could not retrieve WordPress core checksums', 'vigilante' ) );
4618 3518 }
4619 3519
4620 3520 $checksums = $body['checksums'];
@@ -4630,52 +3530,8 @@
4630 3530 return $checksums;
4631 3531 }
4632 3532
4633 3533 /**
4634 - * Whether a core file is the official copy of another language
4635 - *
4636 - * The checksums are asked for in the language of the site, and a site does
4637 - * not always run the package of its language: one installed in English and
4638 - * switched to Spanish afterwards keeps the English files, so its
4639 - * version.php and its wp-config-sample.php do not match the Spanish list.
4640 - * That is why version.php used to be left out of the comparison altogether,
4641 - * which left a file WordPress loads on every request unchecked. A file that
4642 - * matches the official copy of the same version in English, or in the
4643 - * language its own package declares, is an official file. The language
4644 - * only chooses which official list to look at: a file that was tampered
4645 - * with matches none of them.
4646 - *
4647 - * @param string $file Path relative to the WordPress root.
4648 - * @param string $actual_hash md5 of the file on disk.
4649 - * @return bool
4650 - */
4651 - private function matches_core_of_other_locale( $file, $actual_hash ) {
4652 - $current = get_locale();
4653 - $others = array( 'en_US' );
4654 -
4655 - if ( ! empty( $GLOBALS['wp_local_package'] ) && is_string( $GLOBALS['wp_local_package'] ) ) {
4656 - $others[] = $GLOBALS['wp_local_package'];
4657 - }
4658 -
4659 - foreach ( array_unique( $others ) as $locale ) {
4660 - if ( $locale === $current || ! preg_match( '/^[A-Za-z0-9_]{2,24}$/', $locale ) ) {
4661 - continue;
4662 - }
4663 -
4664 - // Without the English list in its place: asking about another
4665 - // language must not leave the scan believing that the list of the
4666 - // site itself was missing.
4667 - $checksums = $this->get_core_checksums( false, $locale, false );
4668 -
4669 - if ( is_array( $checksums ) && isset( $checksums[ $file ] ) && $checksums[ $file ] === $actual_hash ) {
4670 - return true;
4671 - }
4672 - }
4673 -
4674 - return false;
4675 - }
4676 -
4677 - /**
4678 3534 * Scan plugins for modifications
4679 3535 *
4680 3536 * @return array Scan results.
4681 3537 */
@@ -4693,10 +3549,9 @@
4693 3549 if ( ! function_exists( 'get_plugins' ) ) {
4694 3550 require_once ABSPATH . 'wp-admin/includes/plugin.php';
4695 3551 }
4696 3552
4697 - $plugins = get_plugins();
4698 - $finished = array();
3553 + $plugins = get_plugins();
4699 3554
4700 3555 foreach ( $plugins as $plugin_file => $plugin_data ) {
4701 3556 // Check time limit
4702 3557 if ( $this->is_time_exceeded() ) {
@@ -4704,12 +3559,30 @@
4704 3559 }
4705 3560
4706 3561 $plugin_slug = dirname( $plugin_file );
4707 3562
4708 - if ( ! $this->plugin_has_turn( $plugin_slug ) ) {
3563 + // Skip single-file plugins
3564 + if ( '.' === $plugin_slug ) {
4709 3565 continue;
4710 3566 }
4711 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 +
3577 + // Skip slugs in their post-update grace window: wp.org may still be
3578 + // publishing the new version's checksums, so a scheduled scan here
3579 + // would raise benign "modified/extra" noise. The dedicated post-update
3580 + // verifier (vigilante_fi_postupdate_verify) handles these instead.
3581 + if ( $this->in_post_update_grace( 'plugin', $plugin_slug ) ) {
3582 + continue;
3583 + }
3584 +
4712 3585 // Get checksums from WordPress.org
4713 3586 $version = $plugin_data['Version'] ?? '';
4714 3587 $checksums = $this->get_plugin_checksums( $plugin_slug, $version );
4715 3588
@@ -4714,22 +3587,8 @@
4714 3587 $checksums = $this->get_plugin_checksums( $plugin_slug, $version );
4715 3588
4716 3589 $has_checksums = ! is_wp_error( $checksums ) && 'not_found' !== $checksums;
4717 3590
4718 - // Say which plugins could not be compared, and why. A plugin that
4719 - // is not on WordPress.org is named as such; a failed request is an
4720 - // error of this scan, not a plugin without an official copy.
4721 - if ( is_wp_error( $checksums ) ) {
4722 - $results['errors'][] = sprintf(
4723 - /* translators: 1: Plugin name, 2: What went wrong */
4724 - __( 'The plugin %1$s could not be compared with its copy on WordPress.org: %2$s', 'vigilante' ),
4725 - $plugin_data['Name'] ?? $plugin_slug,
4726 - $checksums->get_error_message()
4727 - );
4728 - } elseif ( ! $has_checksums ) {
4729 - $results['unverified'][] = $plugin_data['Name'] ?? $plugin_slug;
4730 - }
4731 -
4732 3591 $plugin_dir = WP_PLUGIN_DIR . '/' . $plugin_slug;
4733 3592
4734 3593 // Check known files against checksums (only if available)
4735 3594 if ( $has_checksums ) {
@@ -4758,17 +3617,9 @@
4758 3617
4759 3618 $results['scanned']++;
4760 3619
4761 3620 if ( ! file_exists( $file_path ) ) {
4762 - // Reported, and not just skipped: a file that is in the
4763 - // published copy and not on the server is part of what
4764 - // the scan found.
4765 - $results['missing'][] = array(
4766 - 'file' => 'plugins/' . $plugin_slug . '/' . $file,
4767 - 'type' => 'plugin',
4768 - 'plugin' => $plugin_data['Name'] ?? $plugin_slug,
4769 - );
4770 - continue;
3621 + continue; // Some files might not be installed
4771 3622 }
4772 3623
4773 3624 if ( ! $this->hash_matches_published( $file_path, $expected_hash ) ) {
4774 3625 $results['modified'][] = array(
@@ -4792,84 +3643,15 @@
4792 3643 $suspicious_only = ! $has_checksums; // Without checksums, only report files with suspicious code
4793 3644 $extra_results = $this->detect_extra_files( $plugin_dir, $known_files, 'plugin', $plugin_data['Name'], $suspicious_only );
4794 3645 $results['extra'] = array_merge( $results['extra'] ?? array(), $extra_results['extra'] );
4795 3646 $results['suspicious'] = array_merge( $results['suspicious'] ?? array(), $extra_results['suspicious'] );
4796 - $results['errors'] = array_merge( $results['errors'] ?? array(), $extra_results['errors'] ?? array() );
4797 3647 }
4798 -
4799 - // The plugin had its whole turn. The one the time runs out on
4800 - // does not get here with time left, and is named with the ones
4801 - // behind it.
4802 - if ( ! $this->is_time_exceeded() ) {
4803 - $finished[ $plugin_file ] = true;
4804 -
4805 - // Its folder, as a line for Excluded Paths: what a partial
4806 - // scan offers so that the next one spends its time on the
4807 - // rest. One that is already excluded was not looked at.
4808 - if ( ! $this->is_path_excluded( $plugin_dir . '/.' ) ) {
4809 - $results['checked'][] = $this->relative_path( $plugin_dir );
4810 - }
4811 - }
4812 3648 }
4813 3649
4814 - // The plugins the time did not reach, by name.
4815 - $results['not_checked'] = array();
4816 -
4817 - foreach ( $plugins as $plugin_file => $plugin_data ) {
4818 - if ( isset( $finished[ $plugin_file ] ) || ! $this->plugin_has_turn( dirname( $plugin_file ) ) ) {
4819 - continue;
4820 - }
4821 -
4822 - $results['not_checked'][] = (string) ( $plugin_data['Name'] ?? dirname( $plugin_file ) );
4823 - }
4824 -
4825 - // Must-use plugins, drop-ins and loose PHP files, which have no
4826 - // official copy and no entry in the list above.
4827 - $results['unlisted_checked'] = false;
4828 -
4829 - if ( ! $this->is_time_exceeded() ) {
4830 - $unlisted = $this->scan_unlisted_code();
4831 -
4832 - $results['extra'] = array_merge( $results['extra'] ?? array(), $unlisted['extra'] );
4833 - $results['suspicious'] = array_merge( $results['suspicious'] ?? array(), $unlisted['suspicious'] );
4834 - $results['unverified'] = array_merge( $results['unverified'] ?? array(), $unlisted['unverified'] );
4835 - $results['errors'] = array_merge( $results['errors'] ?? array(), $unlisted['errors'] );
4836 -
4837 - $results['unlisted_checked'] = ! $this->is_time_exceeded();
4838 - }
4839 -
4840 3650 return $results;
4841 3651 }
4842 3652
4843 3653 /**
4844 - * Whether a plugin gets a turn of its own in scan_plugins()
4845 - *
4846 - * @param string $plugin_slug Folder of the plugin, or "." for a single file.
4847 - * @return bool
4848 - */
4849 - private function plugin_has_turn( $plugin_slug ) {
4850 - // Single-file plugins are loose PHP files: scan_unlisted_code().
4851 - if ( '.' === $plugin_slug ) {
4852 - return false;
4853 - }
4854 -
4855 - // With the self-check on, Vigilant itself is verified by the
4856 - // sha256 triple-anchor block at the start of run_scan(): scanning
4857 - // it here again would duplicate findings and the md5 fetch. With
4858 - // the check off by filter, Vigilant is a regular plugin (legacy
4859 - // behaviour).
4860 - if ( dirname( VIGILANTE_PLUGIN_BASENAME ) === $plugin_slug && Vigilante_Self_Integrity::is_on() ) {
4861 - return false;
4862 - }
4863 -
4864 - // Skip slugs in their post-update grace window: wp.org may still be
4865 - // publishing the new version's checksums, so a scheduled scan here
4866 - // would raise benign "modified/extra" noise. The dedicated post-update
4867 - // verifier (vigilante_fi_postupdate_verify) handles these instead.
4868 - return ! $this->in_post_update_grace( 'plugin', $plugin_slug );
4869 - }
4870 -
4871 - /**
4872 3654 * Whether a file on disk matches a hash WordPress.org publishes for it.
4873 3655 *
4874 3656 * The wp.org checksums JSON gives, per file, an md5 (and usually a sha256)
4875 3657 * that may be a single string OR an array of strings: a file whose content
@@ -4999,9 +3781,9 @@
4999 3781 return $response;
5000 3782 }
5001 3783
5002 3784 $status = wp_remote_retrieve_response_code( $response );
5003 - if ( 404 === (int) $status ) {
3785 + if ( 200 !== $status ) {
5004 3786 // Cache "not found" to avoid repeated requests (never during grace).
5005 3787 if ( ! $grace ) {
5006 3788 set_transient( $cache_key, 'not_found', HOUR_IN_SECONDS );
5007 3789 }
@@ -5007,19 +3789,8 @@
5007 3789 }
5008 3790 return 'not_found';
5009 3791 }
5010 3792
5011 - if ( 200 !== (int) $status ) {
5012 - // WordPress.org answered, but not with the checksums. An outage is
5013 - // not a plugin with no published copy, and until now it passed
5014 - // for one: the plugin was quietly left uncompared.
5015 - return new WP_Error(
5016 - 'http_error',
5017 - /* translators: %d: HTTP status code */
5018 - sprintf( __( 'WordPress.org answered with status %d', 'vigilante' ), (int) $status )
5019 - );
5020 - }
5021 -
5022 3793 $body = json_decode( wp_remote_retrieve_body( $response ), true );
5023 3794
5024 3795 if ( empty( $body['files'] ) ) {
5025 3796 return new WP_Error( 'no_checksums', __( 'No checksums found', 'vigilante' ) );
@@ -5058,10 +3829,9 @@
5058 3829 'extra' => array(),
5059 3830 'errors' => array(),
5060 3831 );
5061 3832
5062 - $themes = wp_get_themes();
5063 - $finished = array();
3833 + $themes = wp_get_themes();
5064 3834
5065 3835 foreach ( $themes as $theme_slug => $theme ) {
5066 3836 // Check time limit
5067 3837 if ( $this->is_time_exceeded() ) {
@@ -5077,20 +3847,8 @@
5077 3847 $checksums = $this->get_theme_checksums( $theme_slug, $version );
5078 3848
5079 3849 $has_checksums = ! is_wp_error( $checksums ) && 'not_found' !== $checksums;
5080 3850
5081 - // Same as for plugins: name what could not be compared, and why.
5082 - if ( is_wp_error( $checksums ) && 'not_found' !== $checksums->get_error_code() ) {
5083 - $results['errors'][] = sprintf(
5084 - /* translators: 1: Theme name, 2: What went wrong */
5085 - __( 'The theme %1$s could not be compared with its copy on WordPress.org: %2$s', 'vigilante' ),
5086 - $theme->get( 'Name' ),
5087 - $checksums->get_error_message()
5088 - );
5089 - } elseif ( ! $has_checksums ) {
5090 - $results['unverified'][] = $theme->get( 'Name' );
5091 - }
5092 -
5093 3851 $theme_dir = $theme->get_stylesheet_directory();
5094 3852
5095 3853 // Check known files against checksums (only if available)
5096 3854 if ( $has_checksums ) {
@@ -5119,13 +3877,8 @@
5119 3877
5120 3878 $results['scanned']++;
5121 3879
5122 3880 if ( ! file_exists( $file_path ) ) {
5123 - $results['missing'][] = array(
5124 - 'file' => 'themes/' . $theme_slug . '/' . $file,
5125 - 'type' => 'theme',
5126 - 'theme' => $theme->get( 'Name' ),
5127 - );
5128 3881 continue;
5129 3882 }
5130 3883
5131 3884 if ( ! $this->hash_matches_published( $file_path, $expected_hash ) ) {
@@ -5148,32 +3901,11 @@
5148 3901 $suspicious_only = ! $has_checksums;
5149 3902 $extra_results = $this->detect_extra_files( $theme_dir, $known_files, 'theme', $theme->get( 'Name' ), $suspicious_only );
5150 3903 $results['extra'] = array_merge( $results['extra'] ?? array(), $extra_results['extra'] );
5151 3904 $results['suspicious'] = array_merge( $results['suspicious'] ?? array(), $extra_results['suspicious'] );
5152 - $results['errors'] = array_merge( $results['errors'] ?? array(), $extra_results['errors'] ?? array() );
5153 3905 }
5154 -
5155 - // Same as for plugins: a whole turn, with time left.
5156 - if ( ! $this->is_time_exceeded() ) {
5157 - $finished[ $theme_slug ] = true;
5158 -
5159 - if ( ! $this->is_path_excluded( $theme_dir . '/.' ) ) {
5160 - $results['checked'][] = $this->relative_path( $theme_dir );
5161 - }
5162 - }
5163 3906 }
5164 3907
5165 - // The themes the time did not reach, by name.
5166 - $results['not_checked'] = array();
5167 -
5168 - foreach ( $themes as $theme_slug => $theme ) {
5169 - if ( isset( $finished[ $theme_slug ] ) || $this->in_post_update_grace( 'theme', $theme_slug ) ) {
5170 - continue;
5171 - }
5172 -
5173 - $results['not_checked'][] = (string) $theme->get( 'Name' );
5174 - }
5175 -
5176 3908 return $results;
5177 3909 }
5178 3910
5179 3911 /**
@@ -5183,9 +3915,9 @@
5183 3915 * @param string $version Theme version.
5184 3916 * @return array|WP_Error
5185 3917 */
5186 3918 private function get_theme_checksums( $slug, $version ) {
5187 - $cache_key = 'vigilante_theme_package_' . md5( $slug . $version );
3919 + $cache_key = 'vigilante_theme_checksums_' . md5( $slug . $version );
5188 3920
5189 3921 // Bypass the cache during the post-update grace window (see plugin path).
5190 3922 $grace = $this->in_post_update_grace( 'theme', $slug );
5191 3923
@@ -5195,103 +3927,48 @@
5195 3927 return $cached;
5196 3928 }
5197 3929 }
5198 3930
5199 - /*
5200 - * WordPress.org publishes checksums for plugins and for core, and none
5201 - * for themes: the address this method asked until now answers 404 for
5202 - * every theme, so no theme was ever compared with anything and they
5203 - * were all treated as themes from elsewhere. What WordPress.org does
5204 - * publish is the package of each version, so the checksums are taken
5205 - * from there: the package is downloaded once per version, each file in
5206 - * it is hashed straight from the archive, nothing is unpacked to disk,
5207 - * and the result is kept for a month.
5208 - */
5209 - if ( '' === (string) $version || ! preg_match( '/^[A-Za-z0-9._-]+$/', (string) $slug ) ) {
5210 - return new WP_Error( 'not_found', __( 'Checksums not available', 'vigilante' ) );
5211 - }
3931 + $url = sprintf(
3932 + 'https://downloads.wordpress.org/theme-checksums/%s/%s.json',
3933 + $slug,
3934 + $version
3935 + );
5212 3936
5213 - if ( ! class_exists( 'ZipArchive' ) ) {
5214 - return new WP_Error( 'no_zip', __( 'the zip extension of PHP is missing on this server', 'vigilante' ) );
5215 - }
3937 + $response = wp_remote_get( $url, array( 'timeout' => 10 ) );
5216 3938
5217 - if ( ! function_exists( 'download_url' ) ) {
5218 - require_once ABSPATH . 'wp-admin/includes/file.php';
3939 + if ( is_wp_error( $response ) ) {
3940 + return $response;
5219 3941 }
5220 3942
5221 - $package = download_url(
5222 - sprintf( 'https://downloads.wordpress.org/theme/%s.%s.zip', rawurlencode( $slug ), rawurlencode( (string) $version ) ),
5223 - 30
5224 - );
5225 -
5226 - if ( is_wp_error( $package ) ) {
5227 - $data = $package->get_error_data();
5228 -
5229 - // WordPress.org has no such theme or no such version: a theme from
5230 - // somewhere else. Anything other than a 404 is a failed download,
5231 - // and is reported as that instead of passing for a theme with no
5232 - // official copy.
5233 - if ( is_array( $data ) && isset( $data['code'] ) && 404 === (int) $data['code'] ) {
5234 - if ( ! $grace ) {
5235 - set_transient( $cache_key, 'not_found', 12 * HOUR_IN_SECONDS );
5236 - }
5237 - return new WP_Error( 'not_found', __( 'Checksums not available', 'vigilante' ) );
3943 + $status = wp_remote_retrieve_response_code( $response );
3944 + if ( 200 !== $status ) {
3945 + // Cache "not found" to avoid repeated requests (never during grace).
3946 + if ( ! $grace ) {
3947 + set_transient( $cache_key, 'not_found', HOUR_IN_SECONDS );
5238 3948 }
5239 -
5240 - return $package;
3949 + return new WP_Error( 'not_found', __( 'Checksums not available', 'vigilante' ) );
5241 3950 }
5242 3951
5243 - $checksums = array();
5244 - $archive = new ZipArchive();
3952 + $body = json_decode( wp_remote_retrieve_body( $response ), true );
5245 3953
5246 - if ( true === $archive->open( $package ) ) {
5247 - $prefix = $slug . '/';
5248 - $max_entry = 16 * MB_IN_BYTES;
5249 -
5250 - for ( $i = 0; $i < $archive->numFiles; $i++ ) {
5251 - $entry = $archive->statIndex( $i );
5252 - $name = is_array( $entry ) ? (string) $entry['name'] : '';
5253 -
5254 - // Everything in the package lives under the folder of the theme.
5255 - // Folders, anything outside it and any path with a ".."
5256 - // segment are not files of the theme.
5257 - if ( '' === $name || '/' === substr( $name, -1 ) || 0 !== strpos( $name, $prefix ) || preg_match( '#(?:^|/)\.\.(?:/|$)#', $name ) ) {
5258 - continue;
5259 - }
5260 -
5261 - // Read up to a limit: an entry larger than this is not code,
5262 - // and is left out of the comparison instead of being loaded
5263 - // whole into memory.
5264 - $content = $archive->getFromIndex( $i, $max_entry + 1 );
5265 -
5266 - if ( false === $content ) {
5267 - $checksums = array();
5268 - break;
5269 - }
5270 -
5271 - if ( strlen( $content ) > $max_entry ) {
5272 - continue;
5273 - }
5274 -
5275 - $checksums[ substr( $name, strlen( $prefix ) ) ] = array(
5276 - 'md5' => md5( $content ),
5277 - 'sha256' => hash( 'sha256', $content ),
5278 - );
5279 - }
5280 -
5281 - $archive->close();
3954 + if ( empty( $body['files'] ) ) {
3955 + return new WP_Error( 'no_checksums', __( 'No checksums found', 'vigilante' ) );
5282 3956 }
5283 3957
5284 - wp_delete_file( $package );
5285 -
5286 - if ( empty( $checksums ) ) {
5287 - return new WP_Error( 'no_checksums', __( 'the package from WordPress.org could not be read', 'vigilante' ) );
3958 + // Store the full per-file record (md5 + sha256), either of which may be a
3959 + // string or an array; hash_matches_published() handles both shapes.
3960 + $checksums = array();
3961 + foreach ( $body['files'] as $file => $data ) {
3962 + $checksums[ $file ] = array(
3963 + 'md5' => isset( $data['md5'] ) ? $data['md5'] : null,
3964 + 'sha256' => isset( $data['sha256'] ) ? $data['sha256'] : null,
3965 + );
5288 3966 }
5289 3967
5290 - // The package of a published version does not change, so it is kept
5291 - // for a month (never during grace).
3968 + // Cache for 24 hours (never during grace).
5292 3969 if ( ! $grace ) {
5293 - set_transient( $cache_key, $checksums, MONTH_IN_SECONDS );
3970 + set_transient( $cache_key, $checksums, DAY_IN_SECONDS );
5294 3971 }
5295 3972
5296 3973 return $checksums;
5297 3974 }
@@ -5304,56 +3981,36 @@
5304 3981 private function scan_uploads() {
5305 3982 $found = array(
5306 3983 'suspicious' => array(),
5307 3984 'extra' => array(),
5308 - 'errors' => array(),
5309 3985 );
5310 3986 $upload_dir = wp_upload_dir();
5311 3987 $base_dir = $upload_dir['basedir'];
3988 + $max_files = 10000; // Increased limit for thorough scanning
5312 3989 $files_checked = 0;
5313 3990
5314 - // The walk used to stop after 10,000 files without saying so, and a
5315 - // media library passes that with a few thousand images. Now it goes on
5316 - // for as long as its share of the scan time lasts, and says so when it
5317 - // is cut short. The share keeps a huge library from leaving the core
5318 - // and the plugins without their turn.
5319 - $deadline = $this->scan_start_time > 0 ? $this->scan_start_time + ( 0.4 * $this->max_scan_time ) : 0;
5320 -
5321 3991 // Executable extensions that should never be in uploads
5322 - $dangerous_extensions = self::php_extensions();
3992 + $dangerous_extensions = array( 'php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar', 'phps' );
5323 3993
5324 - // Files that change how PHP runs in the folder they are in.
5325 - $php_config_files = array( '.user.ini', 'php.ini' );
5326 -
5327 - // Scripts a server can be told to run through CGI.
5328 - $script_extensions = array( 'cgi', 'pl', 'py', 'sh', 'shtml' );
5329 -
5330 3994 if ( ! is_dir( $base_dir ) ) {
5331 3995 return $found;
5332 3996 }
5333 3997
5334 3998 try {
5335 - foreach ( self::walk_folder( $base_dir ) as $file ) {
3999 + $iterator = new RecursiveIteratorIterator(
4000 + new RecursiveDirectoryIterator( $base_dir, RecursiveDirectoryIterator::SKIP_DOTS ),
4001 + RecursiveIteratorIterator::LEAVES_ONLY
4002 + );
4003 +
4004 + foreach ( $iterator as $file ) {
5336 4005 // Check global time limit
5337 4006 if ( $this->is_time_exceeded() ) {
5338 4007 break;
5339 4008 }
5340 4009
5341 - if ( $file->isDir() ) {
5342 - if ( ! $file->isReadable() ) {
5343 - $found['errors'][] = self::unreadable_folder_error( $file->getPathname() );
5344 - }
5345 - continue;
5346 - }
5347 -
5348 - // Check the share of the scan time (every 500 files)
4010 + // Check file limit
5349 4011 $files_checked++;
5350 - if ( $deadline > 0 && 0 === $files_checked % 500 && microtime( true ) > $deadline ) {
5351 - $found['errors'][] = sprintf(
5352 - /* translators: %s: Number of files */
5353 - __( 'The uploads folder was not checked completely: the time for it ran out after %s files.', 'vigilante' ),
5354 - number_format_i18n( $files_checked )
5355 - );
4012 + if ( $files_checked > $max_files ) {
5356 4013 break;
5357 4014 }
5358 4015
5359 4016 $file_path = $file->getPathname();
@@ -5378,12 +4035,15 @@
5378 4035
5379 4036 $reason = __( 'PHP file found in uploads directory', 'vigilante' );
5380 4037
5381 4038 // Scan content for specific suspicious patterns
5382 - $pattern = $this->search_file( $file_path );
5383 - if ( $pattern ) {
5384 - /* translators: %s: Suspicious pattern found */
5385 - $reason = sprintf( __( 'PHP in uploads with suspicious code: %s', 'vigilante' ), $pattern );
4039 + if ( $file->getSize() < 512000 ) { // Only scan files < 500KB
4040 + $content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
4041 + $pattern = $this->detect_suspicious_pattern( $content );
4042 + if ( $pattern ) {
4043 + /* translators: %s: Suspicious pattern found */
4044 + $reason = sprintf( __( 'PHP in uploads with suspicious code: %s', 'vigilante' ), $pattern );
4045 + }
5386 4046 }
5387 4047
5388 4048 $found['suspicious'][] = array(
5389 4049 'file' => $relative,
@@ -5393,12 +4053,9 @@
5393 4053 continue;
5394 4054 }
5395 4055
5396 4056 // 2. Check for double extensions (image.php.jpg, file.phtml.png)
5397 - // The second extension can be anything: a script renamed by a
5398 - // malware cleaner (shell.php.suspected) or numbered (shell.php.1)
5399 - // is as much of a finding as one dressed up as an image.
5400 - if ( preg_match( '/\.(' . implode( '|', $dangerous_extensions ) . ')\.[a-z0-9_~-]{1,16}$/i', $basename ) ) {
4057 + if ( preg_match( '/\.(' . implode( '|', $dangerous_extensions ) . ')\.[a-z]{2,4}$/i', $basename ) ) {
5401 4058 $found['suspicious'][] = array(
5402 4059 'file' => $relative,
5403 4060 'type' => 'double_extension',
5404 4061 'reason' => __( 'Double extension detected (possible disguised executable)', 'vigilante' ),
@@ -5405,41 +4062,17 @@
5405 4062 );
5406 4063 continue;
5407 4064 }
5408 4065
5409 - // 2b. Files that change how PHP runs in this folder
5410 - if ( in_array( strtolower( $basename ), $php_config_files, true ) ) {
5411 - $found['suspicious'][] = array(
5412 - 'file' => $relative,
5413 - 'type' => 'php_config_in_uploads',
5414 - 'reason' => __( 'PHP configuration file in uploads', 'vigilante' ),
5415 - );
5416 - continue;
5417 - }
5418 -
5419 - // 2c. Scripts in other languages
5420 - if ( in_array( $extension, $script_extensions, true ) ) {
5421 - $found['extra'][] = array(
5422 - 'file' => $relative,
5423 - 'type' => 'script_in_uploads',
5424 - 'reason' => __( 'Script file in uploads', 'vigilante' ),
5425 - );
5426 - continue;
5427 - }
5428 -
5429 4066 // 3. Check for .htaccess files in uploads
5430 - // Read content to classify (see classify_htaccess_in_uploads())
4067 + // Read content to classify: dangerous rules = suspicious, protective rules = extra
5431 4068 if ( '.htaccess' === $basename ) {
5432 4069 $htaccess_result = $this->classify_htaccess_in_uploads( $file_path, $relative );
5433 -
5434 - // One that only denies access, or does nothing, is not a finding.
5435 - if ( 'none' !== $htaccess_result['category'] ) {
5436 - $found[ $htaccess_result['category'] ][] = $htaccess_result['item'];
5437 - }
4070 + $found[ $htaccess_result['category'] ][] = $htaccess_result['item'];
5438 4071 }
5439 4072 }
5440 4073 } catch ( Exception $e ) {
5441 - $found['errors'][] = self::unreadable_folder_error( $base_dir );
4074 + // Ignore iterator errors
5442 4075 }
5443 4076
5444 4077 return $found;
5445 4078 }
@@ -5446,515 +4079,94 @@
5446 4079
5447 4080 /**
5448 4081 * Classify a .htaccess file found in uploads directory
5449 4082 *
5450 - * The file is read the way Apache reads it: continued lines are joined,
5451 - * comments are dropped and each directive is looked at on its own. That
5452 - * replaces a handful of expressions run over the whole text, which took a
5453 - * commented line for a live one and the rule that forbids PHP for one that
5454 - * runs it, and let through the directives that turn CGI on.
4083 + * Reads the file content to determine if it contains dangerous rules
4084 + * (enabling PHP execution, rewriting to executables) or protective rules
4085 + * (deny access, disable indexes). Dangerous = suspicious, protective = extra.
5455 4086 *
5456 - * - Something that makes the server run code: suspicious.
5457 - * - Only directives that deny or switch something off: not reported.
5458 - * - Rewrite rules of its own and nothing that denies PHP: extra, because in
5459 - * that folder they replace the rules of the site root, the one that
5460 - * blocks PHP in uploads included (measured on Apache, 3 Oct 2026).
5461 - * - Anything else: extra, with what it contains.
5462 - *
5463 4087 * @param string $file_path Absolute file path.
5464 4088 * @param string $relative Relative file path for display.
5465 - * @return array Array with 'category' ('suspicious', 'extra' or 'none') and 'item' data.
4089 + * @return array Array with 'category' ('suspicious' or 'extra') and 'item' data.
5466 4090 */
5467 4091 private function classify_htaccess_in_uploads( $file_path, $relative ) {
5468 - $content = false;
4092 + $content = '';
5469 4093
5470 4094 if ( filesize( $file_path ) < 65536 ) { // Only read files < 64KB
5471 4095 $content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
5472 4096 }
5473 4097
5474 - $item = array(
5475 - 'file' => $relative,
5476 - 'type' => 'htaccess_in_uploads',
5477 - );
5478 -
5479 - // A file that cannot be read cannot be vouched for.
5480 - if ( false === $content ) {
5481 - $item['reason'] = __( '.htaccess file in uploads directory that could not be read', 'vigilante' );
5482 -
4098 + // If we can't read it or it's empty, treat as suspicious (unknown)
4099 + if ( empty( trim( $content ) ) ) {
5483 4100 return array(
5484 4101 'category' => 'suspicious',
5485 - 'item' => $item,
4102 + 'item' => array(
4103 + 'file' => $relative,
4104 + 'type' => 'htaccess_in_uploads',
4105 + 'reason' => __( '.htaccess file in uploads directory (empty or unreadable)', 'vigilante' ),
4106 + ),
5486 4107 );
5487 4108 }
5488 4109
5489 - $facts = self::read_htaccess_rules( $content );
5490 -
5491 - if ( ! empty( $facts['runs'] ) ) {
5492 - $item['reason'] = self::htaccess_run_reason( $facts );
5493 -
5494 - return array(
5495 - 'category' => 'suspicious',
5496 - 'item' => $item,
5497 - );
5498 - }
5499 -
5500 - if ( $facts['rewrite'] && ! $facts['denies_php'] ) {
5501 - $item['reason'] = __( '.htaccess in uploads with rewrite rules of its own: in this folder they replace the ones of the site root, the rule that blocks PHP in uploads included', 'vigilante' );
5502 -
5503 - return array(
5504 - 'category' => 'extra',
5505 - 'item' => $item,
5506 - );
5507 - }
5508 -
5509 - // Empty, or nothing in it that does not deny or switch something off:
5510 - // it protects the folder, or does nothing, and is not a finding.
5511 - if ( empty( $facts['other'] ) ) {
5512 - return array(
5513 - 'category' => 'none',
5514 - 'item' => null,
5515 - );
5516 - }
5517 -
5518 - $kinds = array(
5519 - 'access' => __( 'access rules', 'vigilante' ),
5520 - 'rewrite' => __( 'rewrite rules', 'vigilante' ),
5521 - 'headers' => __( 'cache or header rules', 'vigilante' ),
5522 - 'other' => __( 'other rules', 'vigilante' ),
4110 + // Dangerous patterns: rules that enable code execution or rewrite to executables
4111 + $dangerous_patterns = array(
4112 + '/AddHandler\s+.*(php|cgi|pl|py)/i' => 'AddHandler enabling script execution',
4113 + '/AddType\s+application\/x-httpd-php/i' => 'AddType enabling PHP execution',
4114 + '/SetHandler\s+.*(php|cgi)/i' => 'SetHandler enabling script execution',
4115 + '/php_flag\s+engine\s+on/i' => 'PHP engine enabled',
4116 + '/php_admin_flag\s+engine\s+on/i' => 'PHP admin engine enabled',
4117 + '/RewriteRule\s+.*\.(php|phtml|phar)/i' => 'Rewrite rule targeting PHP files',
4118 + '/auto_prepend_file/i' => 'auto_prepend_file directive',
4119 + '/auto_append_file/i' => 'auto_append_file directive',
5523 4120 );
5524 4121
5525 - /* translators: %s: Summary of rules found in the .htaccess file */
5526 - $item['reason'] = sprintf( __( '.htaccess in uploads (likely from plugin). Contains: %s', 'vigilante' ), implode( ', ', array_intersect_key( $kinds, $facts['other'] ) ) );
5527 -
5528 - return array(
5529 - 'category' => 'extra',
5530 - 'item' => $item,
5531 - );
5532 - }
5533 -
5534 - /**
5535 - * The reason shown for a .htaccess that makes the server run code
5536 - *
5537 - * @param array $facts What read_htaccess_rules() found.
5538 - * @return string
5539 - */
5540 - private static function htaccess_run_reason( $facts ) {
5541 - $labels = array(
5542 - 'run' => __( 'makes the server run files of this folder as code', 'vigilante' ),
5543 - 'load' => __( 'loads a file on every PHP request', 'vigilante' ),
5544 - 'rewrite' => __( 'sends requests to a PHP file', 'vigilante' ),
5545 - 'cgi' => __( 'turns on CGI or server-side includes', 'vigilante' ),
5546 - );
5547 -
5548 - /* translators: %s: What the rule does */
5549 - return sprintf( __( '.htaccess with a rule that affects how code runs: %s', 'vigilante' ), implode( '; ', array_intersect_key( $labels, $facts['runs'] ) ) );
5550 - }
5551 -
5552 - /**
5553 - * What a section of a .htaccess says about the directives inside it
5554 - *
5555 - * @param string $name Section name, in lower case.
5556 - * @param string $args What follows the name, as written.
5557 - * @return string '' when it changes nothing (<RequireAll>), 'rewrite' for
5558 - * <IfModule mod_rewrite.c>, 'maybe' for any other test of
5559 - * what the server has, 'files-php' for a <Files> or
5560 - * <FilesMatch> that names PHP files, 'files' for one that
5561 - * names other files, and 'condition' for the rest (<Limit>,
5562 - * <If>, <RequireAny>...), which apply to some requests only.
5563 - */
5564 - private static function htaccess_section_kind( $name, $args ) {
5565 - $args = trim( rtrim( trim( $args ), '>' ) );
5566 -
5567 - if ( 'requireall' === $name ) {
5568 - return '';
5569 - }
5570 -
5571 - if ( 'ifmodule' === $name ) {
5572 - return in_array( strtolower( trim( $args, '"\' ' ) ), array( 'mod_rewrite.c', 'rewrite_module' ), true ) ? 'rewrite' : 'maybe';
5573 - }
5574 -
5575 - if ( in_array( $name, array( 'ifdefine', 'ifversion' ), true ) ) {
5576 - return 'maybe';
5577 - }
5578 -
5579 - if ( 'files' === $name || 'filesmatch' === $name ) {
5580 - $regex = 'filesmatch' === $name;
5581 -
5582 - if ( ! $regex && '~' === substr( $args, 0, 1 ) ) {
5583 - $regex = true;
5584 - $args = trim( substr( $args, 1 ) );
4122 + foreach ( $dangerous_patterns as $pattern => $label ) {
4123 + if ( preg_match( $pattern, $content ) ) {
4124 + return array(
4125 + 'category' => 'suspicious',
4126 + 'item' => array(
4127 + 'file' => $relative,
4128 + 'type' => 'htaccess_in_uploads',
4129 + /* translators: %s: Dangerous rule description */
4130 + 'reason' => sprintf( __( '.htaccess with dangerous rule: %s', 'vigilante' ), $label ),
4131 + ),
4132 + );
5585 4133 }
5586 -
5587 - return true === self::extension_pattern_names_php( trim( $args, '"\'' ), $regex, false ) ? 'files-php' : 'files';
5588 4134 }
5589 4135
5590 - return 'condition';
5591 - }
4136 + // Identify what protective/benign rules it contains for informational display
4137 + $found_rules = array();
5592 4138
5593 - /**
5594 - * Whether a pattern that goes only by the extension of a file names the PHP ones
5595 - *
5596 - * "Every PHP file" cannot be asked of a sample name. A sample is public,
5597 - * so a section can be written for it and nothing else, and an expression
5598 - * can name every file but one ("all but shell.php") in more ways than can
5599 - * be tried. So the pattern has to be of a kind that cannot tell one name
5600 - * from another: any name, a dot and a list of extensions, to the end. In
5601 - * that kind there is nothing to make an exception with, and whether .php
5602 - * is on the list is then a fair question for a sample. Anything else is
5603 - * not taken for a denial of PHP, which only means the .htaccess is listed.
5604 - *
5605 - * @param string $pattern Pattern, without its quotes.
5606 - * @param bool $regex Whether it is an expression (FilesMatch, "Files ~",
5607 - * RewriteRule) or a wildcard (Files).
5608 - * @param bool $nocase Whether it ignores case.
5609 - * @return bool|null True when it names every .php file, false when it goes
5610 - * by the extension and .php is not one of them, null
5611 - * when it is not of that kind.
5612 - */
5613 - private static function extension_pattern_names_php( $pattern, $regex, $nocase ) {
5614 - if ( ! $regex ) {
5615 - if ( 1 !== preg_match( '/^\*\.[a-z0-9*?]+$/i', $pattern ) ) {
5616 - return null;
5617 - }
4139 + $benign_patterns = array(
4140 + '/Deny\s+from\s+all/i' => 'Deny from all',
4141 + '/Require\s+all\s+denied/i' => 'Require all denied',
4142 + '/Options\s+.*-Indexes/i' => 'Options -Indexes',
4143 + '/Header\s+set/i' => 'Header rules',
4144 + '/ExpiresActive/i' => 'Expires/cache rules',
4145 + '/RewriteEngine/i' => 'Rewrite rules',
4146 + '/FilesMatch/i' => 'FilesMatch rules',
4147 + '/ForceType\s+application\/octet/i' => 'ForceType (force download)',
4148 + );
5618 4149
5619 - return function_exists( 'fnmatch' ) ? fnmatch( $pattern, 'x.php' ) : '*.php' === $pattern;
5620 - }
5621 -
5622 - $rest = $pattern;
5623 -
5624 - if ( 0 === stripos( $rest, '(?i)' ) ) {
5625 - $rest = substr( $rest, 4 );
5626 - $nocase = true;
5627 - }
5628 -
5629 - // "Any name" in front, written out or left out, and then a dot, the
5630 - // extensions and the end, written out or left out too. The extensions
5631 - // are letters, digits, groups, alternatives and classes of letters and
5632 - // digits: no dot, no "any character", nothing negated and nothing
5633 - // that looks around.
5634 - $rest = preg_replace( '/^\^?\.[*+]/', '', $rest, 1 );
5635 -
5636 - if ( 1 !== preg_match( '/^\\\\\.(?:[a-z0-9|?*+)]|\\\\d|\[[a-z0-9-]+\]|\((?:\?i?:)?)+\$?$/i', (string) $rest ) ) {
5637 - return null;
5638 - }
5639 -
5640 - // The dot has to be there: with "?" or "*" after it, what follows
5641 - // would be looked for anywhere in the name. And an alternative
5642 - // outside a group is a second pattern with no dot in front of it,
5643 - // which goes by the name again.
5644 - if ( 1 === preg_match( '/^\\\\\.[?*+]/', $rest ) ) {
5645 - return null;
5646 - }
5647 -
5648 - $depth = 0;
5649 -
5650 - for ( $i = 0, $length = strlen( $rest ); $i < $length; $i++ ) {
5651 - if ( '(' === $rest[ $i ] ) {
5652 - $depth++;
5653 - } elseif ( ')' === $rest[ $i ] ) {
5654 - $depth--;
4150 + foreach ( $benign_patterns as $pattern => $label ) {
4151 + if ( preg_match( $pattern, $content ) ) {
4152 + $found_rules[] = $label;
5655 4153 }
5656 -
5657 - if ( $depth < 0 || ( 0 === $depth && '|' === $rest[ $i ] ) ) {
5658 - return null;
5659 - }
5660 4154 }
5661 4155
5662 - if ( 0 !== $depth ) {
5663 - return null;
5664 - }
4156 + $rules_summary = ! empty( $found_rules )
4157 + ? implode( ', ', $found_rules )
4158 + : __( 'Custom rules', 'vigilante' );
5665 4159
5666 - $expression = '~' . str_replace( '~', '\\~', $pattern ) . '~' . ( $nocase ? 'i' : '' );
5667 -
5668 - foreach ( array( 'x.php', 'a/b/x.php' ) as $sample ) {
5669 - $matched = @preg_match( $expression, $sample ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A pattern Apache would refuse names nothing.
5670 -
5671 - if ( false === $matched ) {
5672 - return null;
5673 - }
5674 -
5675 - if ( 0 === $matched ) {
5676 - return false;
5677 - }
5678 - }
5679 -
5680 - return true;
5681 - }
5682 -
5683 - /**
5684 - * Whether a RewriteRule that refuses requests refuses every PHP file
5685 - *
5686 - * A pattern with a "!" in front refuses what it does not match, so it
5687 - * refuses PHP when it goes by the extension and .php is not one of them
5688 - * ("!\.(jpe?g|png)$": everything but the images).
5689 - *
5690 - * @param string $pattern Pattern of the rule.
5691 - * @param string $flags Flags of the rule, in lower case.
5692 - * @return bool
5693 - */
5694 - private static function rewrite_rule_refuses_php( $pattern, $flags ) {
5695 - $pattern = trim( $pattern, '"\'' );
5696 - $negated = '' !== $pattern && '!' === $pattern[0];
5697 -
5698 - if ( $negated ) {
5699 - $pattern = substr( $pattern, 1 );
5700 - }
5701 -
5702 - if ( '' === $pattern ) {
5703 - return false;
5704 - }
5705 -
5706 - $names = self::extension_pattern_names_php( $pattern, true, (bool) preg_match( '/[\[,]\s*(?:nc|nocase)\s*[,\]]/', $flags ) );
5707 -
5708 - return $negated ? false === $names : true === $names;
5709 - }
5710 -
5711 - /**
5712 - * What the directives of a .htaccess do, as far as running code goes
5713 - *
5714 - * @param string $content Content of the file.
5715 - * @return array {
5716 - * @type array $runs Ways it makes the server run code, as keys:
5717 - * 'run', 'load', 'rewrite', 'cgi'.
5718 - * @type array $other Kinds of directive that neither run code nor
5719 - * only deny, as keys: 'access', 'rewrite',
5720 - * 'headers', 'other'.
5721 - * @type bool $rewrite Whether it has rewrite directives of its own.
5722 - * @type bool $denies_php Whether it denies access to every PHP file
5723 - * itself, for every request and whatever the
5724 - * server has loaded.
5725 - * }
5726 - */
5727 - private static function read_htaccess_rules( $content ) {
5728 - $facts = array(
5729 - 'runs' => array(),
5730 - 'other' => array(),
5731 - 'rewrite' => false,
5732 - 'denies_php' => false,
4160 + return array(
4161 + 'category' => 'extra',
4162 + 'item' => array(
4163 + 'file' => $relative,
4164 + 'type' => 'htaccess_in_uploads',
4165 + /* translators: %s: Summary of rules found in the .htaccess file */
4166 + 'reason' => sprintf( __( '.htaccess in uploads (likely from plugin). Contains: %s', 'vigilante' ), $rules_summary ),
4167 + ),
5733 4168 );
5734 -
5735 - // A line that ends in a backslash goes on in the next one, and Apache
5736 - // joins the two before it looks at anything else, comments included.
5737 - $content = str_replace( array( "\r\n", "\r" ), "\n", (string) $content );
5738 - $content = preg_replace( '/(?<!\\\\)\\\\\n/', '', $content );
5739 -
5740 - // "script" as a word of its own: cgi-script and php-script are
5741 - // handlers that run code, application/javascript is a kind of file.
5742 - $scripts = '/php|cgi|(?<![a-z])script|lsapi|server-parsed|proxy:|python-program/';
5743 - $php_target = '/\.(?:ph(?:p\d*|tml|t|ar|ps)|inc)(?![a-z0-9])/';
5744 - $containers = array();
5745 - $cgi_handler = false;
5746 - $execcgi_on = false;
5747 - $execcgi_off = false;
5748 -
5749 - foreach ( explode( "\n", (string) $content ) as $line ) {
5750 - $line = trim( $line );
5751 -
5752 - if ( '' === $line || '#' === $line[0] ) {
5753 - continue;
5754 - }
5755 -
5756 - // Sections are remembered, to know what a directive inside them
5757 - // applies to (see htaccess_section_kind()).
5758 - if ( '<' === $line[0] ) {
5759 - if ( preg_match( '/^<\s*\//', $line ) ) {
5760 - array_pop( $containers );
5761 - } elseif ( preg_match( '/^<\s*([A-Za-z]+)\b(.*)$/', $line, $section ) ) {
5762 - $containers[] = self::htaccess_section_kind( strtolower( $section[1] ), $section[2] );
5763 - }
5764 - continue;
5765 - }
5766 -
5767 - if ( ! preg_match( '/^([A-Za-z_]+)\s*(.*)$/', $line, $parts ) ) {
5768 - $facts['other']['other'] = true;
5769 - continue;
5770 - }
5771 -
5772 - $name = strtolower( $parts[1] );
5773 - $args = trim( $parts[2] );
5774 - $low = strtolower( $args );
5775 - $words = preg_split( '/\s+/', $low, -1, PREG_SPLIT_NO_EMPTY );
5776 - $first = isset( $words[0] ) ? trim( $words[0], '"\'' ) : '';
5777 -
5778 - // The sections around this line that put a condition on it.
5779 - $inside = array_values( array_diff( $containers, array( '' ) ) );
5780 -
5781 - switch ( $name ) {
5782 - case 'sethandler':
5783 - case 'addhandler':
5784 - case 'addtype':
5785 - case 'forcetype':
5786 - if ( preg_match( $scripts, $first ) ) {
5787 - // "AddHandler cgi-script" next to "Options -ExecCGI" is
5788 - // the old recipe that hands every script to CGI and
5789 - // forbids CGI. Which of the two it is gets decided
5790 - // once the whole file has been read.
5791 - if ( 'addhandler' === $name && false !== strpos( $first, 'cgi' ) ) {
5792 - $cgi_handler = true;
5793 - } else {
5794 - $facts['runs']['run'] = true;
5795 - }
5796 - } elseif ( ! in_array( $first, array( 'none', 'default-handler', 'text/plain', 'application/octet-stream' ), true ) ) {
5797 - $facts['other']['other'] = true;
5798 - }
5799 - break;
5800 -
5801 - case 'php_flag':
5802 - case 'php_admin_flag':
5803 - case 'php_value':
5804 - case 'php_admin_value':
5805 - $value = isset( $words[1] ) ? trim( $words[1], '"\'' ) : '';
5806 -
5807 - if ( 'engine' === $first ) {
5808 - if ( ! in_array( $value, array( 'off', '0', 'false', 'no' ), true ) ) {
5809 - $facts['runs']['run'] = true;
5810 - }
5811 - } elseif ( in_array( $first, array( 'auto_prepend_file', 'auto_append_file' ), true ) ) {
5812 - if ( '' !== $value && 'none' !== $value ) {
5813 - $facts['runs']['load'] = true;
5814 - }
5815 - } else {
5816 - $facts['other']['other'] = true;
5817 - }
5818 - break;
5819 -
5820 - case 'options':
5821 - foreach ( $words as $option ) {
5822 - // Only a ban on CGI that holds for everybody counts as one.
5823 - if ( '-execcgi' === $option && empty( $inside ) ) {
5824 - $execcgi_off = true;
5825 - }
5826 -
5827 - if ( '-' === $option[0] || 'none' === $option ) {
5828 - continue;
5829 - }
5830 -
5831 - $option = ltrim( $option, '+' );
5832 -
5833 - if ( in_array( $option, array( 'execcgi', 'includes', 'all' ), true ) ) {
5834 - $facts['runs']['cgi'] = true;
5835 - $execcgi_on = true;
5836 - } else {
5837 - $facts['other']['other'] = true;
5838 - }
5839 - }
5840 - break;
5841 -
5842 - case 'addoutputfilter':
5843 - case 'setoutputfilter':
5844 - case 'addoutputfilterbytype':
5845 - if ( false !== strpos( $low, 'includes' ) ) {
5846 - $facts['runs']['cgi'] = true;
5847 - } else {
5848 - $facts['other']['other'] = true;
5849 - }
5850 - break;
5851 -
5852 - case 'action':
5853 - $facts['runs']['cgi'] = true;
5854 - break;
5855 -
5856 - case 'xbithack':
5857 - // Server-side includes for every file with the execute bit.
5858 - if ( 'off' !== $first ) {
5859 - $facts['runs']['cgi'] = true;
5860 - }
5861 - break;
5862 -
5863 - case 'deny':
5864 - case 'require':
5865 - $denies = 'deny' === $name ? ( 'from' === $first && isset( $words[1] ) && 'all' === $words[1] ) : ( 'all' === $first && isset( $words[1] ) && 'denied' === $words[1] );
5866 -
5867 - if ( ! $denies || in_array( 'condition', $inside, true ) ) {
5868 - $facts['other']['access'] = true;
5869 - } elseif ( ! array_diff( $inside, array( 'rewrite', 'files-php' ) ) ) {
5870 - // For every request, on any server, and for every file
5871 - // or for the PHP ones. A denial that depends on a
5872 - // module being there, or that names other files, still
5873 - // protects, but it does not make up for rewrite rules
5874 - // that switch off the ones of the site root.
5875 - $facts['denies_php'] = true;
5876 - }
5877 - break;
5878 -
5879 - case 'order':
5880 - case 'indexignore':
5881 - break;
5882 -
5883 - case 'allow':
5884 - case 'satisfy':
5885 - case 'authtype':
5886 - case 'authname':
5887 - case 'authuserfile':
5888 - case 'authgroupfile':
5889 - $facts['other']['access'] = true;
5890 - break;
5891 -
5892 - case 'rewriteengine':
5893 - case 'rewritebase':
5894 - case 'rewriteoptions':
5895 - $facts['rewrite'] = true;
5896 - break;
5897 -
5898 - case 'rewritecond':
5899 - // A condition makes the rule after it apply to some
5900 - // requests and not to others, a denial included.
5901 - $facts['rewrite'] = true;
5902 - $facts['other']['rewrite'] = true;
5903 - break;
5904 -
5905 - case 'rewriterule':
5906 - $facts['rewrite'] = true;
5907 -
5908 - $rule = preg_split( '/\s+/', $args, 3 );
5909 - $pattern = isset( $rule[0] ) ? $rule[0] : '';
5910 - $target = isset( $rule[1] ) ? strtolower( trim( $rule[1], '"\'' ) ) : '';
5911 - $flags = isset( $rule[2] ) ? strtolower( $rule[2] ) : '';
5912 -
5913 - if ( preg_match( '/[\[,]\s*h=/', $flags ) || preg_match( '/[\[,]\s*t=[^,\]]*(?:php|cgi|(?<![a-z])script)/', $flags ) ) {
5914 - // A handler or a type forced from the rule itself.
5915 - $facts['runs']['run'] = true;
5916 - } elseif ( '-' === $target && preg_match( '/[\[,]\s*(?:f|forbidden|g|gone)\s*[,\]]/', $flags ) ) {
5917 - // Rewrites nothing and refuses the request. It stands
5918 - // for the rule of the site root only when it refuses
5919 - // every PHP file, for every request.
5920 - if ( ! array_diff( $inside, array( 'rewrite' ) ) && self::rewrite_rule_refuses_php( $pattern, $flags ) ) {
5921 - $facts['denies_php'] = true;
5922 - } else {
5923 - $facts['other']['rewrite'] = true;
5924 - }
5925 - } elseif ( '-' === $target ) {
5926 - // Rewrites nothing and lets the request through, which
5927 - // can be the way to keep one file out of the denial
5928 - // that follows: it is not part of a file that only
5929 - // protects.
5930 - $facts['other']['rewrite'] = true;
5931 - } elseif ( preg_match( $php_target, $target ) && '/index.php' !== $target ) {
5932 - $facts['runs']['rewrite'] = true;
5933 - } else {
5934 - $facts['other']['rewrite'] = true;
5935 - }
5936 - break;
5937 -
5938 - case 'header':
5939 - case 'requestheader':
5940 - case 'expiresactive':
5941 - case 'expiresbytype':
5942 - case 'expiresdefault':
5943 - case 'fileetag':
5944 - $facts['other']['headers'] = true;
5945 - break;
5946 -
5947 - default:
5948 - $facts['other']['other'] = true;
5949 - }
5950 - }
5951 -
5952 - if ( $cgi_handler && ( $execcgi_on || ! $execcgi_off ) ) {
5953 - $facts['runs']['cgi'] = true;
5954 - }
5955 -
5956 - return $facts;
5957 4169 }
5958 4170
5959 4171 /**
5960 4172 * Check content for suspicious patterns
@@ -5998,45 +4210,25 @@
5998 4210 foreach ( $patterns_data['standard_patterns'] as $encoded_needle => $label ) {
5999 4211 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Decoding pattern definitions, not user input.
6000 4212 $needle = base64_decode( $encoded_needle );
6001 4213 if ( $this->needle_present( $content, $needle ) ) {
6002 - // The labels live in the pattern file, in English: "$_GET access"
6003 - // becomes "$_GET", which needs no translation.
6004 - return preg_replace( '/ access$/', '', (string) $label );
4214 + return $label;
6005 4215 }
6006 4216 }
6007 4217
6008 - return $this->detect_common_pattern( $content );
6009 - }
6010 -
6011 - /**
6012 - * The three checks both levels of the search share
6013 - *
6014 - * @param string $content File content.
6015 - * @return string|false The pattern found, or false.
6016 - */
6017 - private function detect_common_pattern( $content ) {
6018 - // preg_replace with the /e modifier (arbitrary code execution,
6019 - // deprecated). The modifier is looked for on the same line and within
6020 - // 300 characters of the opening quote. With ".*" every preg_replace of
6021 - // a long line cost a pass over the whole line: 88 seconds for a file
6022 - // of 500 KB made for it, measured without the PCRE compiler.
6023 - if ( $this->found( '/preg_replace\s*+\(\s*+[\'"][^\n]{0,300}?\/e[\'"]/i', $content ) ) {
6024 - return __( 'preg_replace with the /e modifier, which runs code', 'vigilante' );
4218 + // Check for preg_replace with /e modifier (code execution)
4219 + if ( preg_match( '/preg_replace\s*\(\s*[\'"].*\/e[\'"]/i', $content ) ) {
4220 + return 'preg_replace /e modifier';
6025 4221 }
6026 4222
6027 - // Long hex-encoded strings (obfuscated payloads). Eleven escapes in a
6028 - // row and not "ten or more after the first": asked to take them all,
6029 - // PCRE gave up on a run of some 12,000, and a search that gave up
6030 - // read as "nothing found". The longer the payload, the less it was
6031 - // found.
6032 - if ( $this->found( '/(?:\\\\x[0-9a-f]{2}){11}/i', $content ) ) {
6033 - return __( 'a long string written in hexadecimal', 'vigilante' );
4223 + // Check for long hex-encoded strings (obfuscated payloads)
4224 + if ( preg_match( '/\\\\x[0-9a-f]{2}(\\\\x[0-9a-f]{2}){10,}/i', $content ) ) {
4225 + return 'hex-encoded string';
6034 4226 }
6035 4227
6036 - // Heavily concatenated chr() calls (char-by-char obfuscation)
6037 - if ( $this->found( '/chr\s*+\(\s*+\d++\s*+\)\s*+\.\s*+chr\s*+\(\s*+\d++\s*+\)\s*+\.\s*+chr/i', $content ) ) {
6038 - return __( 'text built character by character with chr()', 'vigilante' );
4228 + // Check for heavily concatenated chr() calls (char-by-char obfuscation)
4229 + if ( preg_match( '/chr\s*\(\s*\d+\s*\)\s*\.\s*chr\s*\(\s*\d+\s*\)\s*\.\s*chr/i', $content ) ) {
4230 + return 'chr() concatenation obfuscation';
6039 4231 }
6040 4232
6041 4233 return false;
6042 4234 }
@@ -6073,94 +4265,29 @@
6073 4265 $as = $fragments['as'] ?? '';
6074 4266 $ss = $fragments['ss'] ?? '';
6075 4267 $cf = $fragments['cf'] ?? '';
6076 4268
6077 - // Obfuscation combos: dangerous function wrapping decoded content.
6078 - // The labels name the two functions side by side and not one inside
6079 - // the other. Written the way the code is written, a label was itself
6080 - // a match: the results of a scan, kept in a cache that stores options
6081 - // as PHP files under wp-content, would have been the next finding.
6082 - // The possessive quantifiers ("*+") change nothing in what matches,
6083 - // because what follows each run of spaces is never a space. They keep
6084 - // PCRE from walking a long run back one space at a time, which is
6085 - // where it gives up when it runs without its compiler.
4269 + // Obfuscation combos: dangerous function wrapping decoded content
6086 4270 $obfuscation_combos = array(
6087 - '/' . $ev . '\s*+\(\s*+' . $b6 . '\s*+\(/i' => $ev . '() + ' . $b6 . '()',
6088 - '/' . $ev . '\s*+\(\s*+' . $gz . '\s*+\(/i' => $ev . '() + ' . $gz . '()',
6089 - '/' . $ev . '\s*+\(\s*+' . $gu . '\s*+\(/i' => $ev . '() + ' . $gu . '()',
6090 - '/' . $ev . '\s*+\(\s*+' . $sr . '\s*+\(/i' => $ev . '() + ' . $sr . '()',
6091 - '/' . $ev . '\s*+\(\s*+' . $hb . '\s*+\(/i' => $ev . '() + ' . $hb . '()',
6092 - '/' . $as . '\s*+\(\s*+' . $b6 . '\s*+\(/i' => $as . '() + ' . $b6 . '()',
6093 - '/' . $ev . '\s*+\(\s*+\$[a-z_]++\s*+\(/i' => $ev . '() + $variable()',
6094 - '/' . $ev . '\s*+\(\s*+' . $ss . '\s*+\(/i' => $ev . '() + ' . $ss . '()',
4271 + '/' . $ev . '\s*\(\s*' . $b6 . '\s*\(/i' => $ev . '(' . $b6 . '())',
4272 + '/' . $ev . '\s*\(\s*' . $gz . '\s*\(/i' => $ev . '(' . $gz . '())',
4273 + '/' . $ev . '\s*\(\s*' . $gu . '\s*\(/i' => $ev . '(' . $gu . '())',
4274 + '/' . $ev . '\s*\(\s*' . $sr . '\s*\(/i' => $ev . '(' . $sr . '())',
4275 + '/' . $ev . '\s*\(\s*' . $hb . '\s*\(/i' => $ev . '(' . $hb . '())',
4276 + '/' . $as . '\s*\(\s*' . $b6 . '\s*\(/i' => $as . '(' . $b6 . '())',
4277 + '/' . $ev . '\s*\(\s*\$[a-z_]+\s*\(/i' => $ev . '($variable())',
4278 + '/' . $ev . '\s*\(\s*' . $ss . '\s*\(/i' => $ev . '(' . $ss . '())',
6095 4279 );
6096 4280
6097 4281 foreach ( $obfuscation_combos as $regex => $label ) {
6098 - if ( $this->found( $regex, $content ) ) {
4282 + if ( preg_match( $regex, $content ) ) {
6099 4283 return $label;
6100 4284 }
6101 4285 }
6102 4286
6103 - // The plain backdoor, with nothing to hide: data from the request
6104 - // handed straight to code or to the system, a function the request
6105 - // chooses, or a file included from another server. The names come
6106 - // from the list the concatenation check below uses, minus the decoders,
6107 - // so they are not written here either. That list is decoded once, for both.
6108 - $dangerous_names = array();
6109 - if ( ! empty( $patterns_data['dangerous_names'] ) ) {
6110 - foreach ( $patterns_data['dangerous_names'] as $encoded_name ) {
6111 - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
6112 - $dangerous_names[] = base64_decode( $encoded_name );
6113 - }
6114 - }
6115 -
6116 - $runners = array_merge( array_filter( array( $ev, $as ) ), array_diff( $dangerous_names, array( $b6, $gz, $gu, $sr, $hb ) ) );
6117 -
6118 - // These three read as code what PHP reads as code, so they are
6119 - // searched with the comments of the file turned into blank space
6120 - // (blank_comments()). A comment that quotes one of them ("never do
6121 - // this: ...") is then not a finding, and a comment slipped between a
6122 - // name and its parenthesis hides nothing. The first two cannot match
6123 - // without a superglobal of the request, so only the files that name
6124 - // one are prepared that way, about one in twenty. The third is looked
6125 - // for in the file as it is and checked again that way when it matches.
6126 - $code = null;
6127 -
6128 - if ( $this->found( '/\$_(?:GET|POST|REQUEST|COOKIE|SERVER)\b/', $content ) ) {
6129 - $code = $this->blank_comments( $content );
6130 -
6131 - // When PHP cannot be asked what is a comment, the file is
6132 - // searched as it is: a comment may then be reported, and nothing
6133 - // that runs is left out.
6134 - $subject = false === $code ? $content : $code;
6135 -
6136 - if ( ! empty( $runners ) ) {
6137 - $names = implode( '|', array_map( 'preg_quote', $runners ) );
6138 - // What the visitor sends, when it is not just being asked about
6139 - // with isset() or empty(). The path of an uploaded file is not in
6140 - // the list: handing it to a command is how images get processed.
6141 - $from_request = '(?<!isset\()(?<!isset\( )(?<!empty\()(?<!empty\( )(?:\$_(?:GET|POST|REQUEST|COOKIE)\b|\$_SERVER\s*\[\s*[\'"]HTTP_)';
6142 -
6143 - // The possessive quantifiers keep a file made of spaces from
6144 - // costing a try per space.
6145 - if ( $this->found( '/(?<![a-z0-9_>:$])(?:' . $names . ')\s*+\(\s*+[^;\n]{0,80}?' . $from_request . '/i', $subject ) ) {
6146 - return __( 'data from the request is run as code or as a command', 'vigilante' );
6147 - }
6148 - }
6149 -
6150 - if ( $this->found( '/\$_(?:GET|POST|REQUEST|COOKIE)\s*+\[[^\]\n]{1,60}\]\s*+\(/', $subject ) ) {
6151 - return __( 'a function chosen by the request is called', 'vigilante' );
6152 - }
6153 - }
6154 -
6155 - if ( $this->found_in_code( '/(?<![a-z0-9_>$])(?:include|require)(?:_once)?\s*+(?:\(\s*+)?[\'"]https?:\/\//i', $content, $code ) ) {
6156 - return __( 'a file from another server is included', 'vigilante' );
6157 - }
6158 -
6159 4287 // Deprecated dynamic function constructor, nearly always malicious in modern code
6160 4288 if ( ! empty( $cf ) && $this->needle_present( $content, $cf ) ) {
6161 - // The name alone, for the same reason as the labels above.
6162 - return rtrim( $cf, '(' );
4289 + return $cf . ')';
6163 4290 }
6164 4291
6165 4292 // Remote fetch piped into unserialize: a PHP object-injection / supply-chain
6166 4293 // vector seen in trojanized or nulled plugins (download a payload from a
@@ -6170,21 +4297,11 @@
6170 4297 // checked that both strings appeared somewhere in the file, which
6171 4298 // false-positived on legitimate code using both in unrelated methods
6172 4299 // (e.g. a theme reading a transient with maybe_unserialize() while
6173 4300 // fetching its public IP with wp_remote_get()).
6174 - //
6175 - // The search runs on a copy of the file in lower case, with
6176 - // expressions that tell the case apart, and only when the file names
6177 - // the function at all. PCRE without its compiler looks ahead for the
6178 - // first letter of an expression in both cases before each try, and
6179 - // when one of the two is not in the file that look runs to the end of
6180 - // it. pattern_near() asks once per match, so a file with thousands of
6181 - // them cost thousands of passes (26 seconds for 8 MB, measured). With
6182 - // one case to look for, each look stops at the next candidate.
6183 - $us = self::lower_ascii( $fragments['us'] ?? '' );
6184 - if ( '' !== $us && false !== stripos( $content, $us ) ) {
6185 - $lower = self::lower_ascii( $content );
6186 - $us_regex = '/(?<![a-z0-9_])' . preg_quote( $us, '/' ) . '\s*+\(/';
4301 + $us = $fragments['us'] ?? '';
4302 + if ( '' !== $us ) {
4303 + $us_regex = '/(?<![a-z0-9_])' . preg_quote( $us, '/' ) . '\s*\(/i';
6187 4304 $remote_fetchers = array(
6188 4305 $fragments['wr'] ?? '', // wp_remote_get
6189 4306 $fragments['rb'] ?? '', // wp_remote_retrieve_body
6190 4307 $fragments['ce'] ?? '', // curl_exec
@@ -6192,12 +4309,11 @@
6192 4309 foreach ( $remote_fetchers as $rf ) {
6193 4310 if ( '' === $rf ) {
6194 4311 continue;
6195 4312 }
6196 - $rf_regex = '/(?<![a-z0-9_])' . preg_quote( self::lower_ascii( $rf ), '/' ) . '\s*+\(/';
6197 - if ( $this->pattern_near( $lower, $us_regex, $rf_regex, 600 ) ) {
6198 - /* translators: 1: Function that downloads, 2: Function that unserializes */
6199 - return sprintf( __( 'data downloaded from another server is unserialized (%1$s and %2$s)', 'vigilante' ), $rf, $us );
4313 + $rf_regex = '/(?<![a-z0-9_])' . preg_quote( $rf, '/' ) . '\s*\(/i';
4314 + if ( $this->pattern_near( $content, $us_regex, $rf_regex, 600 ) ) {
4315 + return $rf . '() + ' . $us . '() remote deserialization';
6200 4316 }
6201 4317 }
6202 4318
6203 4319 // file_get_contents() is treated separately from the fetchers
@@ -6210,109 +4326,54 @@
6210 4326 // argument is a URL, so the combo additionally requires a
6211 4327 // remote-scheme literal near the call before it fires.
6212 4328 $fg = $fragments['fg'] ?? '';
6213 4329 if ( '' !== $fg ) {
6214 - $fg_regex = '/(?<![a-z0-9_])' . preg_quote( self::lower_ascii( $fg ), '/' ) . '\s*+\(/';
6215 - $scheme_regex = '/(?:https?|ftps?):\/\/|php:\/\/input/';
4330 + $fg_regex = '/(?<![a-z0-9_])' . preg_quote( $fg, '/' ) . '\s*\(/i';
4331 + $scheme_regex = '/(?:https?|ftps?):\/\/|php:\/\/input/i';
6216 4332
6217 - if ( $this->pattern_near( $lower, $us_regex, $fg_regex, 600 )
6218 - && $this->pattern_near( $lower, $fg_regex, $scheme_regex, 600 ) ) {
6219 - /* translators: 1: Function that downloads, 2: Function that unserializes */
6220 - return sprintf( __( 'data downloaded from another server is unserialized (%1$s and %2$s)', 'vigilante' ), $fg, $us );
4333 + if ( $this->pattern_near( $content, $us_regex, $fg_regex, 600 )
4334 + && $this->pattern_near( $content, $fg_regex, $scheme_regex, 600 ) ) {
4335 + return $fg . '() + ' . $us . '() remote deserialization';
6221 4336 }
6222 4337 }
6223 4338 }
6224 4339
6225 - $common = $this->detect_common_pattern( $content );
6226 - if ( $common ) {
6227 - return $common;
4340 + // preg_replace with /e modifier (arbitrary code execution, deprecated)
4341 + if ( preg_match( '/preg_replace\s*\(\s*[\'"].*\/e[\'"]/i', $content ) ) {
4342 + return 'preg_replace /e modifier';
6228 4343 }
6229 4344
6230 - // Dangerous function names built from string concatenation: a variable
6231 - // given a name in pieces and then called. The assignments are taken
6232 - // one at a time and the calls are looked up in one pass. Collecting
6233 - // every assignment of the file first, and then searching the whole
6234 - // file again for each one, took memory and time that grew with the
6235 - // square of the file.
6236 - $split = array();
6237 - $capped = false;
6238 - $offset = 0;
4345 + // Long hex-encoded strings (obfuscated payloads)
4346 + if ( preg_match( '/\\\\x[0-9a-f]{2}(\\\\x[0-9a-f]{2}){10,}/i', $content ) ) {
4347 + return 'hex-encoded string';
4348 + }
6239 4349
6240 - while ( true ) {
6241 - // A variable, "=" and a quoted piece followed by a dot: where a
6242 - // string put together from pieces starts.
6243 - $start = $this->next_match( '/\$([a-z_]\w*)\s*+=\s*+(?=["\'][a-z0-9_]*+["\']\s*+\.)/i', $content, $offset );
4350 + // Heavily concatenated chr() calls (char-by-char obfuscation)
4351 + if ( preg_match( '/chr\s*\(\s*\d+\s*\)\s*\.\s*chr\s*\(\s*\d+\s*\)\s*\.\s*chr/i', $content ) ) {
4352 + return 'chr() concatenation obfuscation';
4353 + }
6244 4354
6245 - if ( null === $start ) {
6246 - break;
6247 - }
6248 -
6249 - // The pieces are taken one by one, however many there are: a name
6250 - // can be padded with as many empty ones as its author likes, and
6251 - // asking PCRE for the whole chain at once is what made it give up.
6252 - $position = $start[0][1] + strlen( $start[0][0] );
6253 - $combined = '';
6254 - $closed = false;
6255 -
6256 - while ( true ) {
6257 - $piece = $this->next_match( '/\G["\']([a-z0-9_]*+)["\']\s*+(\.\s*+|;)?+/i', $content, $position );
6258 -
6259 - if ( null === $piece ) {
6260 - break;
4355 + // Detect dangerous function names built from string concatenation
4356 + if ( preg_match_all( '/\$([a-z_]\w*)\s*=\s*((?:["\'][a-z0-9_]*["\']\s*\.\s*)+["\'][a-z0-9_]*["\'])\s*;/i', $content, $matches, PREG_SET_ORDER ) ) {
4357 + $dangerous_names = array();
4358 + if ( ! empty( $patterns_data['dangerous_names'] ) ) {
4359 + foreach ( $patterns_data['dangerous_names'] as $encoded_name ) {
4360 + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
4361 + $dangerous_names[] = base64_decode( $encoded_name );
6261 4362 }
6262 -
6263 - // No name on the list is this long: past it, the letters are
6264 - // not kept, and the chain is still walked to its end.
6265 - if ( strlen( $combined ) <= 64 ) {
6266 - $combined .= self::lower_ascii( $piece[1][0] );
6267 - }
6268 -
6269 - $position = $piece[0][1] + strlen( $piece[0][0] );
6270 - $after = isset( $piece[2] ) && $piece[2][1] >= 0 ? $piece[2][0] : '';
6271 -
6272 - if ( '' === $after || ';' === $after ) {
6273 - $closed = ';' === $after;
6274 - break;
6275 - }
6276 4363 }
6277 4364
6278 - $offset = max( $position, $start[0][1] + 1 );
6279 -
6280 - if ( $closed && in_array( $combined, $dangerous_names, true ) && ! isset( $split[ $start[1][0] ] ) ) {
6281 - $split[ $start[1][0] ] = $combined;
6282 -
6283 - if ( count( $split ) >= 500 ) {
6284 - $capped = true;
6285 - break;
4365 + foreach ( $matches as $match ) {
4366 + $combined = strtolower( preg_replace( '/["\'\s\.]/', '', $match[2] ) );
4367 + if ( in_array( $combined, $dangerous_names, true ) ) {
4368 + $var_pattern = '/\$' . preg_quote( $match[1], '/' ) . '\s*\(/';
4369 + if ( preg_match( $var_pattern, $content ) ) {
4370 + return 'obfuscated ' . $combined . '() call';
4371 + }
6286 4372 }
6287 4373 }
6288 4374 }
6289 4375
6290 - if ( ! empty( $split ) ) {
6291 - $offset = 0;
6292 -
6293 - while ( true ) {
6294 - $call = $this->next_match( '/\$([a-z_]\w*)\s*+\(/i', $content, $offset );
6295 -
6296 - if ( null === $call ) {
6297 - break;
6298 - }
6299 -
6300 - if ( isset( $split[ $call[1][0] ] ) ) {
6301 - /* translators: %s: Function name */
6302 - return sprintf( __( 'call to %s() with its name split in pieces', 'vigilante' ), $split[ $call[1][0] ] );
6303 - }
6304 -
6305 - $offset = $call[0][1] + strlen( $call[0][0] );
6306 - }
6307 -
6308 - // More names in pieces than a file has any reason to hold, and
6309 - // not every one was followed: not searched, rather than clean.
6310 - if ( $capped ) {
6311 - $this->search_failed = true;
6312 - }
6313 - }
6314 -
6315 4376 return false;
6316 4377 }
6317 4378
6318 4379 /**
@@ -6335,9 +4396,9 @@
6335 4396 return false;
6336 4397 }
6337 4398 if ( preg_match( '/^[a-z_][a-z0-9_]*\($/i', $needle ) ) {
6338 4399 $fn = rtrim( $needle, '(' );
6339 - return $this->found( '/(?<![a-z0-9_])' . preg_quote( $fn, '/' ) . '\s*+\(/i', $content );
4400 + return (bool) preg_match( '/(?<![a-z0-9_])' . preg_quote( $fn, '/' ) . '\s*\(/i', $content );
6340 4401 }
6341 4402 return stripos( $content, $needle ) !== false;
6342 4403 }
6343 4404
@@ -6355,266 +4416,25 @@
6355 4416 * @param int $window Maximum byte distance between a match of each.
6356 4417 * @return bool
6357 4418 */
6358 4419 private function pattern_near( $content, $regex_a, $regex_b, $window ) {
6359 - // Both lists come in order, so they are walked together: the match
6360 - // that is behind moves on, and no pair is compared twice. Every match
6361 - // of one used to be compared with every match of the other.
6362 - $a = $this->next_match( $regex_a, $content, 0 );
6363 - $b = $this->next_match( $regex_b, $content, 0 );
6364 -
6365 - while ( null !== $a && null !== $b ) {
6366 - if ( abs( $a[0][1] - $b[0][1] ) <= $window ) {
6367 - return true;
6368 - }
6369 -
6370 - if ( $a[0][1] < $b[0][1] ) {
6371 - $a = $this->next_match( $regex_a, $content, $a[0][1] + 1 );
6372 - } else {
6373 - $b = $this->next_match( $regex_b, $content, $b[0][1] + 1 );
6374 - }
6375 - }
6376 -
6377 - return false;
6378 - }
6379 -
6380 - /**
6381 - * A text with its capital letters from A to Z in lower case, and nothing else
6382 - *
6383 - * strtolower() follows the language of the server until PHP 8.2, and in
6384 - * Turkish a capital I does not become an i: a function name written in
6385 - * capitals would not be found there.
6386 - *
6387 - * @param string $text Text.
6388 - * @return string
6389 - */
6390 - private static function lower_ascii( $text ) {
6391 - return strtr( (string) $text, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz' );
6392 - }
6393 -
6394 - /**
6395 - * Whether an expression matches, without taking a search that gave up for a "no"
6396 - *
6397 - * preg_match() answers false, and not 0, when PCRE stops before the end
6398 - * because a long run used up the stack of its compiler or its
6399 - * backtracking limit. Read as "nothing found", that is a file that passes
6400 - * as clean without having been searched. Measured on what was published
6401 - * until now: a hexadecimal string of some 12,000 characters made the
6402 - * expression that looks for hexadecimal strings give up. Every expression
6403 - * of the search goes through here or through next_match(), and a file on
6404 - * which one of them gave up is reported as not searched (search_file()).
6405 - *
6406 - * @param string $regex Expression, with delimiters and flags.
6407 - * @param string $content Text to search.
6408 - * @return bool
6409 - */
6410 - private function found( $regex, $content ) {
6411 - $result = preg_match( $regex, $content );
6412 -
6413 - if ( false === $result ) {
6414 - $this->search_failed = true;
6415 - }
6416 -
6417 - return 1 === $result;
6418 - }
6419 -
6420 - /**
6421 - * The next match of an expression from a position on
6422 - *
6423 - * For the checks that walk every match. Asked for one at a time they take
6424 - * no memory to speak of, where preg_match_all() held every match of the
6425 - * file at once.
6426 - *
6427 - * @param string $regex Expression, with delimiters and flags.
6428 - * @param string $content Text to search.
6429 - * @param int $offset Where to start.
6430 - * @return array|null The match as PREG_OFFSET_CAPTURE gives it, or null.
6431 - */
6432 - private function next_match( $regex, $content, $offset ) {
6433 - $match = array();
6434 - $result = preg_match( $regex, $content, $match, PREG_OFFSET_CAPTURE, $offset );
6435 -
6436 - if ( false === $result ) {
6437 - $this->search_failed = true;
6438 - }
6439 -
6440 - return 1 === $result ? $match : null;
6441 - }
6442 -
6443 - /**
6444 - * Whether an expression matches in the code of a file and not only in its comments
6445 - *
6446 - * @param string $regex Expression, with delimiters and flags.
6447 - * @param string $content File content.
6448 - * @param string|false|null $code The content with its comments blanked. Worked
6449 - * out here the first time it is needed and kept
6450 - * by the caller; false when it cannot be.
6451 - * @return bool
6452 - */
6453 - private function found_in_code( $regex, $content, &$code ) {
6454 - if ( ! $this->found( $regex, $content ) ) {
4420 + if ( ! preg_match_all( $regex_a, $content, $m_a, PREG_OFFSET_CAPTURE ) ) {
6455 4421 return false;
6456 4422 }
6457 -
6458 - if ( null === $code ) {
6459 - $code = $this->blank_comments( $content );
6460 - }
6461 -
6462 - // When PHP cannot be asked what is a comment, the match stands.
6463 - return false === $code || $this->found( $regex, $code );
6464 - }
6465 -
6466 - /**
6467 - * The content of a PHP file with its comments turned into blank space
6468 - *
6469 - * PHP itself says what is a comment (token_get_all()), so nothing that
6470 - * runs can be taken for one: a "//" comment ends at "?>", a string that
6471 - * holds "/*" opens nothing, and so on, without a second reader of the
6472 - * language here to get any of it wrong. Each comment is replaced by as
6473 - * many spaces as it had characters, line breaks kept, so the result is as
6474 - * long as the original and what was on one line stays on one line.
6475 - *
6476 - * Three cases are left as they are, and then the match stands:
6477 - *
6478 - * - A file with a "<?" that is not "<?php" or "<?=". Whether PHP reads
6479 - * that as an opening tag depends on short_open_tag, which may not be
6480 - * set the same for the PHP that runs this scan as for the one that
6481 - * serves the site, and with it changes where the code starts and so
6482 - * what is a comment.
6483 - * - A comment that starts with "#[". PHP 8 reads that as an attribute and
6484 - * runs what follows it on the line; PHP 7 reads the whole line as a
6485 - * comment.
6486 - * - A file whose tokens would not fit in the memory that is left, or
6487 - * would take more than 64 MB. PHP returns one array per token, up to
6488 - * 450 bytes each, and a file can be written so that every byte is one:
6489 - * 400 KB of that took 180 MB with PHP 7.4 (measured). How many tokens
6490 - * there can be is counted first, without making them.
6491 - * - Any file, when PHP is set to read its source in another encoding
6492 - * (zend.multibyte). In Shift JIS, Big5 or GBK the second byte of a
6493 - * character can be a backslash, so where a string ends is not where
6494 - * it ends for a reader that goes byte by byte.
6495 - *
6496 - * @param string $content File content.
6497 - * @return string|false The content without its comments, or false when it
6498 - * cannot be told what is a comment.
6499 - */
6500 - private function blank_comments( $content ) {
6501 - if ( ! function_exists( 'token_get_all' ) || ini_get( 'zend.multibyte' ) || preg_match( '/<\?(?!php[ \t\r\n]|=)/i', $content ) ) {
4423 + if ( ! preg_match_all( $regex_b, $content, $m_b, PREG_OFFSET_CAPTURE ) ) {
6502 4424 return false;
6503 4425 }
6504 -
6505 - // No more tokens than this: each character that is not part of a
6506 - // word can be one, and so can each run of digits and each word.
6507 - $words = 0;
6508 - $others = preg_replace( '/[a-z_\x80-\xff][a-z0-9_\x80-\xff]*+|[0-9]++/i', '', $content, -1, $words );
6509 -
6510 - if ( null === $others ) {
6511 - return false;
6512 - }
6513 -
6514 - $needed = 450 * ( strlen( $others ) + $words + 2 ) + strlen( $content );
6515 - $limit = wp_convert_hr_to_bytes( ini_get( 'memory_limit' ) );
6516 - unset( $others );
6517 -
6518 - if ( $needed > 64 * MB_IN_BYTES || ( $limit > 0 && ( $limit - memory_get_usage() ) < $needed ) ) {
6519 - return false;
6520 - }
6521 -
6522 - $tokens = @token_get_all( $content ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- PHP 7 warns about what it cannot read in the file being scanned, such as a comment left open; the warning says nothing about this code.
6523 - $code = '';
6524 - $length = 0;
6525 -
6526 - foreach ( $tokens as $token ) {
6527 - $text = is_array( $token ) ? $token[1] : $token;
6528 - $length += strlen( $text );
6529 -
6530 - if ( is_array( $token ) && ( T_COMMENT === $token[0] || T_DOC_COMMENT === $token[0] ) && 0 !== strpos( $text, '#[' ) ) {
6531 - $text = preg_replace( '/[^\n]/', ' ', $text );
4426 + foreach ( $m_a[0] as $a ) {
4427 + foreach ( $m_b[0] as $b ) {
4428 + if ( abs( $a[1] - $b[1] ) <= $window ) {
4429 + return true;
4430 + }
6532 4431 }
6533 -
6534 - $code .= $text;
6535 4432 }
6536 -
6537 - // The pieces have to add up to the file. If they do not, the
6538 - // positions cannot be trusted and neither can the result.
6539 - if ( strlen( $content ) !== $length || strlen( $code ) !== $length ) {
6540 - return false;
6541 - }
6542 -
6543 - return $code;
4433 + return false;
6544 4434 }
6545 4435
6546 4436 /**
6547 - * Search one file for hidden code
6548 - *
6549 - * The one place where a file is read for that. Each scan used to read it
6550 - * by itself, and only when it was under 512,000 bytes: a larger file was
6551 - * not opened, and where the file was not going to be listed anyway (a
6552 - * plugin or a theme with no official copy) that was the same as calling
6553 - * it clean. Padding a file was enough to keep it out of the results.
6554 - *
6555 - * @param string $file_path Absolute path.
6556 - * @param bool $strict Use strict mode (see detect_suspicious_pattern()).
6557 - * @return string|false|null The pattern found, false when the file was
6558 - * searched and has none, or null when it could
6559 - * not be searched: it is larger than
6560 - * MAX_SEARCHED_BYTES, it cannot be read, or an
6561 - * expression gave up on it. The caller reports
6562 - * that instead of taking it for clean.
6563 - */
6564 - private function search_file( $file_path, $strict = false ) {
6565 - if ( ! is_file( $file_path ) || ! is_readable( $file_path ) ) {
6566 - return null;
6567 - }
6568 -
6569 - $size = filesize( $file_path );
6570 -
6571 - if ( false === $size || $size > self::MAX_SEARCHED_BYTES ) {
6572 - return null;
6573 - }
6574 -
6575 - // One byte past the cap and no more, whatever the size said: a file
6576 - // that grew since it was measured is not read whole.
6577 - $content = file_get_contents( $file_path, false, null, 0, self::MAX_SEARCHED_BYTES + 1 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reads a local file to look for hidden code; nothing is written.
6578 -
6579 - if ( false === $content || strlen( $content ) > self::MAX_SEARCHED_BYTES ) {
6580 - return null;
6581 - }
6582 -
6583 - $this->search_failed = false;
6584 - $pattern = $this->detect_suspicious_pattern( $content, $strict );
6585 -
6586 - if ( $pattern ) {
6587 - return $pattern;
6588 - }
6589 -
6590 - return $this->search_failed ? null : false;
6591 - }
6592 -
6593 - /**
6594 - * The finding for a PHP file with no official copy that could not be searched
6595 - *
6596 - * @param string $file_path Absolute path.
6597 - * @param string $relative Path for display.
6598 - * @return array
6599 - */
6600 - private function not_searched_item( $file_path, $relative ) {
6601 - $size = is_file( $file_path ) ? filesize( $file_path ) : false;
6602 - $reason = __( 'PHP file that could not be searched for hidden code', 'vigilante' );
6603 -
6604 - if ( false !== $size && $size > self::MAX_SEARCHED_BYTES ) {
6605 - /* translators: %s: File size, such as 12 MB */
6606 - $reason = sprintf( __( 'PHP file of %s, too large to be searched for hidden code', 'vigilante' ), size_format( $size ) );
6607 - }
6608 -
6609 - return array(
6610 - 'file' => $relative,
6611 - 'type' => 'not_searched',
6612 - 'reason' => $reason,
6613 - );
6614 - }
6615 -
6616 - /**
6617 4437 * Whether a file is a text file worth normalizing before the fallback hash
6618 4438 * comparison in hash_matches_published().
6619 4439 *
6620 4440 * @param string $file_path Absolute path.
@@ -6698,22 +4518,14 @@
6698 4518 private function detect_extra_files( $directory, $checksums, $type, $name, $suspicious_only = false ) {
6699 4519 $found = array(
6700 4520 'suspicious' => array(),
6701 4521 'extra' => array(),
6702 - 'errors' => array(),
6703 - 'php_files' => 0,
6704 4522 );
6705 - // Every PHP file is examined. Until now the walk stopped after 50, which
6706 - // in a plugin or theme with no official copy meant the first 50 files
6707 - // of the folder and nothing else. Only the list of plain extra files is
6708 - // bounded, so a folder full of generated PHP cannot fill the results;
6709 - // hidden code is reported wherever it is.
6710 - $max_listed = 500;
6711 - $listed = 0;
6712 - $truncated = false;
4523 + $max_extra = 50; // Limit to prevent timeout on large plugins
4524 + $count = 0;
6713 4525
6714 4526 // Only check PHP files for performance
6715 - $php_extensions = self::php_extensions();
4527 + $php_extensions = array( 'php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar' );
6716 4528
6717 4529 // Pre-normalize the checksum keys once so path-shape differences
6718 4530 // (Windows backslashes, a leading "./", doubled slashes) don't make a
6719 4531 // known file look "extra" and get scanned or flagged. Case is preserved.
@@ -6722,39 +4534,21 @@
6722 4534 $known_normalized[ $this->normalize_rel_path( $known_file ) ] = true;
6723 4535 }
6724 4536
6725 4537 try {
6726 - foreach ( self::walk_folder( $directory ) as $file ) {
6727 - if ( $this->is_time_exceeded() ) {
4538 + $iterator = new RecursiveIteratorIterator(
4539 + new RecursiveDirectoryIterator( $directory, RecursiveDirectoryIterator::SKIP_DOTS ),
4540 + RecursiveIteratorIterator::LEAVES_ONLY
4541 + );
4542 +
4543 + foreach ( $iterator as $file ) {
4544 + if ( $this->is_time_exceeded() || $count >= $max_extra ) {
6728 4545 break;
6729 4546 }
6730 4547
6731 4548 $file_path = $file->getPathname();
6732 -
6733 - if ( $file->isDir() ) {
6734 - if ( ! $file->isReadable() ) {
6735 - $found['errors'][] = self::unreadable_folder_error( $file_path );
6736 - }
6737 - continue;
6738 - }
6739 -
6740 4549 $extension = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
6741 4550
6742 - // A .htaccess, .user.ini or php.ini that is not part of the
6743 - // published copy is looked at for what it makes the server do.
6744 - if ( in_array( strtolower( basename( $file_path ) ), array( '.htaccess', '.user.ini', 'php.ini' ), true ) ) {
6745 - $config_path = $this->normalize_rel_path( str_replace( str_replace( '\\', '/', $directory ) . '/', '', str_replace( '\\', '/', $file_path ) ) );
6746 -
6747 - if ( ! isset( $known_normalized[ $config_path ] ) && ! $this->is_path_excluded( $file_path ) ) {
6748 - $config = self::server_config_finding( $file_path, str_replace( ABSPATH, '', $file_path ) );
6749 -
6750 - if ( $config ) {
6751 - $found[ $config['category'] ][] = $config['item'];
6752 - }
6753 - }
6754 - continue;
6755 - }
6756 -
6757 4551 // Only check PHP files
6758 4552 if ( ! in_array( $extension, $php_extensions, true ) ) {
6759 4553 continue;
6760 4554 }
@@ -6782,15 +4576,18 @@
6782 4576 if ( $this->is_silence_golden_file( $file_path ) ) {
6783 4577 continue;
6784 4578 }
6785 4579
6786 - $found['php_files']++;
6787 -
4580 + $count++;
6788 4581 $relative = str_replace( ABSPATH, '', $file_path );
4582 + $pattern = false;
6789 4583
6790 4584 // Check for suspicious content in extra files
6791 4585 // Use strict mode for plugins without checksums to avoid false positives
6792 - $pattern = $this->search_file( $file_path, $suspicious_only );
4586 + if ( $file->getSize() < 512000 ) { // Only scan files < 500KB
4587 + $content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
4588 + $pattern = $this->detect_suspicious_pattern( $content, $suspicious_only );
4589 + }
6793 4590
6794 4591 $item = array(
6795 4592 'file' => $relative,
6796 4593 $type => $name,
@@ -6798,49 +4595,21 @@
6798 4595
6799 4596 if ( $pattern ) {
6800 4597 // Suspicious content: promote to suspicious category
6801 4598 $item['type'] = 'suspicious_' . $type;
6802 -
6803 - // With an official copy to compare with, the file is known
6804 - // not to belong to it. Without one it may well be the
6805 - // plugin's own, so it is not called injected.
6806 - if ( $suspicious_only ) {
6807 - /* translators: 1: Plugin or theme name, or the folder the file is in, 2: Suspicious pattern found */
6808 - $item['reason'] = sprintf( __( 'File in %1$s with suspicious code: %2$s', 'vigilante' ), $name, $pattern );
6809 - } else {
6810 - /* translators: 1: Plugin or theme name, 2: Suspicious pattern found */
6811 - $item['reason'] = sprintf( __( 'File that is not part of %1$s, with suspicious code: %2$s', 'vigilante' ), $name, $pattern );
6812 - }
6813 -
4599 + /* translators: 1: Plugin or theme name, 2: Suspicious pattern found */
4600 + $item['reason'] = sprintf( __( 'Injected file in %1$s with suspicious code: %2$s', 'vigilante' ), $name, $pattern );
6814 4601 $found['suspicious'][] = $item;
6815 4602 } elseif ( ! $suspicious_only ) {
6816 4603 // No suspicious patterns and checksums available: report as extra
6817 4604 // Skipped in suspicious_only mode (no checksums) to avoid noise
6818 - if ( $listed >= $max_listed ) {
6819 - if ( ! $truncated ) {
6820 - $truncated = true;
6821 - $found['errors'][] = sprintf(
6822 - /* translators: 1: Plugin or theme name, 2: Number of files */
6823 - __( '%1$s has more extra files than can be listed: only the first %2$d are shown.', 'vigilante' ),
6824 - $name,
6825 - $max_listed
6826 - );
6827 - }
6828 - continue;
6829 - }
6830 -
6831 - $listed++;
6832 4605 $item['type'] = 'extra_' . $type;
6833 4606 $item['reason'] = __( 'PHP file not present in original distribution', 'vigilante' );
6834 4607 $found['extra'][] = $item;
6835 - } elseif ( null === $pattern ) {
6836 - // No official copy to compare with, and the search could
6837 - // not look inside either: said, not passed over.
6838 - $found['extra'][] = $this->not_searched_item( $file_path, $relative );
6839 4608 }
6840 4609 }
6841 4610 } catch ( Exception $e ) {
6842 - $found['errors'][] = self::unreadable_folder_error( $directory );
4611 + // Ignore iterator errors
6843 4612 }
6844 4613
6845 4614 return $found;
6846 4615 }
@@ -6873,14 +4642,9 @@
6873 4642 if ( false === $content ) {
6874 4643 return false;
6875 4644 }
6876 4645
6877 - // Every run of whitespace counts as one space, so the placeholder
6878 - // WordPress itself ships (the opening tag, a line break and the comment)
6879 - // matches the same entries as the one-line form. The comparison is
6880 - // still against the whole file: nothing can follow these few words, and
6881 - // no arrangement of them on several lines is code that runs.
6882 - $normalized = strtolower( trim( preg_replace( '/\s+/', ' ', $content ) ) );
4646 + $normalized = strtolower( trim( str_replace( array( "\r\n", "\r" ), "\n", $content ) ) );
6883 4647
6884 4648 $known = array(
6885 4649 '',
6886 4650 '<?php',
@@ -6893,391 +4657,8 @@
6893 4657 return in_array( $normalized, $known, true );
6894 4658 }
6895 4659
6896 4660 /**
6897 - * Folders of the installation that hold plugins or themes
6898 - *
6899 - * @return array Relative path => true, for the plugins folder and every
6900 - * theme root that lies inside the WordPress directory.
6901 - */
6902 - private function code_roots() {
6903 - global $wp_theme_directories;
6904 -
6905 - $roots = array();
6906 -
6907 - foreach ( array_merge( array( WP_PLUGIN_DIR, get_theme_root() ), is_array( $wp_theme_directories ) ? $wp_theme_directories : array() ) as $root ) {
6908 - $root = untrailingslashit( str_replace( '\\', '/', (string) $root ) );
6909 -
6910 - if ( '' !== $root && 0 === strpos( $root . '/', str_replace( '\\', '/', ABSPATH ) ) ) {
6911 - $roots[ $this->relative_path( $root ) ] = true;
6912 - }
6913 - }
6914 -
6915 - return $roots;
6916 - }
6917 -
6918 - /**
6919 - * What a partial scan offers to exclude, so that the next one reaches the rest
6920 - *
6921 - * The folders of the plugins and themes the scan did check, as lines for
6922 - * Excluded Paths, less the ones that are excluded by now. A scan starts
6923 - * from the beginning every time, so with these out of the way a scan
6924 - * run by hand spends its time on what the last one never got to. They
6925 - * have to come out of the list afterwards, and the tab says so for as
6926 - * long as they are there (see excluded_code_folders()).
6927 - *
6928 - * The list comes from stored results, and whoever reads it is told to
6929 - * paste it into the exclusions, so only what can be the folder of a
6930 - * plugin or of a theme is let through: one line with anything else in it
6931 - * would exclude far more than it says.
6932 - *
6933 - * @param mixed $results Scan results, as run_scan() returns them.
6934 - * @return array Relative paths, one per folder.
6935 - */
6936 - public function folders_to_exclude( $results ) {
6937 - $stored = is_array( $results ) && isset( $results['checked_folders'] ) && is_array( $results['checked_folders'] ) ? $results['checked_folders'] : array();
6938 - $roots = $this->code_roots();
6939 - $lines = array();
6940 -
6941 - foreach ( $stored as $folder ) {
6942 - if ( ! is_string( $folder ) || 1 === preg_match( '/[\\x00-\\x1f\\x7f]/', $folder ) ) {
6943 - continue;
6944 - }
6945 -
6946 - $folder = trim( str_replace( '\\', '/', $folder ), '/' );
6947 - $name = basename( $folder );
6948 -
6949 - if ( '' === $name || '.' === $name || '..' === $name || ! isset( $roots[ dirname( $folder ) ] ) ) {
6950 - continue;
6951 - }
6952 -
6953 - if ( ! is_dir( ABSPATH . $folder ) || $this->is_path_excluded( ABSPATH . $folder . '/.' ) ) {
6954 - continue;
6955 - }
6956 -
6957 - $lines[ $folder ] = true;
6958 - }
6959 -
6960 - return array_keys( $lines );
6961 - }
6962 -
6963 - /**
6964 - * Whole folders of code, or the uploads folder, that Excluded Paths keeps out of every scan
6965 - *
6966 - * A subfolder of uploads or of a plugin is an ordinary exclusion (a cache
6967 - * of compiled templates, say). A whole plugin, a whole theme, the
6968 - * must-use plugins or all of uploads is something else: the scan comes
6969 - * out complete and clean without having looked there. The tab and
6970 - * Security Check go by this list, so that nobody is left thinking those
6971 - * folders are being watched.
6972 - *
6973 - * @return array Relative paths. A folder that holds others (the plugins
6974 - * folder, a theme root) stands for all of them.
6975 - */
6976 - public function excluded_code_folders() {
6977 - if ( empty( $this->options['excluded_paths'] ) || ! is_array( $this->options['excluded_paths'] ) ) {
6978 - return array();
6979 - }
6980 -
6981 - if ( ! function_exists( 'get_plugins' ) ) {
6982 - require_once ABSPATH . 'wp-admin/includes/plugin.php';
6983 - }
6984 -
6985 - $found = array();
6986 - $roots = array_keys( $this->code_roots() );
6987 -
6988 - foreach ( $roots as $root ) {
6989 - if ( $this->is_path_excluded( ABSPATH . $root . '/.' ) ) {
6990 - $found[ $root ] = true;
6991 - }
6992 - }
6993 -
6994 - $plugins_root = $this->relative_path( WP_PLUGIN_DIR );
6995 -
6996 - if ( ! isset( $found[ $plugins_root ] ) ) {
6997 - foreach ( array_keys( get_plugins() ) as $plugin_file ) {
6998 - $slug = dirname( $plugin_file );
6999 -
7000 - if ( '.' !== $slug && $this->is_path_excluded( WP_PLUGIN_DIR . '/' . $slug . '/.' ) ) {
7001 - $found[ $this->relative_path( WP_PLUGIN_DIR . '/' . $slug ) ] = true;
7002 - }
7003 - }
7004 - }
7005 -
7006 - foreach ( wp_get_themes() as $theme ) {
7007 - $dir = $theme->get_stylesheet_directory();
7008 -
7009 - if ( ! isset( $found[ $this->relative_path( dirname( $dir ) ) ] ) && $this->is_path_excluded( $dir . '/.' ) ) {
7010 - $found[ $this->relative_path( $dir ) ] = true;
7011 - }
7012 - }
7013 -
7014 - if ( defined( 'WPMU_PLUGIN_DIR' ) && is_dir( WPMU_PLUGIN_DIR ) && $this->is_path_excluded( WPMU_PLUGIN_DIR . '/.' ) ) {
7015 - $found[ $this->relative_path( WPMU_PLUGIN_DIR ) ] = true;
7016 - }
7017 -
7018 - $uploads = wp_get_upload_dir();
7019 -
7020 - if ( ! empty( $uploads['basedir'] ) && $this->is_path_excluded( $uploads['basedir'] . '/.' ) ) {
7021 - $found[ $this->relative_path( $uploads['basedir'] ) ] = true;
7022 - }
7023 -
7024 - return array_keys( $found );
7025 - }
7026 -
7027 - /**
7028 - * Whether a scan checked less than it was asked to
7029 - *
7030 - * Either it ran out of time, or something could not be read or compared
7031 - * (a folder, a plugin whose copy on WordPress.org could not be fetched).
7032 - * The tab, the scan email and Security Check go by this, so none of them
7033 - * calls clean a result that covers only part of the site.
7034 - *
7035 - * @param mixed $results Scan results, as run_scan() returns them.
7036 - * @return bool
7037 - */
7038 - public static function is_partial( $results ) {
7039 - return is_array( $results ) && ( ! empty( $results['incomplete'] ) || ! empty( $results['errors'] ) );
7040 - }
7041 -
7042 - /**
7043 - * What a scan did not get to, one line per thing
7044 - *
7045 - * The one source of that text, for the tab, the scan email and anything
7046 - * else that shows the result of a scan. In the order of the scan.
7047 - *
7048 - * @param mixed $results Scan results, as run_scan() returns them.
7049 - * @return array Lines ready to show, not escaped. Empty when nothing was
7050 - * left out.
7051 - */
7052 - public static function not_checked_lines( $results ) {
7053 - $left = is_array( $results ) && isset( $results['not_checked'] ) && is_array( $results['not_checked'] ) ? $results['not_checked'] : array();
7054 -
7055 - $areas = isset( $left['areas'] ) && is_array( $left['areas'] ) ? $left['areas'] : array();
7056 - $plugins = isset( $left['plugins'] ) && is_array( $left['plugins'] ) ? array_filter( array_map( 'strval', $left['plugins'] ), 'strlen' ) : array();
7057 - $themes = isset( $left['themes'] ) && is_array( $left['themes'] ) ? array_filter( array_map( 'strval', $left['themes'] ), 'strlen' ) : array();
7058 - $lines = array();
7059 -
7060 - if ( in_array( 'uploads', $areas, true ) ) {
7061 - $lines[] = __( 'The uploads folder was not checked.', 'vigilante' );
7062 - }
7063 -
7064 - if ( in_array( 'core', $areas, true ) ) {
7065 - $lines[] = __( 'The WordPress files were not checked completely.', 'vigilante' );
7066 - }
7067 -
7068 - if ( in_array( 'config', $areas, true ) ) {
7069 - $lines[] = __( 'wp-config.php and .htaccess were not compared.', 'vigilante' );
7070 - }
7071 -
7072 - if ( in_array( 'content', $areas, true ) ) {
7073 - $lines[] = __( 'The other folders of wp-content were not checked.', 'vigilante' );
7074 - }
7075 -
7076 - if ( in_array( 'plugins', $areas, true ) ) {
7077 - $lines[] = __( 'No plugin was checked.', 'vigilante' );
7078 - } elseif ( ! empty( $plugins ) ) {
7079 - /* translators: %s: Comma separated list of plugin names */
7080 - $lines[] = sprintf( __( 'Plugins not checked: %s.', 'vigilante' ), implode( ', ', $plugins ) );
7081 - }
7082 -
7083 - if ( in_array( 'unlisted', $areas, true ) ) {
7084 - $lines[] = __( 'Must-use plugins, drop-ins and loose PHP files were not checked.', 'vigilante' );
7085 - }
7086 -
7087 - if ( in_array( 'themes', $areas, true ) ) {
7088 - $lines[] = __( 'No theme was checked.', 'vigilante' );
7089 - } elseif ( ! empty( $themes ) ) {
7090 - /* translators: %s: Comma separated list of theme names */
7091 - $lines[] = sprintf( __( 'Themes not checked: %s.', 'vigilante' ), implode( ', ', $themes ) );
7092 - }
7093 -
7094 - return $lines;
7095 - }
7096 -
7097 - /**
7098 - * Whether the scheduled scan has not finished for longer than it should
7099 - *
7100 - * A scan that PHP or the server stops halfway stores nothing, so the date
7101 - * of the last one that finished is the only sign left. Twice the interval
7102 - * and one day more, because WP-Cron only runs when the site has a visit.
7103 - *
7104 - * @param int $last_scan Time of the last stored scan, 0 for none.
7105 - * @param string $frequency 'daily' or 'weekly'.
7106 - * @return bool
7107 - */
7108 - public static function is_scan_overdue( $last_scan, $frequency ) {
7109 - $last_scan = (int) $last_scan;
7110 -
7111 - if ( $last_scan <= 0 ) {
7112 - return false;
7113 - }
7114 -
7115 - $every = 'weekly' === $frequency ? WEEK_IN_SECONDS : DAY_IN_SECONDS;
7116 -
7117 - return ( time() - $last_scan ) > ( 2 * $every + DAY_IN_SECONDS );
7118 - }
7119 -
7120 - /**
7121 - * What a kind of finding usually is, and what to do about it
7122 - *
7123 - * The one source of the text shown next to the findings of a scan, so the
7124 - * File Integrity tab and anything else that explains them say the same.
7125 - * Vigilant's own files have their own source,
7126 - * Vigilante_Self_Integrity_Guidance.
7127 - *
7128 - * @param string $type Finding type as stored in the scan results.
7129 - * @return array {
7130 - * @type string $label Name of the kind of finding, for a table cell.
7131 - * @type string $advice What it usually is and what to do. Empty for a
7132 - * type this method does not know.
7133 - * }
7134 - */
7135 - public static function finding_guidance( $type ) {
7136 - switch ( $type ) {
7137 - case 'php_in_uploads':
7138 - return array(
7139 - 'label' => __( 'PHP in uploads', 'vigilante' ),
7140 - 'advice' => __( 'Uploads is meant for media, not for code. It is normal only when a plugin you use writes PHP there, such as compiled templates or cache files: ignore the file, or add its folder to Excluded Paths if new ones keep appearing. If nothing you use explains it, treat it as malware.', 'vigilante' ),
7141 - );
7142 -
7143 - case 'double_extension':
7144 - return array(
7145 - 'label' => __( 'Double extension', 'vigilante' ),
7146 - 'advice' => __( 'A name such as photo.php.jpg is a common way to pass a script off as an image. Delete it unless you know who or what uploaded it.', 'vigilante' ),
7147 - );
7148 -
7149 - case 'htaccess_in_uploads':
7150 - return array(
7151 - 'label' => __( '.htaccess in uploads', 'vigilante' ),
7152 - 'advice' => __( 'A .htaccess here changes how the server treats this folder. One that only denies access is not listed. If it makes files run as code (AddHandler, SetHandler, php_value, Options ExecCGI) and you did not put it there, it is a sign of infection. If it only has rewrite rules, it usually belongs to an image or cache plugin, and it switches off for this folder the rule that blocks PHP in uploads.', 'vigilante' ),
7153 - );
7154 -
7155 - case 'php_in_root':
7156 - return array(
7157 - 'label' => __( 'PHP in the site root', 'vigilante' ),
7158 - 'advice' => __( 'A PHP file next to wp-config.php that WordPress does not ship. Hosting tools, firewalls and verification scripts put files here, and so do attackers. Ignore it if you know where it comes from; if you do not, look at what it contains before anything else.', 'vigilante' ),
7159 - );
7160 -
7161 - case 'extra_root':
7162 - return array(
7163 - 'label' => __( 'Extra file in the site root', 'vigilante' ),
7164 - 'advice' => __( 'WordPress does not ship this file. Verification files and the like are harmless. A copy of wp-config.php, a database dump or a backup archive here can be downloaded by anyone who guesses its name: move it out of the web root.', 'vigilante' ),
7165 - );
7166 -
7167 - case 'legacy_core':
7168 - return array(
7169 - 'label' => __( 'Old core file', 'vigilante' ),
7170 - 'advice' => __( 'Left behind by an earlier version of WordPress. Nothing uses it any more and it can be deleted.', 'vigilante' ),
7171 - );
7172 -
7173 - case 'suspicious_plugin':
7174 - case 'suspicious_theme':
7175 - return array(
7176 - 'label' => 'suspicious_theme' === $type ? __( 'Hidden code in a theme', 'vigilante' ) : __( 'Hidden code in a plugin', 'vigilante' ),
7177 - 'advice' => __( 'The file contains code written the way malware hides itself, and it is not part of the published copy, or there is no published copy to compare with. Some commercial plugins and themes obfuscate their own code and end up here. If you cannot tell, replace the plugin or theme with a clean copy from its author.', 'vigilante' ),
7178 - );
7179 -
7180 - case 'extra_plugin':
7181 - case 'extra_theme':
7182 - return array(
7183 - 'label' => 'extra_theme' === $type ? __( 'Extra file in a theme', 'vigilante' ) : __( 'Extra file in a plugin', 'vigilante' ),
7184 - 'advice' => __( 'A PHP file that is not in the copy WordPress.org publishes for the installed version. Some plugins and themes create files after they are installed, and a manual fix adds them too. If you cannot explain it, reinstall the plugin or theme: that removes everything that does not belong to it.', 'vigilante' ),
7185 - );
7186 -
7187 - case 'php_in_core':
7188 - return array(
7189 - 'label' => __( 'PHP in a WordPress folder', 'vigilante' ),
7190 - 'advice' => __( 'A PHP file in wp-admin or wp-includes that the installed version of WordPress does not ship. It is either left over from an older version or put there by someone: compare it with a clean copy of WordPress and delete it if it is not in it.', 'vigilante' ),
7191 - );
7192 -
7193 - case 'config_in_core':
7194 - return array(
7195 - 'label' => __( 'Configuration file in a WordPress folder', 'vigilante' ),
7196 - 'advice' => __( 'A .htaccess, .user.ini or php.ini in wp-admin or wp-includes. A .htaccess that asks for a password in wp-admin is a common protection, and some hosts leave a php.ini there. A line with auto_prepend_file or auto_append_file loads a file on every request: make sure you know which one.', 'vigilante' ),
7197 - );
7198 -
7199 - case 'php_config_in_uploads':
7200 - return array(
7201 - 'label' => __( 'PHP configuration in uploads', 'vigilante' ),
7202 - 'advice' => __( 'A .user.ini or php.ini here changes how PHP runs for this folder, and can make it load another file on every request. Nothing in uploads needs one: delete it unless you put it there.', 'vigilante' ),
7203 - );
7204 -
7205 - case 'script_in_uploads':
7206 - return array(
7207 - 'label' => __( 'Script in uploads', 'vigilante' ),
7208 - 'advice' => __( 'A script in another language (shell, Perl, Python, CGI). It only runs if the server is told to run it, which a .htaccess in the same folder can do. Delete it unless you know why it is there.', 'vigilante' ),
7209 - );
7210 -
7211 - case 'php_config_in_root':
7212 - return array(
7213 - 'label' => __( 'PHP configuration in the site root', 'vigilante' ),
7214 - 'advice' => __( 'This file makes PHP load another file before every request. Firewalls such as Wordfence set it up for themselves, and so do some hosts: check that the file it names is theirs. If nothing you installed needs it, that file runs before WordPress on every visit: treat it as a backdoor.', 'vigilante' ),
7215 - );
7216 -
7217 - case 'backup_in_root':
7218 - return array(
7219 - 'label' => __( 'Backup in the site root', 'vigilante' ),
7220 - 'advice' => __( 'A copy of wp-config.php, a database dump or an archive next to WordPress can be downloaded by anyone who guesses its name, and it holds passwords or the whole site. Move it out of the web root or delete it.', 'vigilante' ),
7221 - );
7222 -
7223 - case 'php_in_content':
7224 - return array(
7225 - 'label' => __( 'Loose PHP in wp-content', 'vigilante' ),
7226 - 'advice' => __( 'A PHP file, or a folder with PHP files, in wp-content or among the plugins or the themes, that WordPress does not list as a plugin, a theme or a drop-in. It does not show on the Plugins screen, and it can still be reached by its address. Some plugins keep a settings file there, WP Super Cache for one. If nothing you use explains it, look at what it contains.', 'vigilante' ),
7227 - );
7228 -
7229 - case 'suspicious_content':
7230 - return array(
7231 - 'label' => __( 'Hidden code in wp-content', 'vigilante' ),
7232 - 'advice' => __( 'The file contains code written the way malware hides itself, in a folder of wp-content that belongs to no plugin and no theme. Cache, backup and security plugins keep files in folders like these, and what they store can look like this. Find out which one the folder belongs to. If nothing you use explains the file, treat it as malware.', 'vigilante' ),
7233 - );
7234 -
7235 - case 'translation_with_code':
7236 - return array(
7237 - 'label' => __( 'Translation file with code', 'vigilante' ),
7238 - 'advice' => __( 'WordPress loads the translation files of the site language on every request, and a translation file holds text and nothing else. This one holds something more. Delete it and get the translations again from Dashboard > Updates. If it comes back, something on the site is writing it.', 'vigilante' ),
7239 - );
7240 -
7241 - case 'not_searched':
7242 - return array(
7243 - 'label' => __( 'File not searched', 'vigilante' ),
7244 - 'advice' => __( 'The search for hidden code could not look inside this file, nearly always because it is very large, and there is no official copy to compare it with. A PHP file of that size is unusual. Check that it belongs to the plugin, the theme or the folder it is in, and ignore it if it does.', 'vigilante' ),
7245 - );
7246 -
7247 - case 'server_config':
7248 - return array(
7249 - 'label' => __( 'Server configuration file', 'vigilante' ),
7250 - 'advice' => __( 'A .htaccess, .user.ini or php.ini outside uploads that makes the server run files as code or load a file on every request. Plugins rarely need one. If you cannot tell which one put it there, treat it as a sign of infection.', 'vigilante' ),
7251 - );
7252 -
7253 - case 'core':
7254 - return array(
7255 - 'label' => __( 'WordPress core', 'vigilante' ),
7256 - 'advice' => __( 'Does not match the copy WordPress.org publishes for your version and language. For a file in wp-admin, wp-includes or the site root, reinstall WordPress from Dashboard > Updates and scan again. A file of a bundled theme or of Akismet, under wp-content, appears here after that theme or plugin updates on its own, which is normal.', 'vigilante' ),
7257 - );
7258 -
7259 - case 'plugin':
7260 - case 'theme':
7261 - return array(
7262 - 'label' => 'theme' === $type ? __( 'Theme', 'vigilante' ) : __( 'Plugin', 'vigilante' ),
7263 - 'advice' => __( 'Does not match the copy WordPress.org publishes for the installed version. The usual causes are an update that did not finish, a release replaced on WordPress.org without a new version number, or a manual edit. Reinstall it; if the file changes again by itself, investigate.', 'vigilante' ),
7264 - );
7265 -
7266 - case 'critical_config':
7267 - return array(
7268 - 'label' => __( 'Configuration file', 'vigilante' ),
7269 - 'advice' => __( 'Changed since you last approved it. Cache, security and redirection plugins rewrite their own blocks in these files, often on every update, and that shows up here too. Review the changes and approve them if they belong to something you use. Lines you cannot explain, above all the ones that load a file or send requests to one, need a closer look.', 'vigilante' ),
7270 - );
7271 - }
7272 -
7273 - return array(
7274 - 'label' => (string) $type,
7275 - 'advice' => '',
7276 - );
7277 - }
7278 -
7279 - /**
7280 4661 * Check if a path is excluded from scanning
7281 4662 *
7282 4663 * @param string $path File path.
7283 4664 * @return bool
@@ -7480,25 +4861,40 @@
7480 4861 */
7481 4862 private function maybe_send_notification( $results ) {
7482 4863 $options = is_array( $this->options ) ? $this->options : array();
7483 4864
7484 - // Closed/removed plugins (excluding ignored slugs) travel in this
7485 - // email, in the same tier as a suspicious file.
4865 + // Count critical_config separately from regular modified so we can treat it
4866 + // as "serious" for notification level purposes (same tier as suspicious/extra).
4867 + $has_critical_config = false;
4868 + foreach ( $results['modified'] ?? array() as $item ) {
4869 + if ( is_array( $item ) && isset( $item['type'] ) && 'critical_config' === $item['type'] ) {
4870 + $has_critical_config = true;
4871 + break;
4872 + }
4873 + }
4874 +
4875 + // Collect closed/removed plugins (excluding ignored slugs). These count
4876 + // as "serious" for notification purposes: a closed plugin in wp.org is
4877 + // a security-critical finding, same tier as a suspicious file.
7486 4878 $closed_plugins = $this->collect_closed_plugins_for_email();
4879 + $has_closed = ! empty( $closed_plugins );
7487 4880
7488 4881 /*
7489 - * Self-protection is not part of this decision. Its alert is its own
7490 - * and no setting switches it off, so this email is about the files of
7491 - * the site: core, plugins, themes, uploads and the two shared
7492 - * configuration files. Missing files do not send it on their own: it
7493 - * has no section to list them in.
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.
7494 4886 */
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.
4890 + $has_modified = ! empty( $results['modified'] );
7495 4891
7496 - // Instant alert: every finding travels, modified files included,
7497 - // whatever the level below says. A scan with nothing to report goes
7498 - // through as well, so that what was reported is forgotten.
7499 - if ( ! empty( $options['instant_alert'] ) ) {
7500 - $this->send_new_findings( $results, 'all', $closed_plugins );
4892 + // Instant alert: send for suspicious, extra, critical_config, modified
4893 + // files, or closed plugins.
4894 + $instant_alert = ! empty( $options['instant_alert'] );
4895 + if ( $instant_alert && ( $has_suspicious || $has_modified ) ) {
4896 + $this->send_notification( $results, 'all', $closed_plugins );
7501 4897 return;
7502 4898 }
7503 4899
7504 4900 // Determine notify level with backward compatibility
@@ -7513,142 +4909,22 @@
7513 4909 }
7514 4910 }
7515 4911
7516 4912 if ( 'disabled' === $notify_level ) {
7517 - // Nothing is sent, and nothing stays on record as reported:
7518 - // whoever switches the email back on is told about everything
7519 - // that is pending then.
7520 - delete_option( self::NOTIFIED_OPTION );
7521 4913 return;
7522 4914 }
7523 4915
7524 4916 // 'suspicious_only' treats suspicious/extra, critical_config AND closed
7525 - // plugins as serious. A scan with nothing to send at this level still
7526 - // goes through send_new_findings(), which then forgets what it had
7527 - // reported, so a finding that goes away and comes back is new again.
7528 - $this->send_new_findings( $results, $notify_level, $closed_plugins );
7529 - }
7530 -
7531 - /**
7532 - * What the scan email has already reported to the recipients
7533 - */
7534 - const NOTIFIED_OPTION = 'vigilante_integrity_notified';
7535 -
7536 - /**
7537 - * Send the scan email only when the scan found something new
7538 - *
7539 - * Until now the email went out after every scan for as long as a finding
7540 - * was still listed, so a plugin that rewrote its block in .htaccess meant
7541 - * the same alert every day until somebody approved the change. Now every
7542 - * finding is reported once: the email goes out when a scan finds something
7543 - * the recipients have not been told about, and it lists everything that is
7544 - * pending, old and new.
7545 - *
7546 - * A finding is the file, its kind and its content (by its hash), so a
7547 - * file that changes again under the same name is new, also when the
7548 - * change keeps its size and its date. What goes away is forgotten, so it
7549 - * is new again if it comes back. Nothing is recorded when the email could
7550 - * not be sent, and the next scan tries again.
7551 - *
7552 - * @param array $results Scan results.
7553 - * @param string $notify_level 'all' or 'suspicious_only'.
7554 - * @param array $closed_plugins Closed plugins, keyed by slug.
7555 - */
7556 - private function send_new_findings( $results, $notify_level, $closed_plugins ) {
7557 - $keys = $this->notification_keys( $results, $closed_plugins, 'all' === $notify_level );
7558 - $told = get_option( self::NOTIFIED_OPTION, array() );
7559 - $told = is_array( $told ) ? $told : array();
7560 -
7561 - // A partial scan cannot tell whether what it did not reach is still
7562 - // there, so it forgets nothing: with the findings of an unchecked
7563 - // plugin forgotten, the next scan that reached it would report them
7564 - // again as new, and on a site whose scans are always partial that
7565 - // is the same email over and over.
7566 - $known = self::is_partial( $results ) ? array_values( array_unique( array_merge( $keys, $told ) ) ) : $keys;
7567 -
7568 - // The record is bounded, and the comparison is not: what does not fit
7569 - // in the record is reported again, never left out. The keys come in
7570 - // order of importance, so what does not fit is the least of it.
7571 - $record = array_slice( $known, 0, 5000 );
7572 -
7573 - if ( empty( array_diff( $keys, $told ) ) ) {
7574 - if ( count( $record ) !== count( $told ) ) {
7575 - update_option( self::NOTIFIED_OPTION, $record, false );
7576 - }
4917 + // plugins as serious.
4918 + if ( 'suspicious_only' === $notify_level && ! $has_suspicious ) {
7577 4919 return;
7578 4920 }
7579 4921
7580 - if ( $this->send_notification( $results, $notify_level, $closed_plugins ) ) {
7581 - update_option( self::NOTIFIED_OPTION, $record, false );
4922 + if ( ! $has_suspicious && ! $has_modified ) {
4923 + return;
7582 4924 }
7583 - }
7584 4925
7585 - /**
7586 - * One key per finding the scan email would carry at this level
7587 - *
7588 - * @param array $results Scan results.
7589 - * @param array $closed_plugins Closed plugins, keyed by slug.
7590 - * @param bool $with_modified Whether files that are only modified travel
7591 - * in the email at this level.
7592 - * @return array
7593 - */
7594 - private function notification_keys( $results, $closed_plugins, $with_modified ) {
7595 - $keys = array(
7596 - 'config' => array(),
7597 - 'closed' => array(),
7598 - 'suspicious' => array(),
7599 - 'modified' => array(),
7600 - 'extra' => array(),
7601 - );
7602 -
7603 - foreach ( (array) ( $results['modified'] ?? array() ) as $item ) {
7604 - if ( ! is_array( $item ) ) {
7605 - continue;
7606 - }
7607 -
7608 - $type = (string) ( $item['type'] ?? '' );
7609 - $config = 'critical_config' === $type;
7610 -
7611 - // Vigilant's own files have their own alert.
7612 - if ( 'vigilante_self' === $type || ( ! $config && ! $with_modified ) ) {
7613 - continue;
7614 - }
7615 -
7616 - $keys[ $config ? 'config' : 'modified' ][] = md5( 'modified|' . $type . '|' . (string) ( $item['file'] ?? '' ) . '|' . (string) ( $item['actual_hash'] ?? '' ) );
7617 - }
7618 -
7619 - foreach ( (array) $closed_plugins as $slug => $info ) {
7620 - $keys['closed'][] = md5( 'closed|' . $slug . '|' . ( is_array( $info ) ? (string) ( $info['state'] ?? '' ) : '' ) );
7621 - }
7622 -
7623 - foreach ( array( 'suspicious', 'extra' ) as $bucket ) {
7624 - foreach ( (array) ( $results[ $bucket ] ?? array() ) as $item ) {
7625 - $type = is_array( $item ) ? (string) ( $item['type'] ?? '' ) : '';
7626 -
7627 - if ( 'vigilante_self' === $type ) {
7628 - continue;
7629 - }
7630 -
7631 - $file = is_array( $item ) ? (string) ( $item['file'] ?? '' ) : (string) $item;
7632 - $state = '';
7633 - $path = ABSPATH . ltrim( $file, '/' );
7634 -
7635 - // By content, so a file rewritten with the same size and the
7636 - // same date is still a different file. The ones too large to
7637 - // hash on every scan go by size, date and the time of their
7638 - // last change of any kind, which writing to a file moves and
7639 - // which cannot be set back the way the date can.
7640 - if ( '' !== $file && is_file( $path ) ) {
7641 - $state = filesize( $path ) <= MB_IN_BYTES ? (string) md5_file( $path ) : filesize( $path ) . ':' . filemtime( $path ) . ':' . filectime( $path );
7642 - }
7643 -
7644 - $keys[ $bucket ][] = md5( $bucket . '|' . $type . '|' . $file . '|' . $state );
7645 - }
7646 - }
7647 -
7648 - // In order of importance: only the first 5,000 are remembered, and
7649 - // what is not remembered is reported again.
7650 - return array_values( array_unique( array_merge( $keys['config'], $keys['closed'], $keys['suspicious'], $keys['modified'], $keys['extra'] ) ) );
4926 + $this->send_notification( $results, $notify_level, $closed_plugins );
7651 4927 }
7652 4928
7653 4929 /**
7654 4930 * Collect the closed/removed plugins (excluding ignored slugs) so they can
@@ -7683,10 +4959,8 @@
7683 4959 'suspicious' => array_merge( $results1['suspicious'] ?? array(), $results2['suspicious'] ?? array() ),
7684 4960 'extra' => array_merge( $results1['extra'] ?? array(), $results2['extra'] ?? array() ),
7685 4961 'new' => $results1['new'] ?? array(),
7686 4962 'errors' => array_merge( $results1['errors'] ?? array(), $results2['errors'] ?? array() ),
7687 - // Plugins and themes with no official copy to compare with.
7688 - 'unverified' => array_merge( $results1['unverified'] ?? array(), $results2['unverified'] ?? array() ),
7689 4963 'scan_time' => $results1['scan_time'] ?? 0,
7690 4964 'incomplete' => $results1['incomplete'] ?? false,
7691 4965 );
7692 4966 }
@@ -7767,31 +5041,16 @@
7767 5041 $critical_config_count = count( $critical_config );
7768 5042 $modified_count = count( $regular_modified );
7769 5043 $closed_count = count( $closed_plugins );
7770 5044
7771 - // The subject says what was found. Suspicious files and closed plugins
7772 - // keep the alert wording. A change in wp-config.php or .htaccess with
7773 - // nothing else next to it names the files instead: most of the time it
7774 - // is a plugin rewriting its own block, and "SECURITY ALERT" on every
7775 - // one of those taught people to read the subject as "I was hacked".
7776 - if ( $suspicious_count > 0 || $closed_count > 0 ) {
5045 + // Use more urgent subject when suspicious files, critical config changes,
5046 + // closed plugins or self-integrity findings are found (all security-critical).
5047 + if ( $suspicious_count > 0 || $critical_config_count > 0 || $closed_count > 0 ) {
7777 5048 $subject = sprintf(
7778 5049 /* translators: %s: Site name */
7779 5050 __( '[%s] SECURITY ALERT: File integrity issues detected', 'vigilante' ),
7780 5051 $site_name
7781 5052 );
7782 - } elseif ( $critical_config_count > 0 ) {
7783 - $changed = array();
7784 - foreach ( $critical_config as $item ) {
7785 - $changed[] = (string) ( $item['file'] ?? '' );
7786 - }
7787 -
7788 - $subject = sprintf(
7789 - /* translators: 1: Site name, 2: The file that changed, or both of them, such as "wp-config.php, .htaccess" */
7790 - __( '[%1$s] %2$s changed: review needed', 'vigilante' ),
7791 - $site_name,
7792 - implode( ', ', array_unique( array_filter( $changed ) ) )
7793 - );
7794 5053 } else {
7795 5054 $subject = sprintf(
7796 5055 /* translators: %s: Site name */
7797 5056 __( '[%s] File integrity issues detected', 'vigilante' ),
@@ -7826,9 +5085,9 @@
7826 5085 // Suspicious files section
7827 5086 if ( ! empty( $results['suspicious'] ) ) {
7828 5087 $inner .= $this->get_section_html(
7829 5088 __( 'Suspicious files', 'vigilante' ),
7830 - __( 'Files that can run code where none is expected, or that contain code written the way malware hides itself. Some are harmless, such as cache files a plugin generates or a .htaccess that protects a folder: check the path and the reason before acting.', 'vigilante' ),
5089 + __( 'These files may contain malicious code. Review immediately.', 'vigilante' ),
7831 5090 $results['suspicious'],
7832 5091 '#d63638',
7833 5092 '#fef1f1',
7834 5093 20,
@@ -7839,9 +5098,9 @@
7839 5098 // Extra files section
7840 5099 if ( ! empty( $results['extra'] ) ) {
7841 5100 $inner .= $this->get_section_html(
7842 5101 __( 'Extra files', 'vigilante' ),
7843 - __( 'Files that are not part of WordPress, or of the plugin or theme they sit in. Many are legitimate. A copy of wp-config.php or a database dump in the site root should be moved out of it.', 'vigilante' ),
5102 + __( 'PHP files not in the original WordPress.org distribution.', 'vigilante' ),
7844 5103 $results['extra'],
7845 5104 '#b32d2e',
7846 5105 '#fdf6f4',
7847 5106 20,
@@ -7852,9 +5111,9 @@
7852 5111 // Critical config files section (wp-config.php, .htaccess modified outside Vigilante)
7853 5112 if ( ! empty( $critical_config ) ) {
7854 5113 $inner .= $this->get_section_html(
7855 5114 __( 'Critical config files modified', 'vigilante' ),
7856 - __( 'Changed since you last approved them. Cache, security and redirection plugins rewrite their own blocks in these files, often on every update, and attackers write to them too. Review the changes and approve them if they belong to something you use.', 'vigilante' ),
5115 + __( 'These files are common targets for code injection. Review the changes and approve if they are legitimate.', 'vigilante' ),
7857 5116 $critical_config,
7858 5117 '#e36210',
7859 5118 '#fdf2e6',
7860 5119 10,
@@ -7865,9 +5124,9 @@
7865 5124 // Modified files section (only if notify_level is 'all')
7866 5125 if ( 'all' === $notify_level && ! empty( $regular_modified ) ) {
7867 5126 $inner .= $this->get_section_html(
7868 5127 __( 'Modified files', 'vigilante' ),
7869 - __( 'These files do not match the copy WordPress.org publishes for the installed version. Reinstalling the plugin, the theme or WordPress brings the original back.', 'vigilante' ),
5128 + __( 'Checksum mismatch with WordPress.org originals.', 'vigilante' ),
7870 5129 $regular_modified,
7871 5130 '#dba617',
7872 5131 '#fdf8e8',
7873 5132 15,
@@ -7882,31 +5141,8 @@
7882 5141 if ( $closed_count > 0 ) {
7883 5142 $inner .= $this->build_closed_plugins_email_section( $closed_plugins );
7884 5143 }
7885 5144
7886 - // A partial scan says so and names what it did not get to. With no
7887 - // finding in a plugin that was never looked at, silence here read as
7888 - // "that plugin is fine".
7889 - if ( self::is_partial( $results ) ) {
7890 - $partial_lines = array_merge(
7891 - array_slice( array_map( 'strval', (array) ( $results['errors'] ?? array() ) ), 0, 10 ),
7892 - self::not_checked_lines( $results )
7893 - );
7894 -
7895 - $inner .= '<p style="margin:0 0 8px;font-size:13px;color:#1d2327;"><strong>' . esc_html__( 'The last scan could not check everything, so its result is partial.', 'vigilante' ) . '</strong></p>';
7896 - $inner .= '<ul style="margin:0 0 16px 18px;padding:0;font-size:13px;color:#50575e;">';
7897 -
7898 - foreach ( $partial_lines as $partial_line ) {
7899 - $inner .= '<li>' . esc_html( $partial_line ) . '</li>';
7900 - }
7901 -
7902 - $inner .= '</ul>';
7903 - }
7904 -
7905 - // Where the explanation is. The email stays a list; what each kind of
7906 - // finding usually is and what to do about it lives next to the results.
7907 - $inner .= '<p style="margin:0 0 16px;font-size:12px;color:#50575e;">' . esc_html__( 'File Integrity explains what each kind of finding usually is and what to do about it. A finding stays listed until you approve it, ignore it or fix it.', 'vigilante' ) . '</p>';
7908 -
7909 5145 // CTA button
7910 5146 $inner .= Vigilante_Email_Template::button(
7911 5147 admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-last-scan' ),
7912 5148 __( 'Review in Vigilant', 'vigilante' )
@@ -7912,20 +5148,13 @@
7912 5148 __( 'Review in Vigilant', 'vigilante' )
7913 5149 );
7914 5150
7915 5151 $is_alert = ( $suspicious_count > 0 || $critical_config_count > 0 || $closed_count > 0 );
5152 + $title = $is_alert
5153 + ? __( 'Security alert', 'vigilante' )
5154 + : __( 'File integrity report', 'vigilante' );
7916 5155
7917 - // Same three cases as the subject.
7918 - if ( $suspicious_count > 0 || $closed_count > 0 ) {
7919 - $title = __( 'Security alert', 'vigilante' );
7920 - } elseif ( $critical_config_count > 0 ) {
7921 - $title = __( 'Configuration file changed', 'vigilante' );
7922 - } else {
7923 - $title = __( 'File integrity report', 'vigilante' );
7924 - }
7925 -
7926 - // The caller records what was reported only when the email went out.
7927 - return (bool) Vigilante_Email_Template::send( $to, $subject, $title, $inner, $is_alert );
5156 + Vigilante_Email_Template::send( $to, $subject, $title, $inner, $is_alert );
7928 5157 }
7929 5158
7930 5159 /**
7931 5160 * Build the closed + removed plugins block for the scan email.