diff --git a/includes/Checker/Checks/Plugin_Repo/Personal_Data_Exporter_Check.php b/includes/Checker/Checks/Plugin_Repo/Personal_Data_Exporter_Check.php new file mode 100644 index 000000000..94531a33c --- /dev/null +++ b/includes/Checker/Checks/Plugin_Repo/Personal_Data_Exporter_Check.php @@ -0,0 +1,150 @@ +\s*(?:insert|update|replace))\s*\(/'; + + /** + * Regex pattern that matches registration of a personal data exporter. + * + * Matches add_filter() calls that hook into the wp_privacy_personal_data_exporters + * filter to register a data exporter callback. + * + * @since 1.3.0 + * @var string + */ + const EXPORTER_REGISTRATION_PATTERN = '/add_filter\s*\(\s*[\'"]wp_privacy_personal_data_exporters[\'"]/'; + + /** + * Gets the categories for the check. + * + * Every check must have at least one category. + * + * @since 1.3.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.3.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' ); + + $this->check_for_missing_exporter( $result, $php_files ); + } + + /** + * Checks whether the plugin handles personal data but omits the exporter filter. + * + * The check is intentionally a two-step process: + * 1. Confirm the plugin has at least one personal-data storage call. + * 2. Only then verify whether it registers the exporter filter. + * + * This avoids false positives for plugins that do not touch personal data at all. + * + * @since 1.3.0 + * + * @param Check_Result $result The check result to amend. + * @param array $php_files List of absolute PHP file paths. + */ + protected function check_for_missing_exporter( Check_Result $result, array $php_files ) { + // Step 1: detect personal data signals across all plugin PHP files. + $signal_file = self::file_preg_match( self::PERSONAL_DATA_PATTERN, $php_files ); + + if ( false === $signal_file ) { + // No personal data handling detected — nothing to warn about. + return; + } + + // Step 2: check if the plugin already registers a personal data exporter. + $has_exporter = self::file_preg_match( self::EXPORTER_REGISTRATION_PATTERN, $php_files ); + + if ( false !== $has_exporter ) { + // Exporter is registered — no issue. + return; + } + + // Personal data is handled but no exporter is registered: emit a warning. + $this->add_result_warning_for_file( + $result, + __( 'Personal data was detected in this plugin but no data exporter has been registered. Plugins that store personal data should implement a data exporter via the wp_privacy_personal_data_exporters filter so that site administrators can fulfill data export requests.', 'plugin-check' ), + 'missing_personal_data_exporter', + $signal_file, + 0, + 0, + 'https://developer.wordpress.org/plugins/privacy/adding-the-personal-data-exporter-to-your-plugin/', + 5 + ); + } + + /** + * Gets the description for the check. + * + * Every check must have a short description explaining what the check does. + * + * @since 1.3.0 + * + * @return string Description. + */ + public function get_description(): string { + return __( 'Detects plugins that store personal data without registering a personal data exporter for GDPR compliance.', 'plugin-check' ); + } + + /** + * Gets the documentation URL for the check. + * + * Every check must have a URL with further information about the check. + * + * @since 1.3.0 + * + * @return string The documentation URL. + */ + public function get_documentation_url(): string { + return 'https://developer.wordpress.org/plugins/privacy/adding-the-personal-data-exporter-to-your-plugin/'; + } +} diff --git a/includes/Checker/Default_Check_Repository.php b/includes/Checker/Default_Check_Repository.php index c22371044..81d760924 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(), + 'personal_data_exporter' => new Checks\Plugin_Repo\Personal_Data_Exporter_Check(), ) ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-personal-data-exporter-with-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-personal-data-exporter-with-errors/load.php new file mode 100644 index 000000000..22fa167b1 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-personal-data-exporter-with-errors/load.php @@ -0,0 +1,26 @@ + __( 'Test PDE OK Plugin Data', 'test-plugin-personal-data-exporter-ok' ), + 'callback' => 'test_pde_ok_exporter', + ); + return $exporters; +} +add_filter( 'wp_privacy_personal_data_exporters', 'test_pde_ok_register_exporter' ); + +/** + * Exports personal data for a user. + * + * @param string $email_address Email address of the user. + * @param int $page Pagination page number. + * @return array Export data. + */ +function test_pde_ok_exporter( $email_address, $page = 1 ) { + $user = get_user_by( 'email', $email_address ); + if ( ! $user ) { + return array( + 'data' => array(), + 'done' => true, + ); + } + + $preference = get_user_meta( $user->ID, 'test_pde_ok_preference', true ); + $data = array(); + + if ( $preference ) { + $data[] = array( + 'group_id' => 'test-pde-ok', + 'group_label' => __( 'Test PDE OK Data', 'test-plugin-personal-data-exporter-ok' ), + 'item_id' => 'test-pde-ok-' . $user->ID, + 'data' => array( + array( + 'name' => __( 'Preference', 'test-plugin-personal-data-exporter-ok' ), + 'value' => $preference, + ), + ), + ); + } + + return array( + 'data' => $data, + 'done' => true, + ); +} diff --git a/tests/phpunit/tests/Checker/Checks/Personal_Data_Exporter_Check_Tests.php b/tests/phpunit/tests/Checker/Checks/Personal_Data_Exporter_Check_Tests.php new file mode 100644 index 000000000..57ba94431 --- /dev/null +++ b/tests/phpunit/tests/Checker/Checks/Personal_Data_Exporter_Check_Tests.php @@ -0,0 +1,89 @@ +run( $check_result ); + + $warnings = $check_result->get_warnings(); + + $this->assertNotEmpty( $warnings ); + + $found = false; + foreach ( $warnings as $file_warnings ) { + foreach ( $file_warnings as $line_warnings ) { + foreach ( $line_warnings as $col_warnings ) { + foreach ( $col_warnings as $warning ) { + if ( isset( $warning['code'] ) && 'missing_personal_data_exporter' === $warning['code'] ) { + $found = true; + break 4; + } + } + } + } + } + + $this->assertTrue( $found, 'Expected missing_personal_data_exporter warning was not found.' ); + } + + public function test_plugin_with_personal_data_and_exporter_has_no_warning() { + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-personal-data-exporter-without-errors/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check = new Personal_Data_Exporter_Check(); + $check->run( $check_result ); + + $found = false; + foreach ( $check_result->get_warnings() as $file_warnings ) { + foreach ( $file_warnings as $line_warnings ) { + foreach ( $line_warnings as $col_warnings ) { + foreach ( $col_warnings as $warning ) { + if ( isset( $warning['code'] ) && 'missing_personal_data_exporter' === $warning['code'] ) { + $found = true; + break 4; + } + } + } + } + } + + $this->assertFalse( $found, 'Unexpected missing_personal_data_exporter warning was found.' ); + } + + public function test_plugin_with_no_personal_data_has_no_warning() { + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-safe-redirect/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check = new Personal_Data_Exporter_Check(); + $check->run( $check_result ); + + $found = false; + foreach ( $check_result->get_warnings() as $file_warnings ) { + foreach ( $file_warnings as $line_warnings ) { + foreach ( $line_warnings as $col_warnings ) { + foreach ( $col_warnings as $warning ) { + if ( isset( $warning['code'] ) && 'missing_personal_data_exporter' === $warning['code'] ) { + $found = true; + break 4; + } + } + } + } + } + + $this->assertFalse( $found, 'Unexpected missing_personal_data_exporter warning on a plugin with no personal data.' ); + } +}