Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions includes/Checker/Checks/Plugin_Repo/Personal_Data_Exporter_Check.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<?php
/**
* Class Personal_Data_Exporter_Check.
*
* @package plugin-check
*/

namespace WordPress\Plugin_Check\Checker\Checks\Plugin_Repo;

use WordPress\Plugin_Check\Checker\Check_Categories;
use WordPress\Plugin_Check\Checker\Check_Result;
use WordPress\Plugin_Check\Checker\Checks\Abstract_File_Check;
use WordPress\Plugin_Check\Traits\Amend_Check_Result;
use WordPress\Plugin_Check\Traits\Stable_Check;

/**
* Check to detect personal data handling without a registered exporter callback.
*
* Plugins that collect or store personal data are expected to register an
* exporter callback via the `wp_privacy_personal_data_exporters` filter so
* that WordPress's built-in Personal Data Export tool can include the plugin's
* data in the generated ZIP.
*
* @since 1.3.0
* @link https://developer.wordpress.org/plugins/privacy/adding-the-personal-data-exporter-to-your-plugin/
*/
class Personal_Data_Exporter_Check extends Abstract_File_Check {

use Amend_Check_Result;
use Stable_Check;

/**
* Regex pattern that matches common personal-data storage API calls.
*
* Matches function calls that are strong indicators that a plugin is
* collecting or storing personal data about users.
*
* @since 1.3.0
* @var string
*/
const PERSONAL_DATA_PATTERN = '/\b(?:add_user_meta|update_user_meta|add_comment_meta|update_comment_meta|\$wpdb\s*->\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 <code>wp_privacy_personal_data_exporters</code> 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/';
}
}
1 change: 1 addition & 0 deletions includes/Checker/Default_Check_Repository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php
/**
* Plugin Name: Test Plugin Personal Data Exporter With Errors
* Plugin URI: https://github.com/WordPress/plugin-check
* Description: Test plugin for the Personal Data Exporter check — stores user meta but does not register a data exporter.
* Requires at least: 6.3
* Requires PHP: 7.4
* Version: 1.0.0
* Author: WordPress Performance Team
* Author URI: https://make.wordpress.org/performance/
* License: GPLv2 or later
* License URI: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
* Text Domain: test-plugin-personal-data-exporter-errors
*
* @package test-plugin-personal-data-exporter-errors
*/

/**
* Saves a custom preference for a user.
*
* @param int $user_id User ID.
*/
function test_pde_save_user_preference( $user_id ) {
update_user_meta( $user_id, 'test_pde_preference', 'some_value' );
}
add_action( 'user_register', 'test_pde_save_user_preference' );
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php
/**
* Plugin Name: Test Plugin Personal Data Exporter Without Errors
* Plugin URI: https://github.com/WordPress/plugin-check
* Description: Test plugin for the Personal Data Exporter check — stores user meta AND registers a data exporter.
* Requires at least: 6.3
* Requires PHP: 7.4
* Version: 1.0.0
* Author: WordPress Performance Team
* Author URI: https://make.wordpress.org/performance/
* License: GPLv2 or later
* License URI: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
* Text Domain: test-plugin-personal-data-exporter-ok
*
* @package test-plugin-personal-data-exporter-ok
*/

/**
* Saves a custom preference for a user.
*
* @param int $user_id User ID.
*/
function test_pde_ok_save_user_preference( $user_id ) {
update_user_meta( $user_id, 'test_pde_ok_preference', 'some_value' );
}
add_action( 'user_register', 'test_pde_ok_save_user_preference' );

/**
* Registers the personal data exporter.
*
* @param array $exporters An array of personal data exporters.
* @return array Updated exporters array.
*/
function test_pde_ok_register_exporter( $exporters ) {
$exporters['test-pde-ok'] = array(
'exporter_friendly_name' => __( '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,
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php
/**
* Tests for the Personal_Data_Exporter_Check class.
*
* @package plugin-check
*/

use WordPress\Plugin_Check\Checker\Check_Context;
use WordPress\Plugin_Check\Checker\Check_Result;
use WordPress\Plugin_Check\Checker\Checks\Plugin_Repo\Personal_Data_Exporter_Check;

class Personal_Data_Exporter_Check_Tests extends WP_UnitTestCase {

public function test_plugin_with_personal_data_but_no_exporter_triggers_warning() {
$check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-personal-data-exporter-with-errors/load.php' );
$check_result = new Check_Result( $check_context );

$check = new Personal_Data_Exporter_Check();
$check->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.' );
}
}
Loading