From 548eb7b467bc92c04b979eac0a385aaa3cdd324d Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Sun, 24 May 2026 19:21:51 +0600 Subject: [PATCH 1/2] feat(checks): detect production-time PHP error reporting changes - Add Php_Error_Reporting_Check to detect error_reporting(), ini_set('display_errors'), and redefine of debug constants (WP_DEBUG, etc.). - Register Php_Error_Reporting_Check in Default_Check_Repository. - Add unit tests and test plugins fixtures for verification. Fixes #1315 --- .../General/Php_Error_Reporting_Check.php | 265 ++++++++++++++++++ includes/Checker/Default_Check_Repository.php | 1 + .../load.php | 26 ++ .../load.php | 16 ++ .../tests/some-test.php | 14 + .../Php_Error_Reporting_Check_Tests.php | 40 +++ 6 files changed, 362 insertions(+) create mode 100644 includes/Checker/Checks/General/Php_Error_Reporting_Check.php create mode 100644 tests/phpunit/testdata/plugins/test-plugin-php-error-reporting-with-errors/load.php create mode 100644 tests/phpunit/testdata/plugins/test-plugin-php-error-reporting-without-errors/load.php create mode 100644 tests/phpunit/testdata/plugins/test-plugin-php-error-reporting-without-errors/tests/some-test.php create mode 100644 tests/phpunit/tests/Checker/Checks/Php_Error_Reporting_Check_Tests.php diff --git a/includes/Checker/Checks/General/Php_Error_Reporting_Check.php b/includes/Checker/Checks/General/Php_Error_Reporting_Check.php new file mode 100644 index 000000000..2a1865059 --- /dev/null +++ b/includes/Checker/Checks/General/Php_Error_Reporting_Check.php @@ -0,0 +1,265 @@ +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. + */ + 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( + '%1$s

%2$s

%3$s

%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 error_reporting(), ini_set(\'display_errors\', …), or redefining WP_DEBUG, WP_DEBUG_LOG, WP_DEBUG_DISPLAY or SCRIPT_DEBUG 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 php.ini and the site\'s wp-config.php 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'; + } +} diff --git a/includes/Checker/Default_Check_Repository.php b/includes/Checker/Default_Check_Repository.php index c22371044..f6ada18c0 100644 --- a/includes/Checker/Default_Check_Repository.php +++ b/includes/Checker/Default_Check_Repository.php @@ -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(), diff --git a/tests/phpunit/testdata/plugins/test-plugin-php-error-reporting-with-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-php-error-reporting-with-errors/load.php new file mode 100644 index 000000000..c776b4c1e --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-php-error-reporting-with-errors/load.php @@ -0,0 +1,26 @@ +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() ); + } +} From b3f3cdd7f49498614bdf682d079541e0f216cb0a Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Sun, 31 May 2026 09:16:59 +0600 Subject: [PATCH 2/2] fix(ci): suppress PHPMD NPathComplexity on check_ast() Add @SuppressWarnings(PHPMD.NPathComplexity) to check_ast() method. NPath 1206 exceeds threshold 200; method is linear AST scan, not truly complex. Same annotation pattern used in Enqueued_Scripts_Size_Check and Direct_File_Access_Check. Refs #1317 --- includes/Checker/Checks/General/Php_Error_Reporting_Check.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/includes/Checker/Checks/General/Php_Error_Reporting_Check.php b/includes/Checker/Checks/General/Php_Error_Reporting_Check.php index 2a1865059..1748ee54a 100644 --- a/includes/Checker/Checks/General/Php_Error_Reporting_Check.php +++ b/includes/Checker/Checks/General/Php_Error_Reporting_Check.php @@ -104,6 +104,8 @@ private function check_file( Check_Result $result, string $file ) { * @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();