From 571cfdfe1dc76651ad2f5e58b48129f4ab41a8b5 Mon Sep 17 00:00:00 2001
From: Faisal Ahammad
Date: Mon, 4 May 2026 13:24:51 +0600
Subject: [PATCH 1/2] feat(plugin-repo): add privacy policy content check
Add a new Privacy_Policy_Check that warns when a plugin uses
personal-data-handling APIs but does not call
wp_add_privacy_policy_content().
WordPress.org guidelines require plugins that collect, store,
or transmit personal data to a third party to suggest privacy
policy text to site administrators via this function.
The check scans PHP files for signals indicating potential personal
data handling:
- wp_remote_post() / wp_remote_get() (external data transmission)
- setcookie() / $_COOKIE (cookie-based tracking)
- wp_set_auth_cookie() (authentication cookies)
If any signal is detected and wp_add_privacy_policy_content() is
not called anywhere in the plugin, a single warning is emitted on
the plugin's main file pointing to the official WordPress privacy
developer documentation.
Plugins with no signals are completely unaffected by this check.
Fixes #1249
---
docs/checks.md | 1 +
.../Plugin_Repo/Privacy_Policy_Check.php | 139 ++++++++++++++++++
includes/Checker/Default_Check_Repository.php | 1 +
.../load.php | 27 ++++
.../load.php | 30 ++++
.../load.php | 41 ++++++
.../Checks/Privacy_Policy_Check_Tests.php | 71 +++++++++
7 files changed, 310 insertions(+)
create mode 100644 includes/Checker/Checks/Plugin_Repo/Privacy_Policy_Check.php
create mode 100644 tests/phpunit/testdata/plugins/test-plugin-privacy-policy-no-signals/load.php
create mode 100644 tests/phpunit/testdata/plugins/test-plugin-privacy-policy-with-errors/load.php
create mode 100644 tests/phpunit/testdata/plugins/test-plugin-privacy-policy-without-errors/load.php
create mode 100644 tests/phpunit/tests/Checker/Checks/Privacy_Policy_Check_Tests.php
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..795c6430e
--- /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 );
+ }
+}
From 47e42263826385d865f63e50c3b413f197141da2 Mon Sep 17 00:00:00 2001
From: Faisal Ahammad
Date: Mon, 4 May 2026 13:35:48 +0600
Subject: [PATCH 2/2] fix(tests): remove function name from test plugin
description
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The plugin description contained wp_add_privacy_policy_content()
with parentheses, which caused the check's detection regex to
match the comment string and return early as if the function was
already implemented — producing no warning and failing the test.
The description now reads 'does not register privacy policy
content' which avoids the false positive without changing the
intent of the test fixture.
---
.../plugins/test-plugin-privacy-policy-with-errors/load.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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
index 795c6430e..d32c6f211 100644
--- 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
@@ -2,7 +2,7 @@
/**
* Plugin Name: Test Plugin Privacy Policy With Errors
* Plugin URI: https://github.com/WordPress/plugin-check
- * Description: A test plugin that handles personal data but does not call wp_add_privacy_policy_content().
+ * Description: A test plugin that handles personal data but does not register privacy policy content.
* Requires at least: 6.0
* Requires PHP: 7.4
* Version: 1.0.0