From 2527b1f2ee01499fb3a130f1d9e700e832feab73 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Mon, 4 May 2026 14:51:10 +0600 Subject: [PATCH] feat(privacy): add Personal_Data_Eraser_Check for GDPR compliance Add a new static check that warns plugin authors when their plugin handles personal data (user meta, comment meta, direct DB writes) but does not register a callback via the wp_privacy_personal_data_erasers filter. - New check class: Personal_Data_Eraser_Check - Registered in Default_Check_Repository under 'personal_data_eraser' - PHPUnit test class with three test cases - Test data plugins (with and without eraser registration) Fixes #1252 --- .../Personal_Data_Eraser_Check.php | 150 ++++++++++++++++++ includes/Checker/Default_Check_Repository.php | 1 + .../load.php | 26 +++ .../load.php | 69 ++++++++ .../Personal_Data_Eraser_Check_Tests.php | 89 +++++++++++ 5 files changed, 335 insertions(+) create mode 100644 includes/Checker/Checks/Plugin_Repo/Personal_Data_Eraser_Check.php create mode 100644 tests/phpunit/testdata/plugins/test-plugin-personal-data-eraser-with-errors/load.php create mode 100644 tests/phpunit/testdata/plugins/test-plugin-personal-data-eraser-without-errors/load.php create mode 100644 tests/phpunit/tests/Checker/Checks/Personal_Data_Eraser_Check_Tests.php diff --git a/includes/Checker/Checks/Plugin_Repo/Personal_Data_Eraser_Check.php b/includes/Checker/Checks/Plugin_Repo/Personal_Data_Eraser_Check.php new file mode 100644 index 000000000..17d0b8b84 --- /dev/null +++ b/includes/Checker/Checks/Plugin_Repo/Personal_Data_Eraser_Check.php @@ -0,0 +1,150 @@ +\s*(?:insert|update|replace))\s*\(/'; + + /** + * Regex pattern that matches registration of a personal data eraser. + * + * Matches add_filter() calls that hook into the wp_privacy_personal_data_erasers + * filter to register a data eraser callback. + * + * @since 1.3.0 + * @var string + */ + const ERASER_REGISTRATION_PATTERN = '/add_filter\s*\(\s*[\'"]wp_privacy_personal_data_erasers[\'"]/'; + + /** + * 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_eraser( $result, $php_files ); + } + + /** + * Checks whether the plugin handles personal data but omits the eraser 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 eraser 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_eraser( 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 eraser. + $has_eraser = self::file_preg_match( self::ERASER_REGISTRATION_PATTERN, $php_files ); + + if ( false !== $has_eraser ) { + // Eraser is registered — no issue. + return; + } + + // Personal data is handled but no eraser is registered: emit a warning. + $this->add_result_warning_for_file( + $result, + __( 'Personal data was detected in this plugin but no data eraser has been registered. Plugins that store personal data should implement a data eraser via the wp_privacy_personal_data_erasers filter so that site administrators can fulfill data removal requests.', 'plugin-check' ), + 'missing_personal_data_eraser', + $signal_file, + 0, + 0, + 'https://developer.wordpress.org/plugins/privacy/adding-the-personal-data-eraser-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 eraser 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-eraser-to-your-plugin/'; + } +} diff --git a/includes/Checker/Default_Check_Repository.php b/includes/Checker/Default_Check_Repository.php index c22371044..9f97f67c0 100644 --- a/includes/Checker/Default_Check_Repository.php +++ b/includes/Checker/Default_Check_Repository.php @@ -101,6 +101,7 @@ private function register_default_checks() { 'minified_files' => new Checks\Plugin_Repo\Minified_Files_Check(), 'direct_file_access' => new Checks\Plugin_Repo\Direct_File_Access_Check(), 'external_admin_menu_links' => new Checks\Plugin_Repo\External_Admin_Menu_Links_Check(), + 'personal_data_eraser' => new Checks\Plugin_Repo\Personal_Data_Eraser_Check(), 'wp_functions_compatibility' => new Checks\Plugin_Repo\WP_Functions_Compatibility_Check(), ) ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-personal-data-eraser-with-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-personal-data-eraser-with-errors/load.php new file mode 100644 index 000000000..360e928db --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-personal-data-eraser-with-errors/load.php @@ -0,0 +1,26 @@ + __( 'Test PDEL OK Plugin Data', 'test-plugin-personal-data-eraser-ok' ), + 'callback' => 'test_pdel_ok_eraser', + ); + return $erasers; +} +add_filter( 'wp_privacy_personal_data_erasers', 'test_pdel_ok_register_eraser' ); + +/** + * Erases personal data for a user. + * + * @param string $email_address Email address of the user. + * @param int $page Pagination page number. + * @return array Erasure status array. + */ +function test_pdel_ok_eraser( $email_address, $page = 1 ) { + $user = get_user_by( 'email', $email_address ); + if ( ! $user ) { + return array( + 'items_removed' => false, + 'items_retained' => false, + 'messages' => array(), + 'done' => true, + ); + } + + $removed = delete_user_meta( $user->ID, 'test_pdel_ok_preference' ); + + return array( + 'items_removed' => $removed, + 'items_retained' => false, + 'messages' => array(), + 'done' => true, + ); +} diff --git a/tests/phpunit/tests/Checker/Checks/Personal_Data_Eraser_Check_Tests.php b/tests/phpunit/tests/Checker/Checks/Personal_Data_Eraser_Check_Tests.php new file mode 100644 index 000000000..e04866176 --- /dev/null +++ b/tests/phpunit/tests/Checker/Checks/Personal_Data_Eraser_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_eraser' === $warning['code'] ) { + $found = true; + break 4; + } + } + } + } + } + + $this->assertTrue( $found, 'Expected missing_personal_data_eraser warning was not found.' ); + } + + public function test_plugin_with_personal_data_and_eraser_has_no_warning() { + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-personal-data-eraser-without-errors/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check = new Personal_Data_Eraser_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_eraser' === $warning['code'] ) { + $found = true; + break 4; + } + } + } + } + } + + $this->assertFalse( $found, 'Unexpected missing_personal_data_eraser 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_Eraser_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_eraser' === $warning['code'] ) { + $found = true; + break 4; + } + } + } + } + } + + $this->assertFalse( $found, 'Unexpected missing_personal_data_eraser warning on a plugin with no personal data.' ); + } +}