Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.23% covered (warning)
73.23%
93 / 127
58.82% covered (warning)
58.82%
10 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Threat_File
74.40% covered (warning)
74.40%
93 / 125
58.82% covered (warning)
58.82%
10 / 17
217.92
0.00% covered (danger)
0.00%
0 / 1
 get_owner
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
14
 can_delete
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 is_loaded_stray
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 is_stray
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
9.24
 can_restore
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
6
 get_deletable_root
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 is_loaded
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 delete
25.00% covered (danger)
25.00%
2 / 8
0.00% covered (danger)
0.00%
0 / 1
27.67
 is_code_in_uploads
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 restore
72.00% covered (warning)
72.00%
18 / 25
0.00% covered (danger)
0.00%
0 / 1
13.66
 get_plugin
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
6.01
 get_core_checksum
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_core_relative
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
5
 is_core_in_content
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 get_core_checksums
26.32% covered (danger)
26.32%
5 / 19
0.00% covered (danger)
0.00%
0 / 1
33.60
 get_relative
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 get_first_segment
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * What a file Scan flagged belongs to, and deleting or restoring it.
4 *
5 * @package automattic/jetpack-protect
6 */
7
8namespace Automattic\Jetpack\Protect;
9
10use WP_Error;
11
12if ( ! defined( 'ABSPATH' ) ) {
13    exit( 0 );
14}
15
16/**
17 * Works out whether a flagged file is part of WordPress, a plugin or a theme, and acts on it.
18 *
19 * @since $$next-version$$
20 */
21class Threat_File {
22
23    /**
24     * Transient caching WordPress.org's checksums for the installed core version.
25     *
26     * @var string
27     */
28    const CHECKSUMS_CACHE = 'jetpack_protect_dashboard_core_checksums';
29
30    /**
31     * Files that configure the site, so deleting one would take it down.
32     *
33     * @var string[]
34     */
35    const PROTECTED_FILES = array( 'wp-config.php', '.htaccess', '.user.ini', 'php.ini', 'web.config' );
36
37    /**
38     * The files WordPress itself ships inside wp-content, relative to it.
39     *
40     * @var string[]
41     */
42    const CORE_CONTENT_FILES = array( 'index.php', 'plugins/index.php', 'themes/index.php' );
43
44    /**
45     * What a file belongs to.
46     *
47     * @param string $path The file's real path, inside the site.
48     * @return array The owner `type` (`plugin`, `theme`, `mu-plugin`, `core`, `unknown` or `none`), with `name` and `version` when known; core files add `modified`.
49     */
50    public static function get_owner( $path ) {
51        $plugin = self::get_plugin( $path );
52        if ( $plugin ) {
53            return $plugin;
54        }
55
56        // Every theme root, since plugins can register more with register_theme_directory().
57        $theme_roots = empty( $GLOBALS['wp_theme_directories'] ) ? array( get_theme_root() ) : (array) $GLOBALS['wp_theme_directories'];
58        foreach ( $theme_roots as $theme_root ) {
59            $slug  = self::get_first_segment( $path, $theme_root );
60            $theme = $slug ? wp_get_theme( $slug, $theme_root ) : null;
61            if ( $theme && $theme->exists() ) {
62                return array(
63                    'type'    => 'theme',
64                    'name'    => $theme->get( 'Name' ),
65                    'version' => $theme->get( 'Version' ),
66                );
67            }
68        }
69
70        if ( self::get_first_segment( $path, WPMU_PLUGIN_DIR ) ) {
71            return array( 'type' => 'mu-plugin' );
72        }
73
74        $checksum = self::get_core_checksum( $path );
75        if ( $checksum ) {
76            return array(
77                'type'     => 'core',
78                'version'  => get_bloginfo( 'version' ),
79                'modified' => md5_file( $path ) !== $checksum,
80            );
81        }
82        // Without WordPress.org's checksums, a core file outside wp-content, or one of the few inside it, looks like a stray one.
83        $in_content = self::get_relative( $path, WP_CONTENT_DIR );
84        if ( null !== self::get_core_relative( $path ) && ( null === $in_content || self::is_core_in_content() || in_array( $in_content, self::CORE_CONTENT_FILES, true ) ) && ! self::get_core_checksums() ) {
85            return array( 'type' => 'unknown' );
86        }
87
88        return array( 'type' => 'none' );
89    }
90
91    /**
92     * Whether the file is a stray one: no plugin, theme or core file, and nothing this request loaded.
93     *
94     * @param string $path  The file's real path.
95     * @param array  $owner The file's owner, from get_owner().
96     * @return bool
97     */
98    public static function can_delete( $path, $owner ) {
99        return self::is_stray( $path, $owner ) && ! self::is_loaded( $path );
100    }
101
102    /**
103     * Whether the site loading the file is all that stops can_delete() allowing it.
104     *
105     * @param string $path  The file's real path.
106     * @param array  $owner The file's owner, from get_owner().
107     * @return bool
108     */
109    public static function is_loaded_stray( $path, $owner ) {
110        return self::is_stray( $path, $owner ) && self::is_loaded( $path );
111    }
112
113    /**
114     * Whether the file is no plugin, theme, core or configuration file, in a place a stray file is deleted from.
115     *
116     * @param string $path  The file's real path.
117     * @param array  $owner The file's owner, from get_owner().
118     * @return bool
119     */
120    private static function is_stray( $path, $owner ) {
121        if ( 'none' !== $owner['type'] || in_array( basename( $path ), self::PROTECTED_FILES, true ) || ! wp_is_file_mod_allowed( 'jetpack_protect_threat_file' ) ) {
122            return false;
123        }
124        // Not in either, such as the site root's index.php that loads WordPress kept in its own directory.
125        if ( ! is_file( $path ) || is_link( $path ) || ! self::get_deletable_root( $path ) ) {
126            return false;
127        }
128        // Drop-ins such as object-cache.php sit loose in wp-content but run the site.
129        if ( ! function_exists( '_get_dropins' ) ) {
130            require_once ABSPATH . 'wp-admin/includes/plugin.php';
131        }
132        return ! ( dirname( $path ) === realpath( WP_CONTENT_DIR ) && isset( _get_dropins()[ basename( $path ) ] ) );
133    }
134
135    /**
136     * Whether a modified core file can be put back as WordPress.org ships it.
137     *
138     * @param string $path  The file's real path.
139     * @param array  $owner The file's owner, from get_owner().
140     * @return bool
141     */
142    public static function can_restore( $path, $owner ) {
143        return 'core' === $owner['type'] && ! empty( $owner['modified'] ) && current_user_can( 'update_core' ) && wp_is_writable( $path ) && wp_is_writable( dirname( $path ) ) && wp_is_file_mod_allowed( 'jetpack_protect_threat_file' );
144    }
145
146    /**
147     * The WordPress directory or content directory a file is in, the only places a stray file is deleted from.
148     *
149     * @param string $path The file's real path.
150     * @return string|null The directory's real path, or null when the file is in neither.
151     */
152    public static function get_deletable_root( $path ) {
153        foreach ( array( WP_CONTENT_DIR, ABSPATH ) as $directory ) {
154            if ( null !== self::get_relative( $path, $directory ) ) {
155                return realpath( $directory );
156            }
157        }
158        return null;
159    }
160
161    /**
162     * Whether this request loaded the file, as wp-config.php and drop-ins load files such as db-config.php.
163     *
164     * @param string $path The file's real path.
165     * @return bool
166     */
167    public static function is_loaded( $path ) {
168        return in_array( $path, array_map( 'realpath', get_included_files() ), true );
169    }
170
171    /**
172     * Delete a stray file that can_delete() allows, and only that regular file.
173     *
174     * @param string $path The file's real path.
175     * @return true|WP_Error
176     */
177    public static function delete( $path ) {
178        if ( self::is_loaded( $path ) ) {
179            return new WP_Error( 'threat_file_loaded', __( 'Your site loads this file, so it can’t be deleted here.', 'jetpack-protect-pkg' ), array( 'status' => 403 ) );
180        }
181        $root = self::get_deletable_root( $path );
182        if ( $root && is_file( $path ) && ! is_link( $path ) && wp_delete_file_from_directory( $path, $root ) ) {
183            clearstatcache( true, $path );
184            if ( ! file_exists( $path ) ) {
185                return true;
186            }
187        }
188        return new WP_Error( 'threat_file_not_deleted', __( 'WordPress couldn’t delete the file. Check its permissions.', 'jetpack-protect-pkg' ), array( 'status' => 500 ) );
189    }
190
191    /**
192     * Whether code is in the uploads folder, where WordPress never puts any.
193     *
194     * @param string $path The file's real path.
195     * @return bool
196     */
197    public static function is_code_in_uploads( $path ) {
198        $uploads = wp_upload_dir( null, false );
199        return null !== self::get_relative( $path, $uploads['basedir'] ) && (bool) preg_match( '/\.(php\d?|phtml|phar)$/i', $path );
200    }
201
202    /**
203     * Replace a modified core file with WordPress.org's copy, once its checksum matches.
204     *
205     * @param string $path The file's real path.
206     * @return true|WP_Error
207     */
208    public static function restore( $path ) {
209        $relative = self::get_core_relative( $path );
210        $checksum = self::get_core_checksum( $path );
211        if ( ! $checksum ) {
212            return new WP_Error( 'not_core_file', __( 'This isn’t a WordPress core file.', 'jetpack-protect-pkg' ), array( 'status' => 400 ) );
213        }
214
215        $response = wp_safe_remote_get( sprintf( 'https://core.svn.wordpress.org/tags/%s/%s', rawurlencode( get_bloginfo( 'version' ) ), implode( '/', array_map( 'rawurlencode', explode( '/', $relative ) ) ) ), array( 'timeout' => 20 ) );
216        $original = is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ? null : wp_remote_retrieve_body( $response );
217        if ( null === $original ) {
218            return new WP_Error( 'original_unavailable', __( 'The original file couldn’t be downloaded from WordPress.org. Try again in a few minutes.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) );
219        }
220        // Files such as wp-includes/version.php differ in a localized package, and WordPress.org only serves the English one.
221        if ( md5( $original ) !== $checksum ) {
222            return new WP_Error( 'original_mismatch', __( 'WordPress.org’s copy of this file doesn’t match your site’s WordPress package, so it can’t be restored here. Reinstall WordPress from Dashboard â†’ Updates instead.', 'jetpack-protect-pkg' ), array( 'status' => 409 ) );
223        }
224
225        if ( ! function_exists( 'wp_tempnam' ) ) {
226            require_once ABSPATH . 'wp-admin/includes/file.php';
227        }
228        // Written beside the file, then renamed over it, so a failed write never leaves it half written.
229        $temp    = wp_tempnam( basename( $path ), trailingslashit( dirname( $path ) ) );
230        $written = file_put_contents( $temp, $original ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Checked writable, and verified against WordPress.org.
231        if ( strlen( $original ) !== $written || md5_file( $temp ) !== $checksum ) {
232            wp_delete_file( $temp );
233            return new WP_Error( 'file_not_written', __( 'WordPress couldn’t write the file. Check its permissions.', 'jetpack-protect-pkg' ), array( 'status' => 500 ) );
234        }
235        $perms = fileperms( $path );
236        if ( $perms ) {
237            chmod( $temp, $perms & 0777 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Keep the original file's mode.
238        }
239        if ( ! rename( $temp, $path ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Same directory, so it replaces the file in one step.
240            wp_delete_file( $temp );
241            return new WP_Error( 'file_not_written', __( 'WordPress couldn’t write the file. Check its permissions.', 'jetpack-protect-pkg' ), array( 'status' => 500 ) );
242        }
243        // Else PHP can keep running the modified file's compiled code.
244        wp_opcache_invalidate( $path, true );
245        return true;
246    }
247
248    /**
249     * The installed plugin a file is in, if any.
250     *
251     * @param string $path The file's real path.
252     * @return array|null
253     */
254    private static function get_plugin( $path ) {
255        $relative = self::get_relative( $path, WP_PLUGIN_DIR );
256        if ( null === $relative ) {
257            return null;
258        }
259        if ( ! function_exists( 'get_plugins' ) ) {
260            require_once ABSPATH . 'wp-admin/includes/plugin.php';
261        }
262        $plugins = get_plugins();
263        $segment = strtok( $relative, '/' );
264        $file    = Dashboard_Threats::get_plugin_files( $plugins )[ basename( $segment, '.php' ) ] ?? null;
265        // A loose file in the plugins folder isn't a plugin unless it is the plugin's own main file.
266        if ( ! $file || ( false === strpos( $relative, '/' ) && $file !== $relative ) ) {
267            return null;
268        }
269        return array(
270            'type'    => 'plugin',
271            'name'    => $plugins[ $file ]['Name'],
272            'version' => $plugins[ $file ]['Version'],
273        );
274    }
275
276    /**
277     * WordPress.org's checksum for a core file.
278     *
279     * @param string $path The file's real path.
280     * @return string|null Null when it isn't a core file.
281     */
282    private static function get_core_checksum( $path ) {
283        $relative = self::get_core_relative( $path );
284        return null === $relative ? null : self::get_core_checksums()[ $relative ] ?? null;
285    }
286
287    /**
288     * A file's path as WordPress.org's core package names it.
289     *
290     * @param string $path The file's real path.
291     * @return string|null Null when it can't be a core file.
292     */
293    private static function get_core_relative( $path ) {
294        // wp-content can be moved or renamed, which ABSPATH alone misses for the files core ships in it.
295        $in_content = self::get_relative( $path, WP_CONTENT_DIR );
296        if ( null !== $in_content && in_array( $in_content, self::CORE_CONTENT_FILES, true ) && ! ( 'index.php' === $in_content && self::is_core_in_content() ) ) {
297            return 'wp-content/' . $in_content;
298        }
299        return self::get_relative( $path, ABSPATH );
300    }
301
302    /**
303     * Whether WordPress sits inside the content directory, as when that is the site root, whose index.php loads the site.
304     *
305     * @return bool
306     */
307    private static function is_core_in_content() {
308        $abspath = realpath( ABSPATH );
309        $content = realpath( WP_CONTENT_DIR );
310        return $abspath && $content && ( $abspath === $content || null !== self::get_relative( $abspath, $content ) );
311    }
312
313    /**
314     * WordPress.org's checksums for the installed core files and language package, cached for a day.
315     *
316     * @return array MD5 checksums keyed by path from ABSPATH; empty when WordPress.org is unreachable.
317     */
318    private static function get_core_checksums() {
319        global $wp_local_package;
320        $version = get_bloginfo( 'version' );
321        $locale  = empty( $wp_local_package ) ? 'en_US' : $wp_local_package;
322        $cached  = get_transient( self::CHECKSUMS_CACHE );
323        if ( is_array( $cached ) && ( $cached['version'] ?? null ) === $version && ( $cached['locale'] ?? null ) === $locale ) {
324            return $cached['checksums'];
325        }
326
327        if ( ! function_exists( 'get_core_checksums' ) ) {
328            require_once ABSPATH . 'wp-admin/includes/update.php';
329        }
330        $checksums = get_core_checksums( $version, $locale );
331        $checksums = is_array( $checksums ) ? $checksums : array();
332        set_transient(
333            self::CHECKSUMS_CACHE,
334            array(
335                'version'   => $version,
336                'locale'    => $locale,
337                'checksums' => $checksums,
338            ),
339            $checksums ? DAY_IN_SECONDS : HOUR_IN_SECONDS
340        );
341        return $checksums;
342    }
343
344    /**
345     * A path relative to a directory.
346     *
347     * @param string $path      A real path.
348     * @param string $directory The directory.
349     * @return string|null Null when the path isn't inside it.
350     */
351    public static function get_relative( $path, $directory ) {
352        $directory = $directory ? realpath( $directory ) : false;
353        if ( ! $directory || 0 !== strpos( $path, trailingslashit( $directory ) ) ) {
354            return null;
355        }
356        return substr( $path, strlen( trailingslashit( $directory ) ) );
357    }
358
359    /**
360     * The folder or file directly inside a directory that a path is in, such as a plugin's slug.
361     *
362     * @param string $path      A real path.
363     * @param string $directory The directory.
364     * @return string|null
365     */
366    private static function get_first_segment( $path, $directory ) {
367        $relative = self::get_relative( $path, $directory );
368        return null === $relative ? null : strtok( $relative, '/' );
369    }
370}