diff --git a/projects/packages/threat-fixers/.gitattributes b/projects/packages/threat-fixers/.gitattributes new file mode 100644 index 0000000000000..b0b228d4ad6ad --- /dev/null +++ b/projects/packages/threat-fixers/.gitattributes @@ -0,0 +1,17 @@ +# Files not needed to be distributed in the package. +.gitattributes export-ignore +.github/ export-ignore +package.json export-ignore + +# Files to include in the mirror repo, but excluded via gitignore +# Remember to end all directories with `/**` to properly tag every file. +# /src/js/example.min.js production-include + +# Files to exclude from the mirror repo, but included in the monorepo. +# Remember to end all directories with `/**` to properly tag every file. +.gitignore production-exclude +changelog/** production-exclude +phpunit.xml.dist production-exclude +.phpcs.dir.xml production-exclude +tests/** production-exclude +.phpcsignore production-exclude diff --git a/projects/packages/threat-fixers/.gitignore b/projects/packages/threat-fixers/.gitignore new file mode 100644 index 0000000000000..688e2469bdf27 --- /dev/null +++ b/projects/packages/threat-fixers/.gitignore @@ -0,0 +1,3 @@ +vendor/ +node_modules/ +wordpress/ diff --git a/projects/packages/threat-fixers/.phan/baseline.php b/projects/packages/threat-fixers/.phan/baseline.php new file mode 100644 index 0000000000000..c7aa9a4dd4f30 --- /dev/null +++ b/projects/packages/threat-fixers/.phan/baseline.php @@ -0,0 +1,12 @@ + [], + // 'directory_suppressions' => ['src/directory_name' => ['PhanIssueName1', 'PhanIssueName2']] can be manually added if needed. + // (directory_suppressions will currently be ignored by subsequent calls to --save-baseline, but may be preserved in future Phan releases) +]; diff --git a/projects/packages/threat-fixers/.phan/config.php b/projects/packages/threat-fixers/.phan/config.php new file mode 100644 index 0000000000000..ed111b28290c0 --- /dev/null +++ b/projects/packages/threat-fixers/.phan/config.php @@ -0,0 +1,13 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/packages/threat-fixers/CHANGELOG.md b/projects/packages/threat-fixers/CHANGELOG.md new file mode 100644 index 0000000000000..721294abd00ad --- /dev/null +++ b/projects/packages/threat-fixers/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + diff --git a/projects/packages/threat-fixers/README.md b/projects/packages/threat-fixers/README.md new file mode 100644 index 0000000000000..ab6379375dc95 --- /dev/null +++ b/projects/packages/threat-fixers/README.md @@ -0,0 +1,24 @@ +# threat-fixers + +Library of auto-fixers for security threats detected by Jetpack. + +## How to install threat-fixers + +### Installation From Git Repo + +## Contribute + +## Get Help + +## Using this package in your WordPress plugin + +If you plan on using this package in your WordPress plugin, we would recommend that you use [Jetpack Autoloader](https://packagist.org/packages/automattic/jetpack-autoloader) as your autoloader. This will allow for maximum interoperability with other plugins that use this package as well. + +## Security + +Need to report a security vulnerability? Go to [https://automattic.com/security/](https://automattic.com/security/) or directly to our security bug bounty site [https://hackerone.com/automattic](https://hackerone.com/automattic). + +## License + +threat-fixers is licensed under [GNU General Public License v2 (or later)](./LICENSE.txt) + diff --git a/projects/packages/threat-fixers/changelog/.gitkeep b/projects/packages/threat-fixers/changelog/.gitkeep new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/projects/packages/threat-fixers/changelog/initial-version b/projects/packages/threat-fixers/changelog/initial-version new file mode 100644 index 0000000000000..fb1837c901e51 --- /dev/null +++ b/projects/packages/threat-fixers/changelog/initial-version @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Initial version. diff --git a/projects/packages/threat-fixers/composer.json b/projects/packages/threat-fixers/composer.json new file mode 100644 index 0000000000000..fcc0cd1eb56bb --- /dev/null +++ b/projects/packages/threat-fixers/composer.json @@ -0,0 +1,62 @@ +{ + "name": "automattic/jetpack-threat-fixers", + "description": "Library of auto-fixers for security threats detected by Jetpack.", + "type": "jetpack-library", + "license": "GPL-2.0-or-later", + "require": { + "php": ">=7.2" + }, + "require-dev": { + "yoast/phpunit-polyfills": "^1.1.1", + "automattic/jetpack-changelogger": "@dev", + "automattic/wordbless": "dev-master" + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "scripts": { + "build-development": "echo 'Add your build step to composer.json, please!'", + "build-production": "echo 'Add your build step to composer.json, please!'", + "phpunit": [ + "./vendor/phpunit/phpunit/phpunit --colors=always" + ], + "post-install-cmd": "WorDBless\\Composer\\InstallDropin::copy", + "post-update-cmd": "WorDBless\\Composer\\InstallDropin::copy", + "test-coverage": [ + "php -dpcov.directory=. ./vendor/bin/phpunit --coverage-php \"$COVERAGE_DIR/php.cov\"" + ], + "test-php": [ + "@composer phpunit" + ] + }, + "repositories": [ + { + "type": "path", + "url": "../../packages/*", + "options": { + "monorepo": true + } + } + ], + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "allow-plugins": { + "roots/wordpress-core-installer": true + } + }, + "extra": { + "branch-alias": { + "dev-trunk": "0.1.x-dev" + }, + "textdomain": "jetpack-threat-fixers", + "version-constants": { + "::PACKAGE_VERSION": "src/class-threat-fixers.php" + } + }, + "suggest": { + "automattic/jetpack-autoloader": "Allow for better interoperability with other plugins that use this package." + } +} diff --git a/projects/packages/threat-fixers/package.json b/projects/packages/threat-fixers/package.json new file mode 100644 index 0000000000000..a2c5581caef41 --- /dev/null +++ b/projects/packages/threat-fixers/package.json @@ -0,0 +1,25 @@ +{ + "private": true, + "name": "@automattic/jetpack-threat-fixers", + "version": "0.1.0-alpha", + "description": "Library of auto-fixers for security threats detected by Jetpack.", + "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/packages/threat-fixers/#readme", + "bugs": { + "url": "https://github.com/Automattic/jetpack/labels/[Package] Threat Fixers" + }, + "repository": { + "type": "git", + "url": "https://github.com/Automattic/jetpack.git", + "directory": "projects/packages/threat-fixers" + }, + "license": "GPL-2.0-or-later", + "author": "Automattic", + "scripts": { + "build": "echo 'Not implemented.'", + "build-js": "echo 'Not implemented.'", + "build-production": "echo 'Not implemented.'", + "build-production-js": "echo 'Not implemented.'", + "clean": "true" + }, + "devDependencies": {} +} diff --git a/projects/packages/threat-fixers/phpunit.xml.dist b/projects/packages/threat-fixers/phpunit.xml.dist new file mode 100644 index 0000000000000..3223c32458db2 --- /dev/null +++ b/projects/packages/threat-fixers/phpunit.xml.dist @@ -0,0 +1,14 @@ + + + + tests/php + + + + + + + src + + + diff --git a/projects/packages/threat-fixers/src/class-extension-update-fixer.php b/projects/packages/threat-fixers/src/class-extension-update-fixer.php new file mode 100644 index 0000000000000..49d7872c41221 --- /dev/null +++ b/projects/packages/threat-fixers/src/class-extension-update-fixer.php @@ -0,0 +1,194 @@ +extension_type = $extension_type; + } else { + throw new \InvalidArgumentException( 'Invalid extension type.' ); + } + + if ( ! empty( $extension_slug ) && is_string( $extension_slug ) ) { + $this->extension_slug = $extension_slug; + } else { + throw new \InvalidArgumentException( 'Invalid extension slug.' ); + } + + if ( empty( $target_version ) || is_string( $target_version ) ) { + $this->target_version = $target_version; + } else { + throw new \InvalidArgumentException( 'Invalid target version.' ); + } + } + + /** + * Initialize the fixer's WP_Upgrader instance. + * + * @throws \InvalidArgumentException If the extension type is invalid. + * + * @return Theme_Upgrader|Plugin_Upgrader Upgrader instance. + */ + protected function get_upgrader() { + if ( $this->upgrader ) { + return $this->upgrader; + } + + if ( ! class_exists( '\WP_Upgrader' ) ) { + if ( file_exists( ABSPATH . 'wp-admin/includes/class-wp-upgrader.php' ) ) { + include ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; + } + } + + if ( ! class_exists( '\WP_Upgrader_Skin' ) ) { + if ( file_exists( ABSPATH . 'wp-admin/includes/class-wp-upgrader-skin.php' ) ) { + include ABSPATH . 'wp-admin/includes/class-wp-upgrader-skin.php'; + } + } + + switch ( $this->extension_type ) { + case 'themes': + $this->upgrader = new Theme_Upgrader( new Theme_Upgrader_Skin() ); + return $this->upgrader; + case 'plugins': + $this->upgrader = new Plugin_Upgrader( new Plugin_Upgrader_Skin() ); + return $this->upgrader; + default: + throw new \InvalidArgumentException( 'Invalid extension type.' ); + } + } + + /** + * Get extension data from the WordPress.org API. + * + * @return object|WP_Error The extension data on success, WP_Error on failure. + */ + public function get_extension_api_data() { + switch ( $this->extension_type ) { + case 'themes': + return themes_api( 'theme_information', array( 'slug' => $this->extension_slug ) ); + case 'plugins': + return plugins_api( 'plugin_information', array( 'slug' => $this->extension_slug ) ); + default: + return new WP_Error( 'invalid_extension_type', 'Invalid extension type.' ); + } + } + + /** + * Get extension download link. + * + * @return string|WP_Error The download link on success, WP_Error on failure. + */ + public function get_download_link_from_wporg() { + // Fetch theme installation information from the WordPress.org API. + $api_data = $this->get_extension_api_data(); + if ( is_wp_error( $api_data ) ) { + return $api_data; + } + + // If no version is specified, use the latest version. + $version = $this->target_version ?? $api_data->version; + + // Get the base download link. + $download_link = strstr( $api_data->download_link, $api_data->slug, true ); + // Append the specific version. + $download_link = $download_link . $api_data->slug . '.' . $version . '.zip'; + // WordPress.org requires HTTPS. + $download_link = str_replace( 'http://', 'https://', $download_link ); + + // Ensure the requested version exists. + $response = wp_remote_head( $download_link ); + $response_code = wp_remote_retrieve_response_code( $response ); + + if ( 200 !== (int) $response_code ) { + return is_wp_error( $response ) + ? $response + : new WP_Error( $response_code, sprintf( 'HTTP code %d', $response_code ) ); + } + + return $download_link; + } + + /** + * Run the fixer by installing the fixed version of the extension. + * + * @return bool|WP_Error True on success, WP_Error on failure. + */ + public function run() { + // Get the link to download the extension zip from WordPress.org. + $download_link = $this->get_download_link_from_wporg(); + + if ( is_wp_error( $download_link ) ) { + return $download_link; + } + + // Install the updated extension. + $result = $this->get_upgrader()->install( $download_link ); + + // Handle the result. + if ( is_wp_error( $result ) ) { + $key = $result->get_error_code(); + if ( in_array( $key, array( 'plugins_api_failed', 'themes_api_failed' ), true ) && ! empty( $result->error_data[ $key ] ) && in_array( $result->error_data[ $key ], array( 'N;', 'b:0;' ), true ) ) { + return new WP_Error( 'plugin_not_found', "Couldn't find '{$this->extension_slug}' in the WordPress.org {$this->extension_type} directory." ); + } + return new WP_Error( 'error', "{$this->extension_slug}: " . $result->get_error_message() ); + } + + return true; + } +} diff --git a/projects/packages/threat-fixers/src/class-threat-fixers.php b/projects/packages/threat-fixers/src/class-threat-fixers.php new file mode 100644 index 0000000000000..f1364ac8a1982 --- /dev/null +++ b/projects/packages/threat-fixers/src/class-threat-fixers.php @@ -0,0 +1,16 @@ + + + + diff --git a/projects/packages/threat-fixers/tests/php/bootstrap.php b/projects/packages/threat-fixers/tests/php/bootstrap.php new file mode 100644 index 0000000000000..568758615a4c2 --- /dev/null +++ b/projects/packages/threat-fixers/tests/php/bootstrap.php @@ -0,0 +1,16 @@ +getMockForAbstractClass( + Extension_Update_Fixer::class, + array( 'themes', 'twentytwentyfive' ) + ); + + $mock_response = (object) array( + 'slug' => 'twentytwentyfive', + 'version' => '1.0', + ); + + $fixer = $this->getMockBuilder( get_class( $fixer ) ) + ->onlyMethods( array( 'get_extension_api_data' ) ) + ->setConstructorArgs( array( 'themes', 'twentytwentyfive' ) ) + ->getMock(); + + $fixer->expects( $this->once() ) + ->method( 'get_extension_api_data' ) + ->willReturn( $mock_response ); + + $api_data = $fixer->get_extension_api_data(); + + $this->assertIsObject( $api_data ); + $this->assertSame( 'twentytwentyfive', $api_data->slug ); + } + + public function test_get_download_link_from_wporg() { + $fixer = $this->getMockForAbstractClass( + Extension_Update_Fixer::class, + array( 'themes', 'twentytwentyfive' ) + ); + + $mock_api_data = (object) array( + 'slug' => 'twentytwentyfive', + 'version' => '1.0', + 'download_link' => 'http://example.com/twentytwentyfive.zip', + ); + + // Mock the method to return API data. + $fixer = $this->getMockBuilder( get_class( $fixer ) ) + ->onlyMethods( array( 'get_extension_api_data' ) ) + ->setConstructorArgs( array( 'themes', 'twentytwentyfive' ) ) + ->getMock(); + + $fixer->expects( $this->once() ) + ->method( 'get_extension_api_data' ) + ->willReturn( $mock_api_data ); + + // Use the pre_http_request hook to intercept HTTP requests. + add_filter( + 'pre_http_request', + function () { + return array( + 'response' => array( 'code' => 200 ), + 'body' => array( + 'download_link' => 'https://example.com/twentytwentyfive.1.0.zip', + 'slug' => 'twentytwentyfive', + 'version' => '1.0', + ), + ); + }, + 10, + 3 + ); + + // Run the method and assert the result. + $download_link = $fixer->get_download_link_from_wporg(); + $this->assertSame( 'https://example.com/twentytwentyfive.1.0.zip', $download_link ); + + // Remove the filter after the test. + remove_filter( 'pre_http_request', '__return_null', 10 ); + } + + /** + * Test run with successful installation. + */ + public function test_run_successful_installation() { + // phpcs:disble Squiz.PHP.CommentedOutCode.Found + // $fixer = $this->getMockForAbstractClass( + // Extension_Update_Fixer::class, + // ['plugins', 'jetpack'] + // ); + // $mock_upgrader = $this->createMock(Plugin_Upgrader::class); + // $mock_upgrader->expects($this->once()) + // ->method('install') + // ->willReturn(true); + // // Mock the methods used in the process. + // $fixer = $this->getMockBuilder(get_class($fixer)) + // ->onlyMethods(['get_upgrader', 'get_download_link_from_wporg']) + // ->setConstructorArgs(['plugins', 'jetpack']) + // ->getMock(); + // $fixer->expects($this->once()) + // ->method('get_upgrader') + // ->willReturn($mock_upgrader); + // $fixer->expects($this->once()) + // ->method('get_download_link_from_wporg') + // ->willReturn('https://example.com/plugin-slug.zip'); + // $result = $fixer->run(); + // $this->assertTrue($result); + } + + /** + * Test run with installation failure. + */ + public function test_run_installation_failure() { + // phpcs:disable Squiz.PHP.CommentedOutCode.Found + // $fixer = $this->getMockForAbstractClass( + // Extension_Update_Fixer::class, + // ['plugins', 'jetpack'] + // ); + // $mock_upgrader = $this->createMock(\Plugin_Upgrader::class); + // $mock_upgrader->expects($this->once()) + // ->method('install') + // ->willReturn(new WP_Error('install_failed', 'Installation failed.')); + // // Mock the methods used in the process. + // $fixer = $this->getMockBuilder(get_class($fixer)) + // ->onlyMethods(['get_upgrader', 'get_download_link_from_wporg']) + // ->setConstructorArgs(['plugins', 'jetpack']) + // ->getMock(); + // $fixer->expects($this->once()) + // ->method('get_upgrader') + // ->willReturn($mock_upgrader); + // $fixer->expects($this->once()) + // ->method('get_download_link_from_wporg') + // ->willReturn('https://example.com/plugin-slug.zip'); + // $result = $fixer->run(); + // $this->assertInstanceOf(WP_Error::class, $result); + // $this->assertSame('install_failed', $result->get_error_code()); + } +}