diff --git a/docs/checks.md b/docs/checks.md index 11662a607..ceee2ce5b 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -16,6 +16,7 @@ | plugin_uninstall | plugin_repo | Checks related to plugin uninstallation. | [Learn more](https://developer.wordpress.org/plugins/plugin-basics/uninstall-methods/#method-2-uninstall-php) | | external_admin_menu_links | plugin_repo | Detects external URLs used in top-level WordPress admin menu, which disrupts the expected user experience. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/#11-plugins-should-not-hijack-the-admin) | | wp_functions_compatibility | plugin_repo | Checks whether WordPress functions used by the plugin are compatible with the declared minimum supported WordPress version ("Requires at least"). | [Learn more](https://developer.wordpress.org/plugins/plugin-basics/header-requirements/#header-fields) | +| personal_data_eraser | plugin_repo | Detects plugins that store personal data without registering a personal data eraser for GDPR compliance. | [Learn more](https://developer.wordpress.org/plugins/privacy/adding-the-personal-data-eraser-to-your-plugin/) | | plugin_review_phpcs | plugin_repo | Runs PHP_CodeSniffer to detect certain best practices plugins should follow for submission on WordPress.org, including heredoc usage detection. | [Learn more](https://developer.wordpress.org/plugins/plugin-basics/best-practices/) | | direct_db_queries | security, plugin_repo | Checks the usage of direct database queries, which should be avoided. | [Learn more](https://developer.wordpress.org/apis/database/) | | direct_db | security, plugin_repo | Checks the escaping in direct database queries. | [Learn more](https://developer.wordpress.org/apis/database/) | 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..cc0c9d2b8 --- /dev/null +++ b/includes/Checker/Checks/Plugin_Repo/Personal_Data_Eraser_Check.php @@ -0,0 +1,464 @@ +filter_out_test_paths( $php_files, $result->plugin()->path() ); + + $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 2.0.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. + $line = 0; + $signal_file = $this->find_file_with_personal_data_signal( $php_files, $line ); + + if ( null === $signal_file ) { + // No personal data handling detected — nothing to warn about. + return; + } + + // Step 2: check if the plugin already registers a personal data eraser. + if ( $this->has_eraser_registration( $php_files ) ) { + // 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, + $line, + 0, + 'https://developer.wordpress.org/plugins/privacy/adding-the-personal-data-eraser-to-your-plugin/', + 5 + ); + } + + /** + * Finds the first file that contains a personal data signal. + * + * @since 2.0.0 + * + * @param array $php_files List of absolute PHP file paths. + * @param int $line Reference to store the line of the detected signal. + * @return string|null The file path, or null if no signal was found. + */ + private function find_file_with_personal_data_signal( array $php_files, int &$line ) { + foreach ( $php_files as $file ) { + $source = file_get_contents( $file ); + if ( false === $source || '' === $source ) { + continue; + } + + $tokens = token_get_all( $source ); + $count = count( $tokens ); + + for ( $i = 0; $i < $count; $i++ ) { + if ( $this->is_personal_data_function_call( $tokens, $i ) || $this->is_wpdb_method_call( $tokens, $i ) ) { + $line = (int) $tokens[ $i ][2]; + return $file; + } + } + } + + return null; + } + + /** + * Checks whether the plugin registers a personal data eraser. + * + * @since 2.0.0 + * + * @param array $php_files List of absolute PHP file paths. + * @return bool True if an eraser is registered, false otherwise. + */ + private function has_eraser_registration( array $php_files ): bool { + foreach ( $php_files as $file ) { + $source = file_get_contents( $file ); + if ( false === $source || '' === $source ) { + continue; + } + + $tokens = token_get_all( $source ); + $count = count( $tokens ); + + for ( $i = 0; $i < $count; $i++ ) { + if ( $this->is_eraser_filter_registration( $tokens, $i ) ) { + return true; + } + } + } + + return false; + } + + /** + * Determines whether the token at the given index is a personal data function call. + * + * @since 2.0.0 + * + * @param array $tokens Token stream from token_get_all(). + * @param int $index Index of the token to inspect. + * @return bool True if the token is a personal data function call. + */ + private function is_personal_data_function_call( array $tokens, int $index ): bool { + $token = $tokens[ $index ]; + + if ( ! is_array( $token ) || T_STRING !== $token[0] ) { + return false; + } + + $name = strtolower( $token[1] ); + + if ( ! in_array( $name, self::PERSONAL_DATA_FUNCTIONS, true ) ) { + return false; + } + + if ( ! $this->is_global_function_call( $tokens, $index ) ) { + return false; + } + + $next = $this->get_next_significant_token_index( $tokens, $index ); + + return null !== $next && '(' === $tokens[ $next ]; + } + + /** + * Determines whether the token at the given index is a $wpdb write method call. + * + * @since 2.0.0 + * + * @param array $tokens Token stream from token_get_all(). + * @param int $index Index of the token to inspect. + * @return bool True if the token is a $wpdb write method call. + */ + private function is_wpdb_method_call( array $tokens, int $index ): bool { + $token = $tokens[ $index ]; + + if ( ! is_array( $token ) || T_VARIABLE !== $token[0] || '$wpdb' !== $token[1] ) { + return false; + } + + $arrow_index = $this->get_next_significant_token_index( $tokens, $index ); + + if ( null === $arrow_index ) { + return false; + } + + $method_index = $this->get_next_significant_token_index( $tokens, $arrow_index ); + + if ( null === $method_index ) { + return false; + } + + $arrow_token = $tokens[ $arrow_index ]; + $method_token = $tokens[ $method_index ]; + + if ( + ! is_array( $arrow_token ) + || T_OBJECT_OPERATOR !== $arrow_token[0] + || ! is_array( $method_token ) + || T_STRING !== $method_token[0] + || ! in_array( strtolower( $method_token[1] ), self::WPDB_METHODS, true ) + ) { + return false; + } + + // Confirm the method is actually invoked with a call. + $call_index = $this->get_next_significant_token_index( $tokens, $method_index ); + + return null !== $call_index && '(' === $tokens[ $call_index ]; + } + + /** + * Determines whether the token at the given index registers a personal data eraser. + * + * @since 2.0.0 + * + * @param array $tokens Token stream from token_get_all(). + * @param int $index Index of the token to inspect. + * @return bool True if the token registers a personal data eraser. + */ + private function is_eraser_filter_registration( array $tokens, int $index ): bool { + $token = $tokens[ $index ]; + + if ( ! is_array( $token ) || T_STRING !== $token[0] || 'add_filter' !== strtolower( $token[1] ) ) { + return false; + } + + if ( ! $this->is_global_function_call( $tokens, $index ) ) { + return false; + } + + $open_paren = $this->get_next_significant_token_index( $tokens, $index ); + + if ( null === $open_paren || '(' !== $tokens[ $open_paren ] ) { + return false; + } + + $arg_index = $this->get_next_significant_token_index( $tokens, $open_paren ); + + if ( null === $arg_index ) { + return false; + } + + $arg = $tokens[ $arg_index ]; + + if ( ! is_array( $arg ) || T_CONSTANT_ENCAPSED_STRING !== $arg[0] ) { + return false; + } + + return trim( $arg[1], "\"' \t\n\r\0\x0B" ) === self::ERASER_FILTER; + } + + /** + * Determines whether the token at the given index is a call to a global function. + * + * Excludes method calls, static calls, function declarations, and namespaced + * function calls. + * + * @since 2.0.0 + * + * @param array $tokens Token stream from token_get_all(). + * @param int $index Index of the token to inspect. + * @return bool True if the token is a global function call. + */ + private function is_global_function_call( array $tokens, int $index ): bool { + $previous_index = $this->get_previous_significant_token_index( $tokens, $index ); + + if ( null === $previous_index ) { + return true; + } + + $previous_token = $tokens[ $previous_index ]; + + if ( ! is_array( $previous_token ) ) { + return ! is_string( $previous_token ) || '(' !== $previous_token; + } + + if ( in_array( $previous_token[0], array( T_FUNCTION, T_NEW, T_OBJECT_OPERATOR, T_DOUBLE_COLON ), true ) ) { + return false; + } + + if ( T_NS_SEPARATOR === $previous_token[0] ) { + $before_namespace_index = $this->get_previous_significant_token_index( $tokens, $previous_index ); + + if ( null === $before_namespace_index ) { + return true; + } + + $before_namespace_token = $tokens[ $before_namespace_index ]; + + if ( is_array( $before_namespace_token ) && in_array( $before_namespace_token[0], array( T_STRING, T_NAMESPACE ), true ) ) { + return false; + } + } + + return true; + } + + /** + * Gets the index of the next significant token, skipping whitespace and comments. + * + * @since 2.0.0 + * + * @param array $tokens Token stream from token_get_all(). + * @param int $index Index to start scanning from. + * @return int|null The next significant token index, or null if none exists. + */ + private function get_next_significant_token_index( array $tokens, int $index ): ?int { + $count = count( $tokens ); + + for ( $i = $index + 1; $i < $count; $i++ ) { + $token = $tokens[ $i ]; + + if ( is_array( $token ) && in_array( $token[0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) { + continue; + } + + return $i; + } + + return null; + } + + /** + * Gets the index of the previous significant token, skipping whitespace and comments. + * + * @since 2.0.0 + * + * @param array $tokens Token stream from token_get_all(). + * @param int $index Index to start scanning backwards from. + * @return int|null The previous significant token index, or null if none exists. + */ + private function get_previous_significant_token_index( array $tokens, int $index ): ?int { + for ( $i = $index - 1; $i >= 0; $i-- ) { + $token = $tokens[ $i ]; + + if ( is_array( $token ) && in_array( $token[0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) { + continue; + } + + return $i; + } + + return null; + } + + /** + * Removes files under the plugin's top-level tests directory. + * + * The plugin's own test fixtures are not genuine personal data handling and + * should not trigger the check. + * + * @since 2.0.0 + * + * @param array $php_files List of absolute PHP file paths. + * @param string $plugin_path Absolute path to the plugin directory, with trailing slash. + * @return array Filtered list of absolute PHP file paths. + */ + private function filter_out_test_paths( array $php_files, string $plugin_path ): array { + $root = wp_normalize_path( $plugin_path ); + + return array_values( + array_filter( + $php_files, + static function ( string $file ) use ( $root ): bool { + $relative = str_replace( $root, '', wp_normalize_path( $file ) ); + + // Skip top-level "tests" or "tests/anything" inside the plugin. + if ( 0 === strpos( $relative, 'tests' ) && ( strlen( $relative ) === 5 || '/' === $relative[5] ) ) { + return false; + } + + return true; + } + ) + ); + } + + /** + * Gets the description for the check. + * + * Every check must have a short description explaining what the check does. + * + * @since 2.0.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 2.0.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-comment-filter/load.php b/tests/phpunit/testdata/plugins/test-plugin-personal-data-eraser-comment-filter/load.php new file mode 100644 index 000000000..1273ea6fc --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-personal-data-eraser-comment-filter/load.php @@ -0,0 +1,29 @@ + __( 'Test PDEL Tests Plugin Data', 'test-plugin-personal-data-eraser-tests-dir' ), + 'callback' => 'test_pdel_tests_eraser', + ); + + return $erasers; +} +add_filter( 'wp_privacy_personal_data_erasers', 'test_pdel_tests_register_eraser' ); + +/** + * Erases personal data for a user. + * + * @return array Erasure status array. + */ +function test_pdel_tests_eraser() { + return array( + 'items_removed' => false, + 'items_retained' => false, + 'messages' => array(), + 'done' => true, + ); +} 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 @@ +insert( + $wpdb->prefix . 'test_pdel_log', + array( + 'user_id' => $user_id, + 'note' => $note, + 'time' => current_time( 'mysql' ), + ) + ); +} +add_action( 'user_register', 'test_pdel_wpdb_save_log' ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-personal-data-eraser-without-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-personal-data-eraser-without-errors/load.php new file mode 100644 index 000000000..deaa103c5 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-personal-data-eraser-without-errors/load.php @@ -0,0 +1,69 @@ + __( '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..1c3be3d5d --- /dev/null +++ b/tests/phpunit/tests/Checker/Checks/Personal_Data_Eraser_Check_Tests.php @@ -0,0 +1,164 @@ +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.' ); + } + + public function test_plugin_with_wpdb_write_but_no_eraser_triggers_warning() { + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-personal-data-eraser-with-wpdb-insert/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check = new Personal_Data_Eraser_Check(); + $check->run( $check_result ); + + $this->assertTrue( + $this->has_warning_code( $check_result, 'missing_personal_data_eraser' ), + 'Expected missing_personal_data_eraser warning for a $wpdb write was not found.' + ); + } + + public function test_plugin_with_eraser_registered_only_in_tests_directory_triggers_warning() { + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-personal-data-eraser-tests-dir/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check = new Personal_Data_Eraser_Check(); + $check->run( $check_result ); + + $this->assertTrue( + $this->has_warning_code( $check_result, 'missing_personal_data_eraser' ), + 'Expected missing_personal_data_eraser warning despite an eraser registered under the tests directory.' + ); + } + + public function test_plugin_with_personal_data_only_in_comments_and_strings_has_no_warning() { + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-personal-data-eraser-comment-only/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check = new Personal_Data_Eraser_Check(); + $check->run( $check_result ); + + $this->assertFalse( + $this->has_warning_code( $check_result, 'missing_personal_data_eraser' ), + 'Unexpected missing_personal_data_eraser warning when personal data appears only in comments and strings.' + ); + } + + public function test_plugin_mentioning_eraser_filter_only_in_comment_triggers_warning() { + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-personal-data-eraser-comment-filter/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check = new Personal_Data_Eraser_Check(); + $check->run( $check_result ); + + $this->assertTrue( + $this->has_warning_code( $check_result, 'missing_personal_data_eraser' ), + 'Expected missing_personal_data_eraser warning when the eraser filter is only mentioned in a comment.' + ); + } + + /** + * Checks whether the given result contains a warning with the given code. + * + * @param Check_Result $result The check result to inspect. + * @param string $code The warning code to look for. + * @return bool True if a matching warning exists, false otherwise. + */ + private function has_warning_code( Check_Result $result, string $code ): bool { + foreach ( $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'] ) && $code === $warning['code'] ) { + return true; + } + } + } + } + } + + return false; + } +}