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/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* [Available Checks](checks.md)
* [AI-Powered Features & Configuration](ai-features.md)
* [WordPress Functions Compatibility Data](wp-functions-compatibility-data.md)
* [Plugin Check manifest](plugin-check-info.md)
* [CLI Commands](CLI.md)
* [Running Unit tests](running-unit-tests.md)
* [Releasing a New Version of Plugin](releasing.md)
16 changes: 16 additions & 0 deletions docs/plugin-check-info.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Plugin Check manifest

Plugin authors can add `plugin-check-info.json` to plugin root to identify bundled third-party code.

```json
{
"third_parties": [
"vendor/phpseclib",
"libraries/legacy"
]
}
```

Plugin Check keeps errors from declared paths, but hides warning-level findings for those paths. This reduces recommendations intended for plugin authors, such as replacing a library's native PHP function with a WordPress wrapper, without hiding possible errors. Findings outside declared paths remain unchanged.

Manifest is committed with plugin code, so reviewers can inspect declarations. Missing, malformed, or invalid manifest entries are ignored. Paths are relative to plugin root and use `/` separators. Entries match their declared path and files below it, not similarly named paths.
30 changes: 30 additions & 0 deletions includes/Checker/Abstract_Check_Runner.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use WordPress\Plugin_Check\Checker\Exception\Invalid_Check_Slug_Exception;
use WordPress\Plugin_Check\Checker\Preparations\Universal_Runtime_Preparation;
use WordPress\Plugin_Check\Traits\AI_Analyzer;
use WordPress\Plugin_Check\Utilities\Plugin_Config;
use WordPress\Plugin_Check\Utilities\Plugin_Request_Utility;

/**
Expand Down Expand Up @@ -444,6 +445,8 @@ final public function run() {

$results = $this->get_checks_instance()->run_checks( $this->get_check_context(), $checks, $this );

$this->filter_third_party_warnings( $results );

$ai_analysis = array();
$ai_stats = array();

Expand Down Expand Up @@ -473,6 +476,33 @@ final public function run() {
return $results;
}

/**
* Removes warning-level findings from declared third-party paths.
*
* Errors and findings outside declared paths are kept unchanged.
*
* @since 2.1.0
*
* @param Check_Result $results Check results to filter, modified in place.
*/
private function filter_third_party_warnings( Check_Result $results ) {
$third_party_paths = Plugin_Config::get_third_party_paths( $this->get_check_context()->path() );

if ( empty( $third_party_paths ) ) {
return;
}

$results->transform_messages(
function ( $message, $is_error, $file ) use ( $third_party_paths ) {
if ( ! $is_error && Plugin_Config::is_third_party_file( $file, $third_party_paths ) ) {
return false;
}

return $message;
}
);
}

/**
* Determines if any of the checks are a runtime check.
*
Expand Down
113 changes: 113 additions & 0 deletions includes/Utilities/Plugin_Config.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php
/**
* Class WordPress\Plugin_Check\Utilities\Plugin_Config
*
* @package plugin-check
*/

namespace WordPress\Plugin_Check\Utilities;

/**
* Reads optional plugin configuration.
*
* @since 2.1.0
*/
final class Plugin_Config {

/**
* Configuration file name.
*
* @var string
*/
const FILE_NAME = 'plugin-check-info.json';

/**
* Returns paths declared as third-party code.
*
* @since 2.1.0
*
* @param string $plugin_path Absolute plugin path.
* @return string[] Relative third-party paths.
*/
public static function get_third_party_paths( $plugin_path ) {
$config_file = trailingslashit( $plugin_path ) . self::FILE_NAME;

if ( ! is_readable( $config_file ) ) {
return array();
}

// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Read plugin-local JSON configuration.
$contents = file_get_contents( $config_file );
if ( false === $contents ) {
return array();
}

$config = json_decode( $contents, true );

if ( ! is_array( $config ) || empty( $config['third_parties'] ) || ! is_array( $config['third_parties'] ) ) {
return array();
}

$paths = array();
foreach ( $config['third_parties'] as $path ) {
if ( ! is_string( $path ) ) {
continue;
}

$path = self::normalize_path( $path );
if ( '' !== $path ) {
$paths[] = $path;
}
}

return array_values( array_unique( $paths ) );
}

/**
* Checks whether a relative file is inside a declared path.
*
* @since 2.1.0
*
* @param string $file Relative file path.
* @param string[] $paths Relative third-party paths.
* @return bool Whether the file is inside a declared path.
*/
public static function is_third_party_file( $file, array $paths ) {
$file = self::normalize_path( $file );

if ( '' === $file ) {
return false;
}

foreach ( $paths as $path ) {
if ( $file === $path || 0 === strpos( $file, $path . '/' ) ) {
return true;
}
}

return false;
}

/**
* Normalizes and validates a relative plugin path.
*
* @param string $path Plugin-relative path.
* @return string Normalized path, or empty string when invalid.
*/
private static function normalize_path( $path ) {
$path = trim( str_replace( '\\', '/', $path ), " /\t\n\r\0\x0B" );

if ( '' === $path || '/' === substr( $path, 0, 1 ) || preg_match( '#^[A-Za-z]:/#', $path ) ) {
return '';
}

$segments = explode( '/', $path );
foreach ( $segments as $segment ) {
if ( '' === $segment || '.' === $segment || '..' === $segment ) {
return '';
}
}

return implode( '/', $segments );
}
}
52 changes: 52 additions & 0 deletions tests/phpunit/testdata/Checks/Warning_Check.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

namespace WordPress\Plugin_Check\Test_Data;

use WordPress\Plugin_Check\Checker\Check_Categories;
use WordPress\Plugin_Check\Checker\Check_Result;
use WordPress\Plugin_Check\Checker\Static_Check;
use WordPress\Plugin_Check\Traits\Stable_Check;

class Warning_Check implements Static_Check {

use Stable_Check;

public function run( Check_Result $check_result ) {
$check_result->add_message(
false,
'Warning message',
array(
'code' => 'check_warning',
'file' => 'vendor/phpseclib/file.php',
)
);
$check_result->add_message(
false,
'Outside warning message',
array(
'code' => 'check_warning_outside',
'file' => 'includes/file.php',
)
);
$check_result->add_message(
true,
'Error message',
array(
'code' => 'check_error',
'file' => 'vendor/phpseclib/file.php',
)
);
}

public function get_categories() {
return array( Check_Categories::CATEGORY_GENERAL );
}

public function get_description(): string {
return '';
}

public function get_documentation_url(): string {
return '';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
/*
* Plugin Name: Test Plugin Invalid Config
*/
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"third_parties": ["vendor/phpseclib",]
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
/*
* Plugin Name: Test Plugin Config
*/
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"third_parties": [
"vendor\\phpseclib",
" /libraries/legacy/ "
]
}
27 changes: 27 additions & 0 deletions tests/phpunit/tests/Checker/CLI_Runner_Tests.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use WordPress\Plugin_Check\Test_Data\Empty_Check;
use WordPress\Plugin_Check\Test_Data\Error_Check;
use WordPress\Plugin_Check\Test_Data\Runtime_Check;
use WordPress\Plugin_Check\Test_Data\Warning_Check;
use WordPress\Plugin_Check\Test_Utils\Traits\With_Mock_Filesystem;

class CLI_Runner_Tests extends WP_UnitTestCase {
Expand Down Expand Up @@ -205,6 +206,32 @@ function ( $checks ) {
$this->assertNotEmpty( $results->get_errors() );
}

public function test_run_filters_third_party_warnings() {
$_SERVER['argv'] = array(
'wp',
'plugin',
'check',
UNIT_TESTS_PLUGIN_DIR . 'test-plugin-plugin-check-info',
'--checks=warning-check',
);

add_filter(
'wp_plugin_check_checks',
function () {
return array( 'warning-check' => new Warning_Check() );
}
);

$runner = new CLI_Runner();
$cleanup = $runner->prepare();
$this->cleanups[] = $cleanup;
$results = $runner->run();

$this->assertArrayNotHasKey( 'vendor/phpseclib/file.php', $results->get_warnings() );
$this->assertArrayHasKey( 'includes/file.php', $results->get_warnings() );
$this->assertArrayHasKey( 'vendor/phpseclib/file.php', $results->get_errors() );
}

public function test_runner_initialized_early_throws_plugin_basename_exception() {
global $wp_actions;

Expand Down
28 changes: 28 additions & 0 deletions tests/phpunit/tests/Checker/Check_Result_Tests.php
Original file line number Diff line number Diff line change
Expand Up @@ -188,4 +188,32 @@ public function test_get_error_count_with_message() {

$this->assertEquals( 1, $this->check_result->get_error_count() );
}

public function test_transform_messages_removes_messages_and_updates_counts() {
$this->check_result->add_message(
false,
'Third-party warning',
array(
'file' => 'test-plugin/vendor/library/file.php',
)
);
$this->check_result->add_message(
true,
'Third-party error',
array(
'file' => 'test-plugin/vendor/library/file.php',
)
);

$this->check_result->transform_messages(
function ( $message, $is_error, $file ) {
return $is_error || 0 !== strpos( $file, 'vendor/library/' ) ? $message : false;
}
);

$this->assertSame( 0, $this->check_result->get_warning_count() );
$this->assertSame( 1, $this->check_result->get_error_count() );
$this->assertEmpty( $this->check_result->get_warnings() );
$this->assertNotEmpty( $this->check_result->get_errors() );
}
}
43 changes: 43 additions & 0 deletions tests/phpunit/tests/Utilities/Plugin_Config_Tests.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php
/**
* Tests for the Plugin_Config class.
*
* @package plugin-check
*/

use WordPress\Plugin_Check\Utilities\Plugin_Config;

class Plugin_Config_Tests extends WP_UnitTestCase {

public function test_get_third_party_paths_returns_configured_paths() {
$paths = Plugin_Config::get_third_party_paths( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-plugin-check-info' );

$this->assertSame( array( 'vendor/phpseclib', 'libraries/legacy' ), $paths );
}

public function test_get_third_party_paths_ignores_missing_config() {
$this->assertSame( array(), Plugin_Config::get_third_party_paths( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-wp-functions-compatibility-with-errors' ) );
}

public function test_get_third_party_paths_ignores_invalid_config() {
$this->assertSame( array(), Plugin_Config::get_third_party_paths( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-plugin-check-info-invalid' ) );
}

/**
* @dataProvider third_party_file_provider
*/
public function test_is_third_party_file( $file, $expected ) {
$this->assertSame( $expected, Plugin_Config::is_third_party_file( $file, array( 'vendor/phpseclib' ) ) );
}

public function third_party_file_provider() {
return array(
'in declared directory' => array( 'vendor/phpseclib/Crypt/Hash.php', true ),
'declared file' => array( 'vendor/phpseclib', true ),
'near matching directory' => array( 'vendor/phpseclib2/Crypt/Hash.php', false ),
'outside directory' => array( 'includes/Plugin.php', false ),
'normalizes backslashes' => array( 'vendor\\phpseclib\\Crypt\\Hash.php', true ),
'rejects traversal' => array( 'vendor/phpseclib/../other.php', false ),
);
}
}
Loading