From 9ba108869dcc871316f7e5e11aaf3f22774fa38e Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Mon, 25 May 2026 12:27:49 +0600 Subject: [PATCH] feat(plugin_repo): add trialware check Add new Trialware_Check under plugin_repo category to detect trialware and locked built-in functionality in plugin submissions. Scans PHP and JS files for five pattern groups: - License key gates - Pro/premium plan gates - Trial period expiration gates - Usage quota limits - Payment/subscription gates Each match reported as error with dedicated result code (trialware_{type}_candidate). Patterns designed to minimize false positives on legitimate external service integrations. Fixes #1313 --- docs/checks.md | 1 + .../Checks/Plugin_Repo/Trialware_Check.php | 194 ++++++++++++++++++ includes/Checker/Default_Check_Repository.php | 1 + .../load.php | 67 ++++++ .../load.php | 45 ++++ .../Checker/Checks/Trialware_Check_Tests.php | 80 ++++++++ 6 files changed, 388 insertions(+) create mode 100644 includes/Checker/Checks/Plugin_Repo/Trialware_Check.php create mode 100644 tests/phpunit/testdata/plugins/test-plugin-trialware-with-errors/load.php create mode 100644 tests/phpunit/testdata/plugins/test-plugin-trialware-without-errors/load.php create mode 100644 tests/phpunit/tests/Checker/Checks/Trialware_Check_Tests.php diff --git a/docs/checks.md b/docs/checks.md index 11662a607..2fd899202 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/) | +| trialware | plugin_repo | Detects potential trialware or locked built-in functionality in plugins. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) | diff --git a/includes/Checker/Checks/Plugin_Repo/Trialware_Check.php b/includes/Checker/Checks/Plugin_Repo/Trialware_Check.php new file mode 100644 index 000000000..f3e3efd39 --- /dev/null +++ b/includes/Checker/Checks/Plugin_Repo/Trialware_Check.php @@ -0,0 +1,194 @@ + array( + 'patterns' => array( + '/(?:is_licensed|has_license|license_valid|check_license|verify_license)\s*\(/i', + '/(?:if\s*\(\s*!?\s*(?:license_key|license|license_status))/i', + '/license_key\s*(?:===|!==|==|!=)\s*[\'"](?:[a-zA-Z0-9_-]{10,}|FREE|TRIAL)[\'"]/i', + ), + 'code' => 'trialware_license_gate_candidate', + 'message' => 'Detected possible license key gate on plugin functionality.', + ), + 'pro_premium_gate' => array( + 'patterns' => array( + '/if\s*\(\s*!?\s*(?:is_pro|is_premium|has_pro|has_premium|pro_user|premium_user|is_paid)\s*\(/i', + '/if\s*\(\s*(?:!\s*)?(?:\\$this->|self::|static::)?(?:is_pro|is_premium|can_use_pro|pro_enabled|premium_enabled)\b/i', + '/(?:current_plan|user_plan|subscription_plan)\s*(?:===|!==|==|!=)\s*[\'"](?:pro|premium|business|enterprise)[\'"]/i', + ), + 'code' => 'trialware_pro_premium_gate_candidate', + 'message' => 'Detected possible pro/premium gate on plugin functionality.', + ), + 'trial_gate' => array( + 'patterns' => array( + '/if\s*\(\s*(?:!\s*)?(?:trial_expired|is_trial_over|trial_active|has_trial|in_trial|trial_days)\s*\(/i', + '/trial_(?:days|period|expires?|end|remaining)\s*(?:<=|>=|<|>|===|!==|==|!=)\s*\d/i', + '/(?:free_trial|trial_limit|trial_usage|trial_count)\s*(?:<=|>=|<|>|===|!==|==|!=)/i', + ), + 'code' => 'trialware_trial_gate_candidate', + 'message' => 'Detected possible trial period gate on plugin functionality.', + ), + 'quota_gate' => array( + 'patterns' => array( + '/if\s*\(\s*(?:!\s*)?(?:quota_exceeded|limit_reached|usage_limit|over_quota|at_limit|exceeds_limit)\s*\(/i', + '/if\s*\(\s*\\$\w*(?:usage|count|quota|limit)\s*(?:<=|>=|<|>|===|!==|==|!=)\s*\\$\w*(?:limit|max|quota|allowed)/i', + '/(?:upgrade_to|subscribe|purchase|buy)\s*\(\s*[\'"](?:pro|premium|paid|unlimited)[\'"]/i', + ), + 'code' => 'trialware_quota_gate_candidate', + 'message' => 'Detected possible usage quota gate on plugin functionality.', + ), + 'payment_gate' => array( + 'patterns' => array( + '/if\s*\(\s*(?:!\s*)?(?:has_paid|is_paid_user|payment_valid|subscription_active|is_subscribed)\s*\(/i', + '/(?:unlock|upgrade|go_pro|go_premium|get_pro|buy_pro)\s*\(\s*\)/i', + ), + 'code' => 'trialware_payment_gate_candidate', + 'message' => 'Detected possible payment gate on plugin functionality.', + ), + ); + + /** + * Gets the categories for the check. + * + * Every check must have at least one category. + * + * @since 1.4.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.4.0 + * + * @param Check_Result $result The check result to amend, including the plugin context to check. + * @param array $files List of absolute file paths. + * + * @throws Exception Thrown when the check fails with a critical error (unrelated to any errors detected as part of + * the check). + */ + protected function check_files( Check_Result $result, array $files ) { + $code_files = array_merge( + self::filter_files_by_extension( $files, 'php' ), + self::filter_files_by_extension( $files, 'js' ) + ); + + foreach ( self::PATTERN_GROUPS as $group ) { + $this->scan_for_pattern_group( $result, $code_files, $group ); + } + } + + /** + * Scans files for a pattern group and amends the result with matches. + * + * Each pattern in the group is checked independently. Matches are de-duplicated + * per file so the same file is not reported multiple times for the same group. + * + * @since 1.4.0 + * + * @param Check_Result $result The check result to amend. + * @param array $code_files List of absolute file paths. + * @param array $group Pattern group with 'patterns', 'code', and 'message' keys. + */ + private function scan_for_pattern_group( Check_Result $result, array $code_files, array $group ) { + $reported_files = array(); + + foreach ( $group['patterns'] as $pattern ) { + $files = self::files_preg_match_all( $pattern, $code_files ); + + if ( empty( $files ) ) { + continue; + } + + foreach ( $files as $file ) { + $file_path = $file['file']; + + // De-duplicate: report each file only once per group. + if ( isset( $reported_files[ $file_path ] ) ) { + continue; + } + + $reported_files[ $file_path ] = true; + + $this->add_result_error_for_file( + $result, + $group['message'], + $group['code'], + $file_path, + $file['line'], + $file['column'], + 'https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/', + 7 + ); + } + } + } + + /** + * Gets the description for the check. + * + * Every check must have a short description explaining what the check does. + * + * @since 1.4.0 + * + * @return string Description. + */ + public function get_description(): string { + return __( 'Detects potential trialware or locked built-in functionality in plugins.', 'plugin-check' ); + } + + /** + * Gets the documentation URL for the check. + * + * Every check must have a URL with further information about the check. + * + * @since 1.4.0 + * + * @return string The documentation URL. + */ + public function get_documentation_url(): string { + return __( 'https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/', 'plugin-check' ); + } +} diff --git a/includes/Checker/Default_Check_Repository.php b/includes/Checker/Default_Check_Repository.php index c22371044..8f878a62d 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(), + 'trialware' => new Checks\Plugin_Repo\Trialware_Check(), ) ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-trialware-with-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-trialware-with-errors/load.php new file mode 100644 index 000000000..3b17a9266 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-trialware-with-errors/load.php @@ -0,0 +1,67 @@ +'; + echo esc_html__( 'Settings Page', 'test-plugin' ); + echo ''; +} + +/** + * Save plugin settings. + */ +function test_trialware_save_settings() { + if ( ! isset( $_POST['test_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['test_nonce'] ) ), 'test_action' ) ) { + return; + } + + $option = isset( $_POST['test_option'] ) ? sanitize_text_field( wp_unslash( $_POST['test_option'] ) ) : ''; + update_option( 'test_trialware_option', $option ); +} + +/** + * Load plugin textdomain. + */ +function test_trialware_load_textdomain() { + load_plugin_textdomain( 'test-plugin', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' ); +} +add_action( 'plugins_loaded', 'test_trialware_load_textdomain' ); diff --git a/tests/phpunit/tests/Checker/Checks/Trialware_Check_Tests.php b/tests/phpunit/tests/Checker/Checks/Trialware_Check_Tests.php new file mode 100644 index 000000000..044eb7666 --- /dev/null +++ b/tests/phpunit/tests/Checker/Checks/Trialware_Check_Tests.php @@ -0,0 +1,80 @@ +run( $check_result ); + + $errors = $check_result->get_errors(); + + $this->assertNotEmpty( $errors ); + $this->assertSame( 5, $check_result->get_error_count() ); + + // Verify each result code is present. + $found_codes = array(); + foreach ( $errors as $file_errors ) { + foreach ( $file_errors as $line_errors ) { + foreach ( $line_errors as $col_errors ) { + foreach ( $col_errors as $message ) { + $found_codes[] = $message['code']; + } + } + } + } + + $this->assertContains( 'trialware_license_gate_candidate', $found_codes ); + $this->assertContains( 'trialware_pro_premium_gate_candidate', $found_codes ); + $this->assertContains( 'trialware_trial_gate_candidate', $found_codes ); + $this->assertContains( 'trialware_quota_gate_candidate', $found_codes ); + $this->assertContains( 'trialware_payment_gate_candidate', $found_codes ); + } + + public function test_trialware_without_errors() { + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-trialware-without-errors/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check = new Trialware_Check(); + $check->run( $check_result ); + + $errors = $check_result->get_errors(); + $warnings = $check_result->get_warnings(); + + $this->assertEmpty( $errors ); + $this->assertEmpty( $warnings ); + + $this->assertSame( 0, $check_result->get_error_count() ); + $this->assertSame( 0, $check_result->get_warning_count() ); + } + + public function test_trialware_check_is_plugin_repo_category() { + $check = new Trialware_Check(); + + $this->assertSame( array( 'plugin_repo' ), $check->get_categories() ); + } + + public function test_trialware_check_has_description() { + $check = new Trialware_Check(); + + $this->assertNotEmpty( $check->get_description() ); + } + + public function test_trialware_check_has_documentation_url() { + $check = new Trialware_Check(); + + $this->assertNotEmpty( $check->get_documentation_url() ); + $this->assertStringContainsString( 'https://', $check->get_documentation_url() ); + } +}