diff --git a/docs/checks.md b/docs/checks.md index f7ffd0e72..b2535c619 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -37,3 +37,4 @@ | 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/) | | ai_provider | general | Recommends the WordPress AI Client when a plugin integrates directly with a third-party AI provider. | [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..ca349f6d2 --- /dev/null +++ b/includes/Checker/Checks/Plugin_Repo/Trialware_Check.php @@ -0,0 +1,210 @@ + 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', + '/(?:activation_code|activation_key)\s*(?:===|!==|==|!=)/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', + '/if\s*\(\s*(?:!\s*)?(?:is_lite|is_free_version|free_version)\s*\(/i', + '/(?:only\s+available|available\s+only)\s+in\s+(?:the\s+)?(?:pro|premium)\s+version/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', + '/free\s+trial\s+(?:has\s+)?(?:ended|expired|is\s+over)/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', + '/(?:plan|monthly|daily)\s+limit\s+(?:reached|exceeded)/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', + '/to\s+unlock\s+(?:this|all|full)\s+(?:feature|features|functionality)/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 2.1.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 2.1.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 2.1.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 2.1.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 2.1.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 d4d5c548d..c12d765af 100644 --- a/includes/Checker/Default_Check_Repository.php +++ b/includes/Checker/Default_Check_Repository.php @@ -105,6 +105,7 @@ private function register_default_checks() { 'external_admin_menu_links' => new Checks\Plugin_Repo\External_Admin_Menu_Links_Check(), 'wp_functions_compatibility' => new Checks\Plugin_Repo\WP_Functions_Compatibility_Check(), 'ai_provider' => new Checks\General\AI_Provider_Check(), + 'trialware' => new Checks\Plugin_Repo\Trialware_Check(), ) ); diff --git a/includes/Traits/AI_Analyzer.php b/includes/Traits/AI_Analyzer.php index 6dfb0a540..5e05d811a 100644 --- a/includes/Traits/AI_Analyzer.php +++ b/includes/Traits/AI_Analyzer.php @@ -84,6 +84,7 @@ protected function get_ai_prompt_map() { 'PluginCheck.CodeAnalysis.Obfuscation' => 'ai-review-code-obfuscation.md', 'PluginCheck.CodeAnalysis.SettingSanitization' => 'ai-review-setting-sanitization.md', 'PluginCheck.CodeAnalysis.PluginUpdater' => 'ai-review-plugin-updater.md', + 'trialware_' => 'ai-review-trialware.md', ); } diff --git a/prompts/ai-review-trialware.md b/prompts/ai-review-trialware.md new file mode 100644 index 000000000..dd11078b7 --- /dev/null +++ b/prompts/ai-review-trialware.md @@ -0,0 +1,14 @@ +## Trialware / Locked Functionality Issues + +A trialware issue occurs when functionality shipped in the plugin's own codebase is artificially restricted behind a license key, trial period, usage quota, "pro"/premium plan gate, or payment check, in violation of the WordPress.org guideline that plugins must be fully functional. + +Using the case as a reference, check the code to determine if it genuinely locks bundled functionality or if it is a false positive. + +Details: +- Genuinely flagged: code that checks a license/trial/quota/payment condition and then disables, hides, or short-circuits functionality that otherwise exists in the plugin's own files. +- A reference to a **separate, standalone premium plugin or add-on** sold by the same author (e.g. "Upgrade to Acme Pro" linking to a different plugin/product) is NOT trialware — that is a legitimate freemium business model, not locked bundled code. +- License-key checks used only to unlock **updates or support** (e.g. EDD/WooCommerce-style update-checker license activation) are NOT trialware, unless the same check also disables functionality already present in the submitted code. +- Checks against **external service API keys** (e.g. a third-party SaaS API key required to call that external service) are NOT trialware — the plugin isn't restricting its own bundled code, it's authenticating to an external system it doesn't control. +- Generic marketing copy, readme wording, or UI strings that merely mention "premium", "pro", or "trial" without any corresponding code path that disables bundled functionality are NOT trialware. +- Quota/limit checks tied to an **external resource** (API rate limits, storage quotas on a remote service) are NOT trialware; quota checks that disable the plugin's own local features once a count is reached ARE trialware. +- If the flagged code is inside test fixtures, examples, or clearly non-executed sample code, it is a false positive. 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' ); + +/** + * Calls a third-party weather API using its own API key. + * + * Not trialware: the key authenticates to an external service the plugin + * doesn't control, it does not gate any of the plugin's bundled functionality. + */ +function test_trialware_call_external_api() { + $api_key = get_option( 'test_weather_api_key' ); + + if ( empty( $api_key ) ) { + return new WP_Error( 'missing_api_key', __( 'Weather API key is required.', 'test-plugin' ) ); + } + + return wp_remote_get( 'https://api.example.com/weather?key=' . $api_key ); +} + +/** + * Renders a notice linking to a separate premium add-on plugin. + * + * Not trialware: this promotes a standalone product, it doesn't disable + * any functionality bundled in this plugin. + */ +function test_trialware_upgrade_notice() { + echo '

' . esc_html__( 'Need more features? Check out Test Plugin Pro, our premium add-on.', 'test-plugin' ) . '

'; + echo '' . esc_html__( 'Learn more', 'test-plugin' ) . ''; +} + +/** + * Checks for plugin updates using a license key, EDD-style updater pattern. + * + * Not trialware: the license only unlocks update/support delivery, it does + * not disable any bundled functionality. + */ +function test_trialware_check_for_updates() { + $updater = new Test_Plugin_Updater( + 'https://example.com', + __FILE__, + array( + 'license' => get_option( 'test_plugin_license_key' ), + 'item_id' => 12345, + ) + ); + + $updater->fetch_update_data(); +} 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() ); + } +}