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
267 changes: 267 additions & 0 deletions includes/Checker/Checks/General/Php_Error_Reporting_Check.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
<?php
/**
* Class Php_Error_Reporting_Check.
*
* @package plugin-check
*/

namespace WordPress\Plugin_Check\Checker\Checks\General;

use PhpParser\Error;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Stmt;
use PhpParser\NodeFinder;
use PhpParser\ParserFactory;
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 for production-time PHP error reporting changes.
*
* @since 1.9.0
*/
class Php_Error_Reporting_Check extends Abstract_File_Check {

use Amend_Check_Result;
use Stable_Check;

/**
* Gets the categories for the check.
*
* Every check must have at least one category.
*
* @since 1.9.0
*
* @return array The categories for the check.
*/
public function get_categories() {
return array(
Check_Categories::CATEGORY_GENERAL,
);
}

/**
* Amends the given result by running the check on the given list of files.
*
* @since 1.9.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' );
$plugin_path = $result->plugin()->path();

foreach ( $php_files as $file ) {
// Skip test suite folders or files relative to the plugin's root path.
$relative_file = str_replace( $plugin_path, '', $file );
if ( preg_match( '#^(?:tests|test|testdata|phpunit)/#i', $relative_file ) || preg_match( '#/phpunit[^/]*$#i', $relative_file ) ) {
continue;
}

$this->check_file( $result, $file );
}
}

/**
* Scans a single PHP file for error reporting violations.
*
* @since 1.9.0
*
* @param Check_Result $result The check result to amend.
* @param string $file Absolute path to the file.
*/
private function check_file( Check_Result $result, string $file ) {
$contents = file_get_contents( $file );
if ( false === $contents ) {
return;
}

// Try AST-based detection first.
$parser = ( new ParserFactory() )->create( ParserFactory::PREFER_PHP7 );
try {
$ast = $parser->parse( $contents );
if ( null !== $ast ) {
$this->check_ast( $result, $file, $ast );
return;
}
} catch ( Error $e ) {
// Fall through to regex-based detection if parsing fails.
}

$this->check_regex( $result, $file, $contents );
}

/**
* Scans the AST of a file for error reporting violations.
*
* @since 1.9.0
*
* @param Check_Result $result The check result to amend.
* @param string $file Absolute path to the file.
* @param array $ast The parsed AST nodes.
*
* @SuppressWarnings(PHPMD.NPathComplexity)
*/
private function check_ast( Check_Result $result, string $file, array $ast ) {
$node_finder = new NodeFinder();
$func_calls = $node_finder->findInstanceOf( $ast, Expr\FuncCall::class );

foreach ( $func_calls as $func_call ) {
// @phpstan-ignore-next-line Access to property $name on Expr\FuncCall.
if ( ! $func_call->name instanceof Node\Name ) {
continue;
}

// @phpstan-ignore-next-line Access to property $name on Expr\FuncCall.
$func_name = strtolower( $func_call->name->toString() );
$line = method_exists( $func_call, 'getStartLine' ) ? $func_call->getStartLine() : 0;

// 1. Direct calls to error_reporting().
if ( 'error_reporting' === $func_name ) {
$this->add_violation( $result, $file, $line );
continue;
}

// 2. ini_set() / ini_alter().
if ( in_array( $func_name, array( 'ini_set', 'ini_alter' ), true ) ) {
if ( ! empty( $func_call->args[0] ) ) {
$first_arg = $func_call->args[0]->value;
if ( $first_arg instanceof Node\Scalar\String_ ) {
$arg_value = strtolower( $first_arg->value );
if ( in_array( $arg_value, array( 'error_reporting', 'display_errors' ), true ) ) {
$this->add_violation( $result, $file, $line );
continue;
}
}
}
}

// 3. define() overrides.
if ( 'define' === $func_name ) {
if ( ! empty( $func_call->args[0] ) ) {
$first_arg = $func_call->args[0]->value;
if ( $first_arg instanceof Node\Scalar\String_ ) {
$arg_value = $first_arg->value;
if ( in_array( $arg_value, array( 'WP_DEBUG', 'WP_DEBUG_LOG', 'WP_DEBUG_DISPLAY', 'SCRIPT_DEBUG' ), true ) ) {
$this->add_violation( $result, $file, $line );
continue;
}
}
}
}
}

// Also check for the const keyword: e.g. const WP_DEBUG = true.
$consts = $node_finder->findInstanceOf( $ast, Stmt\Const_::class );
foreach ( $consts as $const_stmt ) {
// @phpstan-ignore-next-line Access to property $consts on Stmt\Const_.
foreach ( $const_stmt->consts as $const ) {
$const_name = $const->name->toString();
$line = method_exists( $const, 'getStartLine' ) ? $const->getStartLine() : 0;
if ( in_array( $const_name, array( 'WP_DEBUG', 'WP_DEBUG_LOG', 'WP_DEBUG_DISPLAY', 'SCRIPT_DEBUG' ), true ) ) {
$this->add_violation( $result, $file, $line );
}
}
}
}

/**
* Fallback regex-based detection for error reporting violations.
*
* @since 1.9.0
*
* @param Check_Result $result The check result to amend.
* @param string $file Absolute path to the file.
* @param string $contents File contents.
*/
private function check_regex( Check_Result $result, string $file, string $contents ) {
// Clean comments before checking regex.
$cleaned = preg_replace( '/\/\*.*?\*\//s', '', $contents );
$cleaned = preg_replace( '/\/\/.*$/m', '', $cleaned );
$cleaned = preg_replace( '/#.*$/m', '', $cleaned );

$patterns = array(
// error_reporting(...).
'/\berror_reporting\s*\(/i',
// ini_set(...) / ini_alter(...).
'/\bini_(?:set|alter)\s*\(\s*[\'"](?:error_reporting|display_errors)[\'"]/i',
// define(...).
'/\bdefine\s*\(\s*[\'"](?:WP_DEBUG|WP_DEBUG_LOG|WP_DEBUG_DISPLAY|SCRIPT_DEBUG)[\'"]/i',
// const WP_DEBUG = ....
'/\bconst\s+(?:WP_DEBUG|WP_DEBUG_LOG|WP_DEBUG_DISPLAY|SCRIPT_DEBUG)\b/i',
);

// Scan line by line to locate line numbers.
$lines = explode( "\n", $cleaned );
foreach ( $lines as $index => $line_content ) {
$line_num = $index + 1;
foreach ( $patterns as $pattern ) {
if ( preg_match( $pattern, $line_content ) ) {
$this->add_violation( $result, $file, $line_num );
break; // Only flag once per line.
}
}
}
}

/**
* Adds a standard warning message for a violation.
*
* @since 1.9.0
*
* @param Check_Result $result The check result to amend.
* @param string $file Absolute path to the file.
* @param int $line The line number on which the warning occurred.
*/
private function add_violation( Check_Result $result, string $file, int $line ) {
$message = sprintf(
'<strong>%1$s</strong><br><br>%2$s<br><br>%3$s<br><br>%4$s',
__( 'Do not change PHP error reporting in production code', 'plugin-check' ),
__( 'A plugin should not modify PHP\'s error-reporting configuration. Calls such as <code>error_reporting()</code>, <code>ini_set(\'display_errors\', &hellip;)</code>, or redefining <code>WP_DEBUG</code>, <code>WP_DEBUG_LOG</code>, <code>WP_DEBUG_DISPLAY</code> or <code>SCRIPT_DEBUG</code> change behaviour for every other plugin and theme on the site.', 'plugin-check' ),
__( 'This can leak sensitive information (paths, secrets, stack traces) and breaks the standard debugging workflow for site owners and other developers. The host\'s <code>php.ini</code> and the site\'s <code>wp-config.php</code> are the correct places to control this.', 'plugin-check' ),
__( 'Please remove these calls, or move them behind a strictly developer-only flag that is never set in shipped code.', 'plugin-check' )
);

$this->add_result_warning_for_file(
$result,
$message,
'php_error_reporting_detected',
$file,
$line,
0,
'https://www.php.net/manual/en/function.error-reporting.php',
8
);
}

/**
* Gets the description for the check.
*
* Every check must have a short description explaining what the check does.
*
* @since 1.9.0
*
* @return string Description.
*/
public function get_description(): string {
return __( 'Detects runtime changes to PHP error reporting configuration or WordPress debug constants.', 'plugin-check' );
}

/**
* Gets the documentation URL for the check.
*
* Every check must have a URL with further information about the check.
*
* @since 1.9.0
*
* @return string The documentation URL.
*/
public function get_documentation_url(): string {
return 'https://www.php.net/manual/en/function.error-reporting.php';
}
}
1 change: 1 addition & 0 deletions includes/Checker/Default_Check_Repository.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ private function register_default_checks() {
'wp_plugin_check_checks',
array(
'i18n_usage' => new Checks\General\I18n_Usage_Check(),
'php_error_reporting' => new Checks\General\Php_Error_Reporting_Check(),
'enqueued_scripts_size' => new Checks\Performance\Enqueued_Scripts_Size_Check(),
'enqueued_styles_size' => new Checks\Performance\Enqueued_Styles_Size_Check(),
'code_obfuscation' => new Checks\Plugin_Repo\Code_Obfuscation_Check(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php
/**
* Plugin Name: Test Plugin - PHP Error Reporting With Errors
* Description: Test plugin that triggers warnings for changing PHP error reporting settings.
* Version: 1.0.0
* Author: WordPress.org
* Text Domain: test-plugin-php-error-reporting-with-errors
*
* @package plugin-check
*/

error_reporting( 0 );

error_reporting( E_ALL );

ini_set( 'display_errors', 1 );

ini_set( 'error_reporting', E_ALL );

ini_alter( 'display_errors', 0 );

define( 'WP_DEBUG', true );

define( 'WP_DEBUG_LOG', true );

define( 'SCRIPT_DEBUG', true );
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php
/**
* Plugin Name: Test Plugin - PHP Error Reporting Without Errors
* Description: Test plugin that does not trigger warnings for changing PHP error reporting settings.
* Version: 1.0.0
* Author: WordPress.org
* Text Domain: test-plugin-php-error-reporting-without-errors
*
* @package plugin-check
*/

// Memory limit changes are allowed.
ini_set( 'memory_limit', '256M' );

// Unaffected define statements are allowed.
define( 'MY_CUSTOM_CONSTANT', true );
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php
/**
* PHPUnit test case containing error reporting configurations.
*
* @package plugin-check
*/

class Some_Test_Case {
public function test_something() {
error_reporting( 0 );
ini_set( 'display_errors', 1 );
define( 'WP_DEBUG', true );
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

use WordPress\Plugin_Check\Checker\Check_Context;
use WordPress\Plugin_Check\Checker\Check_Result;
use WordPress\Plugin_Check\Checker\Checks\General\Php_Error_Reporting_Check;

class Php_Error_Reporting_Check_Tests extends WP_UnitTestCase {

public function test_run_with_errors() {
$check = new Php_Error_Reporting_Check();
$context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-php-error-reporting-with-errors/load.php' );
$check_result = new Check_Result( $context );

$check->run( $check_result );

$warnings = $check_result->get_warnings();

$this->assertNotEmpty( $warnings );
$this->assertArrayHasKey( 'load.php', $warnings );

$this->assertEquals( 8, $check_result->get_warning_count() );

$first_line_warnings = reset( $warnings['load.php'] );
$first_column_warnings = reset( $first_line_warnings );
$warning_data = reset( $first_column_warnings );

$this->assertEquals( 'php_error_reporting_detected', $warning_data['code'] );
}

public function test_run_without_errors() {
$check = new Php_Error_Reporting_Check();
$context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-php-error-reporting-without-errors/load.php' );
$check_result = new Check_Result( $context );

$check->run( $check_result );

$this->assertEquals( 0, $check_result->get_warning_count() );
$this->assertEquals( 0, $check_result->get_error_count() );
}
}
Loading