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
1 change: 1 addition & 0 deletions docs/checks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/) |
210 changes: 210 additions & 0 deletions includes/Checker/Checks/Plugin_Repo/Trialware_Check.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
<?php
/**
* Class Trialware_Check.
*
* @package plugin-check
*/

namespace WordPress\Plugin_Check\Checker\Checks\Plugin_Repo;

use Exception;
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 trialware or locked built-in functionality.
*
* Scans code-like files for indicators that functionality shipped with the plugin
* is gated behind license keys, trials, payment checks, quotas, or "pro" plan gates.
*
* All result codes produced by this check are AI-review candidates, not confirmed
* violations: regex matches are inherently prone to false positives (a separate premium
* product, an external service API key, harmless update-checker license wording, etc).
* Running Plugin Check with the `--ai` flag (or the AI toggle in the admin UI) triggers
* `AI_Analyzer::analyze_results_with_ai()` from
* {@see \WordPress\Plugin_Check\Checker\Abstract_Check_Runner::run()}, which reviews each
* `trialware_*` candidate against `prompts/ai-review-trialware.md` and tags it as a false
* positive when appropriate. This check intentionally does not call the AI client itself;
* it only produces candidates for that existing shared AI-review pass.
*
* @since 2.1.0
*/
class Trialware_Check extends Abstract_File_Check {

use Amend_Check_Result;
use Stable_Check;

/**
* Pattern groups to detect trialware indicators.
*
* Each group has:
* - 'patterns': array of regex patterns.
* - 'code': result code for matches in this group.
* - 'message': human-readable description of what was detected.
*
* @since 2.1.0
* @var array
*/
const PATTERN_GROUPS = array(
'license_gate' => 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' );
}
}
1 change: 1 addition & 0 deletions includes/Checker/Default_Check_Repository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
);

Expand Down
1 change: 1 addition & 0 deletions includes/Traits/AI_Analyzer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
}

Expand Down
14 changes: 14 additions & 0 deletions prompts/ai-review-trialware.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php
/**
* Plugin Name: Test Plugin Trialware With Errors
* Description: Test plugin that contains trialware patterns.
* Version: 1.0.0
* Author: Test Author
* License: GPL-2.0+
*
* @package test-plugin-trialware-with-errors
*/

defined( 'ABSPATH' ) || exit;

/**
* Check if user has valid license.
*/
function test_trialware_check_license() {
if ( ! is_licensed() ) {
return false;
}

return true;
}

/**
* Check if user is on pro plan.
*/
function test_trialware_is_pro() {
if ( ! is_pro() ) {
return false;
}

return true;
}

/**
* Check trial expiration.
*/
function test_trialware_check_trial() {
if ( trial_expired() ) {
return false;
}

return true;
}

/**
* Check usage quota.
*/
function test_trialware_check_quota() {
if ( quota_exceeded() ) {
return false;
}

return true;
}

/**
* Check payment status.
*/
function test_trialware_check_payment() {
if ( ! has_paid() ) {
return false;
}

return true;
}
Loading
Loading