diff --git a/docs/checks.md b/docs/checks.md index 11662a607..c85188147 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -35,3 +35,4 @@ | enqueued_styles_scope | performance | Checks whether any stylesheets are loaded on all pages, which is usually not desirable and can lead to performance issues. | [Learn more](https://developer.wordpress.org/plugins/) | | enqueued_scripts_scope | performance | Checks whether any scripts are loaded on all pages, which is usually not desirable and can lead to performance issues. | [Learn more](https://developer.wordpress.org/plugins/) | | non_blocking_scripts | performance | Checks whether scripts and styles are enqueued using a recommended loading strategy. | [Learn more](https://developer.wordpress.org/plugins/) | +| privacy_policy | plugin_repo | Checks that plugins handling personal data call wp_add_privacy_policy_content() to suggest privacy policy text to site administrators. | [Learn more](https://developer.wordpress.org/plugins/privacy/suggesting-text-for-the-site-privacy-policy/) | diff --git a/includes/Checker/Checks/Plugin_Repo/Privacy_Policy_Check.php b/includes/Checker/Checks/Plugin_Repo/Privacy_Policy_Check.php new file mode 100644 index 000000000..a5922f721 --- /dev/null +++ b/includes/Checker/Checks/Plugin_Repo/Privacy_Policy_Check.php @@ -0,0 +1,139 @@ + + */ + const PERSONAL_DATA_PATTERNS = array( + 'wp_remote_post\s*\(' => 'wp_remote_post()', + 'wp_remote_get\s*\(' => 'wp_remote_get()', + 'setcookie\s*\(' => 'setcookie()', + '\$_COOKIE\b' => '$_COOKIE', + 'wp_set_auth_cookie\s*\(' => 'wp_set_auth_cookie()', + ); + + /** + * Gets the categories for the check. + * + * Every check must have at least one category. + * + * @since 1.7.0 + * + * @return array The categories for the check. + */ + public function get_categories() { + return array( Check_Categories::CATEGORY_PLUGIN_REPO ); + } + + /** + * Amends the given result by running the check on the given list of files. + * + * @since 1.7.0 + * + * @param Check_Result $result The check result to amend, including the plugin context to check. + * @param array $files List of absolute file paths. + */ + protected function check_files( Check_Result $result, array $files ) { + $php_files = self::filter_files_by_extension( $files, 'php' ); + + if ( empty( $php_files ) ) { + return; + } + + // First, detect whether the plugin already calls wp_add_privacy_policy_content(). + $has_privacy_call = (bool) self::file_preg_match( + '#\bwp_add_privacy_policy_content\s*\(#', + $php_files + ); + + // If the plugin already registers privacy policy content, nothing to warn about. + if ( $has_privacy_call ) { + return; + } + + // Check for each personal-data-handling pattern. + foreach ( self::PERSONAL_DATA_PATTERNS as $pattern => $label ) { + $matches = array(); + $matched_file = self::file_preg_match( '#' . $pattern . '#', $php_files, $matches ); + + if ( $matched_file ) { + $this->add_result_warning_for_file( + $result, + sprintf( + /* translators: %s: The detected function or variable name indicating personal data usage. */ + __( 'Missing privacy policy content registration.
The plugin uses %s which may involve handling personal data, but does not call wp_add_privacy_policy_content(). Plugins that collect, store, or transmit personal data should suggest privacy policy text to site administrators.', 'plugin-check' ), + '' . esc_html( $label ) . '' + ), + 'missing_privacy_policy_content', + $result->plugin()->main_file(), + 0, + 0, + 'https://developer.wordpress.org/plugins/privacy/suggesting-text-for-the-site-privacy-policy/', + 5 + ); + + // One warning per plugin is sufficient — avoid duplicate messages. + return; + } + } + } + + /** + * Gets the description for the check. + * + * Every check must have a short description explaining what the check does. + * + * @since 1.7.0 + * + * @return string Description. + */ + public function get_description(): string { + return __( 'Checks that plugins handling personal data call wp_add_privacy_policy_content() to suggest privacy policy text to site administrators.', 'plugin-check' ); + } + + /** + * Gets the documentation URL for the check. + * + * Every check must have a URL with further information about the check. + * + * @since 1.7.0 + * + * @return string The documentation URL. + */ + public function get_documentation_url(): string { + return __( 'https://developer.wordpress.org/plugins/privacy/suggesting-text-for-the-site-privacy-policy/', 'plugin-check' ); + } +} diff --git a/includes/Checker/Default_Check_Repository.php b/includes/Checker/Default_Check_Repository.php index c22371044..a8844bec1 100644 --- a/includes/Checker/Default_Check_Repository.php +++ b/includes/Checker/Default_Check_Repository.php @@ -102,6 +102,7 @@ private function register_default_checks() { 'direct_file_access' => new Checks\Plugin_Repo\Direct_File_Access_Check(), 'external_admin_menu_links' => new Checks\Plugin_Repo\External_Admin_Menu_Links_Check(), 'wp_functions_compatibility' => new Checks\Plugin_Repo\WP_Functions_Compatibility_Check(), + 'privacy_policy' => new Checks\Plugin_Repo\Privacy_Policy_Check(), ) ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-privacy-policy-no-signals/load.php b/tests/phpunit/testdata/plugins/test-plugin-privacy-policy-no-signals/load.php new file mode 100644 index 000000000..f0300534b --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-privacy-policy-no-signals/load.php @@ -0,0 +1,27 @@ +' . esc_html__( 'Hello from Test Plugin!', 'test-plugin-privacy-policy-no-signals' ) . '

'; +} + +add_action( 'admin_footer', 'test_plugin_privacy_no_signals_greet' ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-privacy-policy-with-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-privacy-policy-with-errors/load.php new file mode 100644 index 000000000..d32c6f211 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-privacy-policy-with-errors/load.php @@ -0,0 +1,30 @@ + array( + 'email' => get_option( 'admin_email' ), + ), + ) + ); + + return $response; +} diff --git a/tests/phpunit/testdata/plugins/test-plugin-privacy-policy-without-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-privacy-policy-without-errors/load.php new file mode 100644 index 000000000..34f399b59 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-privacy-policy-without-errors/load.php @@ -0,0 +1,41 @@ + array( + 'site_url' => get_site_url(), + ), + ) + ); + + return $response; +} diff --git a/tests/phpunit/tests/Checker/Checks/Privacy_Policy_Check_Tests.php b/tests/phpunit/tests/Checker/Checks/Privacy_Policy_Check_Tests.php new file mode 100644 index 000000000..91b29f6fe --- /dev/null +++ b/tests/phpunit/tests/Checker/Checks/Privacy_Policy_Check_Tests.php @@ -0,0 +1,71 @@ +run( $check_result ); + + $warnings = $check_result->get_warnings(); + + $this->assertNotEmpty( $warnings ); + + // Warning must be on the plugin's main file. + $this->assertArrayHasKey( 'load.php', $warnings ); + + // Verify the expected warning code is present. + $this->assertCount( 1, wp_list_filter( $warnings['load.php'][0][0], array( 'code' => 'missing_privacy_policy_content' ) ) ); + } + + /** + * Tests that a plugin using wp_remote_post() WITH wp_add_privacy_policy_content() + * does not receive any warnings. + */ + public function test_run_without_errors() { + $check = new Privacy_Policy_Check(); + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-privacy-policy-without-errors/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check->run( $check_result ); + + $warnings = $check_result->get_warnings(); + $errors = $check_result->get_errors(); + + $this->assertEmpty( $warnings ); + $this->assertEmpty( $errors ); + } + + /** + * Tests that a plugin with no personal-data-handling patterns does not receive + * any warnings, even if it does not call wp_add_privacy_policy_content(). + */ + public function test_run_with_no_signals() { + $check = new Privacy_Policy_Check(); + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-privacy-policy-no-signals/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check->run( $check_result ); + + $warnings = $check_result->get_warnings(); + $errors = $check_result->get_errors(); + + $this->assertEmpty( $warnings ); + $this->assertEmpty( $errors ); + } +}