Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 144
0.00% covered (danger)
0.00%
0 / 6
CRAP
n/a
0 / 0
Safeguard\search_plugin_info
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
Safeguard\request_check_plugin
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
182
Safeguard\uncompress_package
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
Safeguard\clean_folder
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
Safeguard\get_plugin_data_from_package
0.00% covered (danger)
0.00%
0 / 54
0.00% covered (danger)
0.00%
0 / 1
462
Safeguard\log_safeguard_error
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * Safeguard utilities file.
4 *
5 * @package safeguard
6 */
7
8namespace Safeguard;
9
10use Automattic\Jetpack\Connection\Client as Jetpack_Client;
11use Jetpack_Options;
12use RecursiveDirectoryIterator;
13use RecursiveIteratorIterator;
14use WP_Error;
15use WPCOMSH_Log;
16use ZipArchive;
17
18const DOTORG_API_HOST             = 'https://api.wordpress.org';
19const DOTORG_PLUGINS_HOST         = DOTORG_API_HOST . '/plugins';
20const DOTORG_PLUGIN_INFO_ENDPOINT = DOTORG_PLUGINS_HOST . '/info/1.0/';
21
22/**
23 * Perform a request to the .org API to get information about the given plugin.
24 *
25 * @param  string $slug Plugin slug.
26 * @return array|WP_Error
27 */
28function search_plugin_info( $slug ) {
29    $request     = array(
30        'action'  => 'plugin_information',
31        'timeout' => 15,
32    );
33    $response    = wp_remote_post( DOTORG_PLUGIN_INFO_ENDPOINT . $slug, array( 'body' => $request ) );
34    $plugin_info = maybe_unserialize( $response['body'] );
35
36    if ( $plugin_info && property_exists( $plugin_info, 'error' ) ) {
37        return new WP_Error( 'search_plugin_error', $plugin_info->error );
38    }
39
40    return $plugin_info;
41}
42
43/**
44 * Perform a request to WP COM API endpoint to check the plugin.
45 *
46 * @param string $slug Plugin slug.
47 * @param array  $body Array data with needed information to check the plugin.
48 * @return WP_Error|array Error instance if something fails, the response if plugin is accepted.
49 */
50function request_check_plugin( $slug, $body ) {
51    $wpcom_blog_id = Jetpack_Options::get_option( 'id' );
52    $endpoint      = "/sites/{$wpcom_blog_id}/plugins/{$slug}/check";
53
54    $request = Jetpack_Client::wpcom_json_api_request_as_blog(
55        $endpoint,
56        Jetpack_Client::WPCOM_JSON_API_VERSION,
57        array( 'method' => 'POST' ),
58        $body
59    );
60
61    if ( is_wp_error( $request ) ) {
62        return $request;
63    }
64
65    if ( ! is_array( $request ) || ! isset( $request['body'] ) ) {
66        return new WP_Error(
67            'failed_to_fetch_data',
68            esc_html__( 'Unable to fetch the requested data.', 'wpcomsh' ),
69            array( 'status' => 500 )
70        );
71    }
72
73    $response_code = wp_remote_retrieve_response_code( $request );
74    $response      = json_decode( wp_remote_retrieve_body( $request ), true ) ?? array();
75
76    $error_data = array(
77        'wpcom_blog_id' => $wpcom_blog_id,
78        'endpoint'      => $endpoint,
79        'response'      => $response,
80    );
81
82    if ( $response_code === 404 ) {
83        $error_data['response-code'] = 404;
84        return new WP_Error( 'checking_plugin_failed', 'Request route not found.', $error_data );
85    }
86
87    // It should be changed passing the `reject` action.
88    if ( $response_code === 400 ) {
89        $error_data['response-code'] = 400;
90        return new WP_Error( 'unaccepted_plugin', $response['message'] ?? '', $error_data );
91    }
92
93    if ( empty( $response ) || ! $response_code || $response_code < 200 || $response_code >= 300 ) {
94        return new WP_Error(
95            'checking_plugin_failed',
96            "Invalid response from API - {$response_code}",
97            $error_data
98        );
99    }
100
101    if ( isset( $response['action'] ) && $response['action'] === 'reject' ) {
102        return new WP_Error( 'unaccepted_plugin', $response['message'] ?? '', $response['threats'] ?? '' );
103    }
104
105    $result = array(
106        'action'     => (string) ( $response['action'] ?? '' ),
107        'message'    => (string) ( $response['message'] ?? '' ),
108        'registered' => (string) ( $response['registered'] ?? '' ),
109    );
110
111    if ( array_key_exists( 'threats', $response ) ) {
112        $result['threats'] = $response['threats'];
113    }
114
115    return $result;
116}
117
118/**
119 * Tries to decompress the package into a temporary folder.
120 *
121 * @param string $package Plugin package.
122 * @return string|WP_Error
123 */
124function uncompress_package( $package ) {
125    $package_info     = pathinfo( $package );
126    $package_filename = $package_info['filename'];
127
128    $current_upload_folder = wp_upload_dir();
129    $path                  = $current_upload_folder['path'];
130
131    $tmp_package_folder = "{$path}/{$package_filename}";
132
133    // Try to unzip the file.
134    $zip_handler = new ZipArchive();
135    $was_opened  = $zip_handler->open( $package );
136    if ( $was_opened !== true ) {
137        return new WP_Error( 'process_failed', 'The zip file could not be opened.' );
138    }
139
140    $was_uncompressed = $zip_handler->extractTo( $tmp_package_folder );
141    if ( ! $was_uncompressed ) {
142        return new WP_Error( 'process_failed', 'The zip file could not be decompressed.' );
143    }
144
145    $zip_handler->close();
146
147    return $tmp_package_folder;
148}
149
150/**
151 * Clean folder structure.
152 *
153 * @param string $dir Directory path.
154 *
155 * @return bool
156 */
157function clean_folder( $dir ) {
158    if ( ! is_dir( $dir ) ) {
159        return false;
160    }
161
162    $it        = new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS );
163    $old_files = new RecursiveIteratorIterator( $it, RecursiveIteratorIterator::CHILD_FIRST );
164
165    foreach ( $old_files as $old_file ) {
166        if ( $old_file->isDir() ) {
167            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
168            rmdir( $old_file->getRealPath() );
169        } else {
170            // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
171            unlink( $old_file->getRealPath() );
172        }
173    }
174    // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
175    rmdir( $dir );
176
177    return true;
178}
179
180/**
181 * It tries to get the plugin-slug and plugin-version from the file package.
182 *
183 * Some information:
184 *
185 *  > If you use a directory to contain your Plugin files, then the directory name will be used by WordPress
186 *  > when checking the WordPress Plugin Repository for updates. If your plugin only consists of a single PHP
187 *  > file, then the file name will be used. If WordPress tells you that a newer version of your Plugin is
188 *  > available, but you know nothing about a newer version, beware. It's possible that another Plugin with the
189 *  > same directory name or file name is in the Plugin Repository, and it's this one which WordPress is seeing.
190 *
191 *  - https://codex.wordpress.org/Writing_a_Plugin#Names.2C_Files.2C_and_Locations
192 *
193 * @param string $package Path of the package. Generally it's a .zip file.
194 * @return array|WP_Error  Slug and version Array, or a WP_Error instance if something fails.
195 */
196function get_plugin_data_from_package( $package ) {
197    if ( ! is_file( $package ) ) {
198        return new WP_Error( 'process_package_fails', 'Invalid plugin file.' );
199    }
200
201    $plugin_folder     = false;
202    $tmp_plugin_folder = uncompress_package( $package );
203    if ( is_wp_error( $tmp_plugin_folder ) ) {
204        return $tmp_plugin_folder;
205    }
206
207    $tmp_plugin_dir = @ opendir( $tmp_plugin_folder ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
208    $plugin_files   = array();
209    if ( $tmp_plugin_dir ) {
210        while ( ( $file = readdir( $tmp_plugin_dir ) ) !== false ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
211            if ( substr( $file, 0, 1 ) === '.' ) {
212                continue;
213            }
214            if ( is_dir( $tmp_plugin_folder . '/' . $file ) ) {
215                // Get plugin slug from the folder.
216                $plugin_folder = $tmp_plugin_folder . '/' . $file;
217
218                $plugins_subdir = @ opendir( $tmp_plugin_folder . '/' . $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
219                if ( $plugins_subdir ) {
220                    while ( ( $subfile = readdir( $plugins_subdir ) ) !== false ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
221                        if ( substr( $subfile, 0, 1 ) === '.' ) {
222                            continue;
223                        }
224                        if ( substr( $subfile, -4 ) === '.php' ) {
225                            $plugin_files[] = "$file/$subfile";
226                        }
227                    }
228                    closedir( $plugins_subdir );
229                }
230            } elseif ( substr( $file, -4 ) === '.php' ) {
231                    $plugin_files[] = $file;
232
233                    // Get plugin slug from the file.
234                    $plugin_folder = $tmp_plugin_folder . '/' . $file;
235            }
236        }
237        closedir( $tmp_plugin_dir );
238    }
239
240    if ( ! $plugin_folder ) {
241        return new WP_Error( 'process_package_fails', 'Getting plugin slug from package failed.' );
242    }
243
244    if ( empty( $plugin_files ) ) {
245        return new WP_Error( 'process_package_fails', 'Package does not have valid files.' );
246    }
247
248    // Finish getting plugin slug.
249    $plugin_pathinfo = pathinfo( $plugin_folder );
250    $plugin_slug     = $plugin_pathinfo['filename'];
251
252    if ( ! $plugin_slug ) {
253        return new WP_Error( 'process_package_fails', 'Plugin slug not found.' );
254    }
255
256    $result = array(
257        'slug' => $plugin_slug,
258        'hash' => hash_file( 'sha256', $package ),
259    );
260
261    /*
262     * Populate result array with plugin data, check if the plugin name is defined,
263     * and get the plugin version if it exists.
264     */
265    foreach ( $plugin_files as $plugin_file ) {
266        $plugin_filename = "{$tmp_plugin_folder}/{$plugin_file}";
267        if ( ! is_readable( $plugin_folder ) ) {
268            continue;
269        }
270
271        $plugin_data = get_plugin_data( $plugin_filename, false, false );
272        if ( empty( $plugin_data['Name'] ) ) {
273            continue;
274        }
275
276        $result['header'] = $plugin_data;
277    }
278
279    if ( ! array_key_exists( 'header', $result ) ) {
280        return new WP_Error( 'process_package_fails', 'Package does not have valid header.' );
281    }
282
283    // Copy version from header.
284    if ( array_key_exists( 'Version', $result['header'] ) ) {
285        $result['version'] = $result['header']['Version'];
286    }
287    if ( array_key_exists( 'version', $result['header'] ) ) {
288        $result['version'] = $result['header']['version'];
289    }
290
291    // Clean temporary folder.
292    clean_folder( $tmp_plugin_folder );
293
294    return $result;
295}
296
297/**
298 * Logs safeguard errors.
299 *
300 * @param WP_Error|string $error WP_Error object or error message.
301 * @param array           $extra Optional. Additional information about the error. Defaults to empty array.
302 *
303 * @return WP_Error|string
304 */
305function log_safeguard_error( $error, $extra = array() ) {
306    $message = 'Safeguard: ';
307
308    if ( is_wp_error( $error ) ) {
309        $message .= $error->get_error_message();
310    } else {
311        $message .= $error;
312    }
313
314    WPCOMSH_Log::unsafe_direct_log(
315        $message,
316        wp_json_encode( $extra, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES )
317    );
318
319    return $error;
320}