diff --git a/README.md b/README.md index 8a3d2af28..c44db5772 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,12 @@ Quick links: [Using](#using) | [Installing](#installing) | [Contributing](#contr ## Using +This package implements the following commands: + +### wp checksum core + +Verifies WordPress files against WordPress.org's checksums. + ~~~ wp checksum core [--version=] [--locale=] ~~~ @@ -52,6 +58,40 @@ site. Warning: File doesn't verify against checksum: wp-config-sample.php Error: WordPress installation doesn't verify against checksums. + + +### wp checksum plugin + +Verifies plugin files against WordPress.org's checksums. + +~~~ +wp checksum plugin [...] [--all] [--strict] [--format=] +~~~ + +**OPTIONS** + + [...] + One or more plugins to verify. + + [--all] + If set, all plugins will be verified. + + [--strict] + If set, even "soft changes" like readme.txt changes will trigger + checksum errors. + + [--format=] + Render output in a specific format. + --- + default: table + options: + - table + - json + - csv + - yaml + - count + --- + ## Installing This package is included with WP-CLI itself, no additional installation necessary. diff --git a/checksum-command.php b/checksum-command.php index 6ab66ca41..3f6f78be1 100644 --- a/checksum-command.php +++ b/checksum-command.php @@ -9,4 +9,8 @@ require_once $autoload; } -WP_CLI::add_command( 'checksum', 'Checksum_Command' ); +WP_CLI::add_command( 'checksum core', 'Checksum_Core_Command' ); +WP_CLI::add_command( 'checksum plugin', 'Checksum_Plugin_Command' ); +if ( class_exists( 'WP_CLI\Dispatcher\CommandNamespace' ) ) { + WP_CLI::add_command( 'checksum', 'Checksum_Namespace' ); +} diff --git a/composer.json b/composer.json index cdad47a57..d7a356ce4 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,8 @@ }, "bundled": true, "commands": [ - "checksum core" + "checksum core", + "checksum plugin" ] } } diff --git a/features/checksum-plugin.feature b/features/checksum-plugin.feature new file mode 100644 index 000000000..9908191ac --- /dev/null +++ b/features/checksum-plugin.feature @@ -0,0 +1,108 @@ +Feature: Validate checksums for WordPress plugins + + Scenario: Verify plugin checksums + Given a WP install + + When I run `wp plugin install duplicate-post --version=3.2.1` + Then STDOUT should not be empty + And STDERR should be empty + + When I run `wp checksum plugin duplicate-post` + Then STDOUT should be: + """ + Success: Verified 1 of 1 plugins. + """ + + Scenario: Modified plugin doesn't verify + Given a WP install + + When I run `wp plugin install duplicate-post --version=3.2.1` + Then STDOUT should not be empty + And STDERR should be empty + + Given "Duplicate Post" replaced with "Different Name" in the wp-content/plugins/duplicate-post/duplicate-post.php file + + When I try `wp checksum plugin duplicate-post --format=json` + Then STDOUT should contain: + """ + "plugin_name":"duplicate-post","file":"duplicate-post.php","message":"Checksum does not match" + """ + And STDERR should be: + """ + Error: No plugins verified (1 failed). + """ + + When I run `rm wp-content/plugins/duplicate-post/duplicate-post.css` + Then STDERR should be empty + + When I try `wp checksum plugin duplicate-post --format=json` + Then STDOUT should contain: + """ + "plugin_name":"duplicate-post","file":"duplicate-post.css","message":"File is missing" + """ + And STDERR should be: + """ + Error: No plugins verified (1 failed). + """ + + When I run `touch wp-content/plugins/duplicate-post/additional-file.php` + Then STDERR should be empty + + When I try `wp checksum plugin duplicate-post --format=json` + Then STDOUT should contain: + """ + "plugin_name":"duplicate-post","file":"additional-file.php","message":"File was added" + """ + And STDERR should be: + """ + Error: No plugins verified (1 failed). + """ + + Scenario: Soft changes are only reported in strict mode + Given a WP install + + When I run `wp plugin install duplicate-post --version=3.2.1` + Then STDOUT should not be empty + And STDERR should be empty + + Given "Duplicate Post" replaced with "Different Name" in the wp-content/plugins/duplicate-post/readme.txt file + + When I run `wp checksum plugin duplicate-post` + Then STDOUT should be: + """ + Success: Verified 1 of 1 plugins. + """ + And STDERR should be empty + + When I try `wp checksum plugin duplicate-post --strict` + Then STDOUT should not be empty + And STDERR should contain: + """ + Error: No plugins verified (1 failed). + """ + + # WPTouch 4.3.22 contains multiple checksums for some of its files. + # See https://github.com/wp-cli/checksum-command/issues/24 + Scenario: Multiple checksums for a single file are supported + Given a WP install + + When I run `wp plugin install wptouch --version=4.3.22` + Then STDOUT should not be empty + And STDERR should be empty + + When I run `wp checksum plugin wptouch` + Then STDOUT should be: + """ + Success: Verified 1 of 1 plugins. + """ + And STDERR should be empty + + Scenario: Throws an error if provided with neither plugin names nor the --all flag + Given a WP install + + When I try `wp checksum plugin` + Then STDERR should contain: + """ + You need to specify either one or more plugin slugs to check or use the --all flag to check all plugins. + """ + And STDOUT should be empty diff --git a/src/Checksum_Base_Command.php b/src/Checksum_Base_Command.php new file mode 100644 index 000000000..3c341b110 --- /dev/null +++ b/src/Checksum_Base_Command.php @@ -0,0 +1,69 @@ + 'application/json' ); + $response = Utils\http_request( 'GET', $url, null, $headers, + array( 'timeout' => 30 ) ); + if ( 200 === $response->status_code ) { + return $response->body; + } + WP_CLI::error( "Couldn't fetch response from {$url} (HTTP code {$response->status_code})." ); + } + + /** + * Recursively get the list of files for a given path. + * + * @param string $path Root path to start the recursive traversal in. + * + * @return array + */ + protected function get_files( $path ) { + $filtered_files = array(); + try { + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $path, + RecursiveDirectoryIterator::SKIP_DOTS ), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ( $files as $file_info ) { + $pathname = substr( $file_info->getPathname(), strlen( $path ) ); + if ( $file_info->isFile() && $this->filter_file( $pathname ) ) { + $filtered_files[] = $pathname; + } + } + } catch ( Exception $e ) { + WP_CLI::error( $e->getMessage() ); + } + + return $filtered_files; + } + + /** + * Whether to include the file in the verification or not. + * + * Can be overridden in subclasses. + * + * @param string $filepath Path to a file. + * + * @return bool + */ + protected function filter_file( $filepath ) { + return true; + } +} diff --git a/src/Checksum_Command.php b/src/Checksum_Core_Command.php similarity index 82% rename from src/Checksum_Command.php rename to src/Checksum_Core_Command.php index b37dca7d8..7e3a0887e 100644 --- a/src/Checksum_Command.php +++ b/src/Checksum_Core_Command.php @@ -7,17 +7,7 @@ * * @package wp-cli */ -class Checksum_Command extends WP_CLI_Command { - - private static function _read( $url ) { - $headers = array('Accept' => 'application/json'); - $response = Utils\http_request( 'GET', $url, null, $headers, array( 'timeout' => 30 ) ); - if ( 200 === $response->status_code ) { - return $response->body; - } else { - WP_CLI::error( "Couldn't fetch response from {$url} (HTTP code {$response->status_code})." ); - } - } +class Checksum_Core_Command extends Checksum_Base_Command { private function get_download_offer( $locale ) { $out = unserialize( self::_read( @@ -32,7 +22,6 @@ private function get_download_offer( $locale ) { return $offer; } - /** * Verifies WordPress files against WordPress.org's checksums. * @@ -77,7 +66,7 @@ private function get_download_offer( $locale ) { * * @when before_wp_load */ - public function core( $args, $assoc_args ) { + public function __invoke( $args, $assoc_args ) { global $wp_version, $wp_local_package; if ( ! empty( $assoc_args['version'] ) ) { @@ -124,8 +113,8 @@ public function core( $args, $assoc_args ) { } } - $core_checksums_files = array_filter( array_keys( $checksums ), array( $this, 'only_core_files_filter' ) ); - $core_files = $this->get_wp_core_files(); + $core_checksums_files = array_filter( array_keys( $checksums ), array( $this, 'filter_file' ) ); + $core_files = $this->get_files( ABSPATH ); $additional_files = array_diff( $core_files, $core_checksums_files ); if ( ! empty( $additional_files ) ) { @@ -141,28 +130,15 @@ public function core( $args, $assoc_args ) { } } - private function get_wp_core_files() { - $core_files = array(); - try { - $files = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator( ABSPATH, RecursiveDirectoryIterator::SKIP_DOTS ), - RecursiveIteratorIterator::CHILD_FIRST - ); - foreach ( $files as $file_info ) { - $pathname = substr( $file_info->getPathname(), strlen( ABSPATH ) ); - if ( $file_info->isFile() && ( 0 === strpos( $pathname, 'wp-admin/' ) || 0 === strpos( $pathname, 'wp-includes/' ) ) ) { - $core_files[] = str_replace( ABSPATH, '', $file_info->getPathname() ); - } - } - } catch( Exception $e ) { - WP_CLI::error( $e->getMessage() ); - } - - return $core_files; - } - - private function only_core_files_filter( $file ) { - return ( 0 === strpos( $file, 'wp-admin/' ) || 0 === strpos( $file, 'wp-includes/' ) ); + /** + * Whether to include the file in the verification or not. + * + * @param string $filepath Path to a file. + * + * @return bool + */ + protected function filter_file( $filepath ) { + return ( 0 === strpos( $filepath, 'wp-admin/' ) || 0 === strpos( $filepath, 'wp-includes/' ) ); } /** diff --git a/src/Checksum_Namespace.php b/src/Checksum_Namespace.php new file mode 100644 index 000000000..b692300f5 --- /dev/null +++ b/src/Checksum_Namespace.php @@ -0,0 +1,12 @@ +...] + * : One or more plugins to verify. + * + * [--all] + * : If set, all plugins will be verified. + * + * [--strict] + * : If set, even "soft changes" like readme.txt changes will trigger + * checksum errors. + * + * [--format=] + * : Render output in a specific format. + * --- + * default: table + * options: + * - table + * - json + * - csv + * - yaml + * - count + * --- + */ + public function __invoke( $args, $assoc_args ) { + + $fetcher = new \WP_CLI\Fetchers\Plugin(); + $all = \WP_CLI\Utils\get_flag_value( $assoc_args, 'all', false ); + $strict = \WP_CLI\Utils\get_flag_value( $assoc_args, 'strict', false ); + $plugins = $fetcher->get_many( $all ? $this->get_all_plugin_names() : $args ); + + if ( empty( $plugins ) && ! $all ) { + WP_CLI::error( 'You need to specify either one or more plugin slugs to check or use the --all flag to check all plugins.' ); + } + + $skips = 0; + + foreach ( $plugins as $plugin ) { + $version = $this->get_plugin_version( $plugin->file ); + + if ( false === $version ) { + WP_CLI::warning( "Could not retrieve the version for plugin {$plugin->name}, skipping." ); + $skips++; + continue; + } + + $checksums = $this->get_plugin_checksums( $plugin->name, $version ); + + if ( false === $checksums ) { + WP_CLI::warning( "Could not retrieve the checksums for version {$version} of plugin {$plugin->name}, skipping." ); + $skips++; + continue; + } + + $files = $this->get_plugin_files( $plugin->file ); + + foreach ( $checksums as $file => $checksum_array ) { + if ( ! in_array( $file, $files, true ) ) { + $this->add_error( $plugin->name, $file, 'File is missing' ); + } + } + + foreach ( $files as $file ) { + if ( ! array_key_exists( $file, $checksums ) ) { + $this->add_error( $plugin->name, $file, 'File was added' ); + continue; + } + + if ( ! $strict && $this->is_soft_change_file( $file ) ) { + continue; + } + + $result = $this->check_file_checksum( dirname( $plugin->file ) . '/' . $file, $checksums[ $file ] ); + if ( true !== $result ) { + $this->add_error( $plugin->name, $file, is_string( $result ) ? $result : 'Checksum does not match' ); + } + } + } + + if ( ! empty( $this->errors ) ) { + $formatter = new \WP_CLI\Formatter( + $assoc_args, + array( 'plugin_name', 'file', 'message' ) + ); + $formatter->display_items( $this->errors ); + } + + $total = count( $plugins ); + $failures = count( array_unique( array_column( $this->errors, 'plugin_name' ) ) ); + $successes = $total - $failures - $skips; + + \WP_CLI\Utils\report_batch_operation_results( + 'plugin', + 'verify', + $total, + $successes, + $failures, + $skips + ); + } + + /** + * Add a new error to the array of detected errors. + * + * @param string $plugin_name Name of the plugin that had the error. + * @param string $file Relative path to the file that had the error. + * @param string $message Message explaining the error. + */ + private function add_error( $plugin_name, $file, $message ) { + $error['plugin_name'] = $plugin_name; + $error['file'] = $file; + $error['message'] = $message; + $this->errors[] = $error; + } + + /** + * Get the currently installed version for a given plugin. + * + * @param string $path Relative path to plugin file to get the version for. + * + * @return string|false Installed version of the plugin, or false if not + * found. + */ + private function get_plugin_version( $path ) { + if ( ! isset( $this->plugins_data ) ) { + $this->plugins_data = get_plugins(); + } + + if ( ! array_key_exists( $path, $this->plugins_data ) ) { + return false; + } + + return $this->plugins_data[ $path ]['Version']; + } + + /** + * Gets the checksums for the given version of plugin. + * + * @param string $version Version string to query. + * @param string $plugin plugin string to query. + * + * @return bool|array False on failure. An array of checksums on success. + */ + private function get_plugin_checksums( $plugin, $version ) { + $url = str_replace( + array( + '{slug}', + '{version}', + ), + array( + $plugin, + $version, + ), + $this->url_template + ); + + $options = array( + 'timeout' => 30, + ); + + $headers = array( + 'Accept' => 'application/json', + ); + $response = Utils\http_request( 'GET', $url, null, $headers, $options ); + + if ( ! $response->success || 200 !== $response->status_code ) { + return false; + } + + $body = trim( $response->body ); + $body = json_decode( $body, true ); + + if ( ! is_array( $body ) || ! isset( $body['files'] ) || ! is_array( $body['files'] ) ) { + return false; + } + + return $body['files']; + } + + /** + * Get the names of all installed plugins. + * + * @return array Names of all installed plugins. + */ + private function get_all_plugin_names() { + $names = array(); + foreach ( get_plugins() as $file => $details ) { + $names[] = Utils\get_plugin_name( $file ); + } + + return $names; + } + + /** + * Get the list of files that are part of the given plugin. + * + * @param string $path Relative path to the main plugin file. + * + * @return array Array of files with their relative paths. + */ + private function get_plugin_files( $path ) { + $folder = dirname( $this->get_absolute_path( $path ) ); + + // Return single file plugins immediately, to avoid iterating over the + // entire plugins folder. + if ( WP_PLUGIN_DIR === $folder ) { + return (array) $path; + } + + return $this->get_files( trailingslashit( $folder ) ); + } + + /** + * Check the integrity of a single plugin file by comparing it to the + * officially provided checksum. + * + * @param string $path Relative path to the plugin file to check the + * integrity of. + * @param array $checksums Array of provided checksums to compare against. + * + * @return true|string + */ + private function check_file_checksum( $path, $checksums ) { + if ( $this->supports_sha256() + && array_key_exists( 'sha256', $checksums ) ) { + $sha256 = $this->get_sha256( $this->get_absolute_path( $path ) ); + + return in_array( $sha256, (array) $checksums['sha256'], true ); + } + + if ( ! array_key_exists( 'md5', $checksums ) ) { + return 'No matching checksum algorithm found'; + } + + $md5 = $this->get_md5( $this->get_absolute_path( $path ) ); + + return in_array( $md5, (array) $checksums['md5'], true ); + } + + /** + * Check whether the current environment supports 256-bit SHA-2. + * + * Should be supported for PHP 5+, but we might find edge cases depending on + * host. + * + * @return bool + */ + private function supports_sha256() { + return true; + } + + /** + * Get the 256-bit SHA-2 of a given file. + * + * @param string $filepath Absolute path to the file to calculate the SHA-2 + * for. + * + * @return string + */ + private function get_sha256( $filepath ) { + return hash_file( 'sha256', $filepath ); + } + + /** + * Get the MD5 of a given file. + * + * @param string $filepath Absolute path to the file to calculate the MD5 + * for. + * + * @return string + */ + private function get_md5( $filepath ) { + return hash_file( 'md5', $filepath ); + } + + /** + * Get the absolute path to a relative plugin file. + * + * @param string $path Relative path to get the absolute path for. + * + * @return string + */ + private function get_absolute_path( $path ) { + return WP_PLUGIN_DIR . '/' . $path; + } + + /** + * Return a list of files that only trigger checksum errors in strict mode. + * + * @return array Array of file names. + */ + private function get_soft_change_files() { + static $files = array( + 'readme.txt', + ); + + return $files; + } + + /** + * Check whether a given file will only trigger checksum errors in strict + * mode. + * + * @param string $file File to check. + * + * @return bool Whether the file only triggers checksum errors in strict + * mode. + */ + private function is_soft_change_file( $file ) { + return in_array( $file, $this->get_soft_change_files(), true ); + } +}