From 98f821718046d060d6f243942dede99fdd8c20a0 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Sun, 9 Aug 2026 00:34:38 +0600 Subject: [PATCH 1/3] Add menu_image_icon check to detect raster image menu icons (#788) Adds a new warning-level check that detects when plugins use raster image files (PNG, JPG, GIF, WebP, ICO, BMP) as the icon parameter in add_menu_page(). Raster images do not adapt to the WordPress admin color schemes, so a dashicon or an SVG data: URI is recommended instead. The check uses a PHP token-based parser to locate the sixth parameter of add_menu_page() and reports a warning when it points to a raster image file. Dashicon classes, SVG data: URIs, empty strings and the 'none' value are all valid and skipped. Includes unit tests covering flagged and clean cases. --- docs/checks.md | 1 + .../Plugin_Repo/Menu_Image_Icon_Check.php | 301 ++++++++++++++++++ includes/Checker/Default_Check_Repository.php | 1 + .../load.php | 64 ++++ .../load.php | 81 +++++ .../Checks/Menu_Image_Icon_Check_Tests.php | 98 ++++++ 6 files changed, 546 insertions(+) create mode 100644 includes/Checker/Checks/Plugin_Repo/Menu_Image_Icon_Check.php create mode 100644 tests/phpunit/testdata/plugins/test-plugin-menu-image-icon-with-errors/load.php create mode 100644 tests/phpunit/testdata/plugins/test-plugin-menu-image-icon-without-errors/load.php create mode 100644 tests/phpunit/tests/Checker/Checks/Menu_Image_Icon_Check_Tests.php diff --git a/docs/checks.md b/docs/checks.md index 9846dc003..ce93a9f39 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -15,6 +15,7 @@ | plugin_updater | plugin_repo | Prevents altering WordPress update routines or using custom updaters, which are not allowed on WordPress.org. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) | | plugin_uninstall | plugin_repo | Checks related to plugin uninstallation. | [Learn more](https://developer.wordpress.org/plugins/plugin-basics/uninstall-methods/#method-2-uninstall-php) | | external_admin_menu_links | plugin_repo | Detects external URLs used in top-level WordPress admin menu, which disrupts the expected user experience. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/#11-plugins-should-not-hijack-the-admin) | +| menu_image_icon | plugin_repo | Detects the use of raster image files as admin menu icons, which do not adapt to the WordPress admin color schemes. Use a dashicon or an SVG data: URI instead. | [Learn more](https://developer.wordpress.org/resource/dashicons/) | | wp_functions_compatibility | plugin_repo | Checks whether WordPress functions used by the plugin are compatible with the declared minimum supported WordPress version ("Requires at least"). | [Learn more](https://developer.wordpress.org/plugins/plugin-basics/header-requirements/#header-fields) | | plugin_review_phpcs | plugin_repo | Runs PHP_CodeSniffer to detect certain best practices plugins should follow for submission on WordPress.org, including heredoc usage detection. | [Learn more](https://developer.wordpress.org/plugins/plugin-basics/best-practices/) | | direct_db_queries | security, plugin_repo | Checks the usage of direct database queries, which should be avoided. | [Learn more](https://developer.wordpress.org/apis/database/) | diff --git a/includes/Checker/Checks/Plugin_Repo/Menu_Image_Icon_Check.php b/includes/Checker/Checks/Plugin_Repo/Menu_Image_Icon_Check.php new file mode 100644 index 000000000..7d06a53d8 --- /dev/null +++ b/includes/Checker/Checks/Plugin_Repo/Menu_Image_Icon_Check.php @@ -0,0 +1,301 @@ +look_for_menu_image_icons( $result, $php_files ); + } + + /** + * Looks for raster image files used as admin menu icons and amends the result with a warning if found. + * + * @since 2.1.0 + * + * @param Check_Result $result The check result to amend, including the plugin context to check. + * @param array $php_files List of absolute PHP file paths. + */ + protected function look_for_menu_image_icons( Check_Result $result, array $php_files ) { + $matches = self::file_scan_add_menu_page_icons( $php_files ); + + if ( empty( $matches ) ) { + return; + } + + foreach ( $matches as $match ) { + if ( ! $this->is_raster_image_icon( $match['icon'] ) ) { + continue; + } + + $this->add_result_warning_for_file( + $result, + __( + 'Raster image used as admin menu icon.
Plugins should use a dashicon or an SVG data: URI as the admin menu icon, as raster image files do not adapt to the WordPress admin color schemes.', + 'plugin-check' + ), + 'menu_image_icon', + $match['file'], + $match['line'], + $match['column'], + 'https://developer.wordpress.org/resource/dashicons/', + 4 + ); + } + } + + /** + * Scans PHP files for add_menu_page() calls with a quoted string icon parameter. + * + * The icon URL is the sixth parameter of add_menu_page(). A regex is used to count + * to the sixth parameter, capturing the icon string along with the file position. + * + * @since 2.1.0 + * + * @param array $php_files List of absolute PHP file paths. + * @return array List of matches containing the file, line, column, and icon string. + */ + private static function file_scan_add_menu_page_icons( array $php_files ) { + $results = array(); + + foreach ( $php_files as $file ) { + $contents = self::file_contents( $file ); + $tokens = token_get_all( $contents ); + $offset = 0; + $scanned = array(); + + foreach ( $tokens as $token ) { + $text = is_array( $token ) ? $token[1] : $token; + $scanned[] = array( + 'id' => is_array( $token ) ? $token[0] : null, + 'text' => $text, + 'offset' => $offset, + ); + $offset += strlen( $text ); + } + + $count = count( $scanned ); + for ( $index = 0; $index < $count; $index++ ) { + if ( T_STRING !== $scanned[ $index ]['id'] || 'add_menu_page' !== strtolower( $scanned[ $index ]['text'] ) ) { + continue; + } + + $open = $index + 1; + while ( $open < $count && in_array( $scanned[ $open ]['id'], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) { + $open += 1; + } + if ( $open >= $count || '(' !== $scanned[ $open ]['text'] ) { + continue; + } + + $args = array( '' ); + $depth = 1; + for ( $cursor = $open + 1; $cursor < $count; $cursor++ ) { + $text = $scanned[ $cursor ]['text']; + if ( '(' === $text ) { + $depth += 1; + } elseif ( ')' === $text ) { + $depth -= 1; + if ( $depth < 1 ) { + break; + } + } elseif ( ',' === $text && 1 === $depth ) { + $args[] = ''; + continue; + } + $args[ count( $args ) - 1 ] .= $text; + } + + if ( isset( $args[5] ) && preg_match( '/^\s*([\'"])(.*?)\1\s*$/s', $args[5], $match ) ) { + $results[] = array( + 'file' => $file, + 'line' => self::offset_to_line( $contents, $scanned[ $index ]['offset'] ), + 'column' => self::offset_to_column( $contents, $scanned[ $index ]['offset'] ), + 'icon' => $match[2], + ); + } + } + } + + return $results; + } + + /** + * Determines whether the given icon value is a raster image file. + * + * A value is considered a raster image icon when it is a path or URL whose + * basename ends with a flagged image extension. Dashicon classes, SVG data: + * URIs, the 'none' value, and empty strings are all valid and skipped. + * + * @since 2.1.0 + * + * @param string $icon The icon parameter value. + * @return bool True if the icon is a raster image file, false otherwise. + */ + private function is_raster_image_icon( $icon ) { + if ( '' === $icon || 'none' === $icon ) { + return false; + } + + if ( 0 === strpos( $icon, 'dashicons-' ) ) { + return false; + } + + // SVG data: URIs adapt to the admin color scheme and are valid. + // Only SVG data: URIs adapt to the admin color scheme. + if ( 0 === strpos( $icon, 'data:' ) ) { + return 1 === preg_match( '/^data:image\/(?:png|jpe?g|gif|webp|bmp|x-icon|vnd\.microsoft\.icon)(?:[;,]|$)/i', $icon ); + } + + // Strip any query string or fragment before checking the extension. + $path = preg_split( '/[?#]/', $icon, 2 ); + $path = $path[0]; + + foreach ( $this->image_extensions as $extension ) { + if ( preg_match( '/\.' . preg_quote( $extension, '/' ) . '$/i', $path ) ) { + return true; + } + } + + return false; + } + + /** + * Gets the contents of the given file. + * + * This is a caching wrapper around the native file_get_contents() function. + * + * @since 2.1.0 + * + * @param string $file The file name. + * @return string The file contents. + */ + private static function file_contents( $file ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + return file_get_contents( $file ); + } + + /** + * Converts a byte offset into a line number. + * + * @since 2.1.0 + * + * @param string $contents The file contents. + * @param int $offset The byte offset of the match. + * @return int The line number (1-based). + */ + private static function offset_to_line( $contents, $offset ) { + return substr_count( $contents, "\n", 0, $offset ) + 1; + } + + /** + * Converts a byte offset into a column number. + * + * @since 2.1.0 + * + * @param string $contents The file contents. + * @param int $offset The byte offset of the match. + * @return int The column number (1-based). + */ + private static function offset_to_column( $contents, $offset ) { + $last_newline = strrpos( substr( $contents, 0, $offset ), "\n" ); + + if ( false === $last_newline ) { + return $offset + 1; + } + + return $offset - $last_newline; + } + + /** + * 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 the use of raster image files as admin menu icons, which do not adapt to the WordPress admin color schemes. Use a dashicon or an SVG data: URI instead.', '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/resource/dashicons/', 'plugin-check' ); + } +} diff --git a/includes/Checker/Default_Check_Repository.php b/includes/Checker/Default_Check_Repository.php index d4d5c548d..48878571c 100644 --- a/includes/Checker/Default_Check_Repository.php +++ b/includes/Checker/Default_Check_Repository.php @@ -103,6 +103,7 @@ private function register_default_checks() { 'minified_files' => new Checks\Plugin_Repo\Minified_Files_Check(), 'direct_file_access' => new Checks\Plugin_Repo\Direct_File_Access_Check(), 'external_admin_menu_links' => new Checks\Plugin_Repo\External_Admin_Menu_Links_Check(), + 'menu_image_icon' => new Checks\Plugin_Repo\Menu_Image_Icon_Check(), 'wp_functions_compatibility' => new Checks\Plugin_Repo\WP_Functions_Compatibility_Check(), 'ai_provider' => new Checks\General\AI_Provider_Check(), ) diff --git a/tests/phpunit/testdata/plugins/test-plugin-menu-image-icon-with-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-menu-image-icon-with-errors/load.php new file mode 100644 index 000000000..415f8e03e --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-menu-image-icon-with-errors/load.php @@ -0,0 +1,64 @@ +

My Plugin

'; +} diff --git a/tests/phpunit/testdata/plugins/test-plugin-menu-image-icon-without-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-menu-image-icon-without-errors/load.php new file mode 100644 index 000000000..97e47996f --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-menu-image-icon-without-errors/load.php @@ -0,0 +1,81 @@ +

My Plugin

'; +} + +function my_plugin_page_2() { + echo '

My Plugin 2

'; +} + +function my_plugin_page_3() { + echo '

My Plugin 3

'; +} + +function plugin_page() { + echo '

Plugin

'; +} + +function plugin_a_page() { + echo '

Plugin A

'; +} + +function plugin_b_page() { + echo '

Plugin B

'; +} + +function plugin_c_page() { + echo '

Plugin C

'; +} diff --git a/tests/phpunit/tests/Checker/Checks/Menu_Image_Icon_Check_Tests.php b/tests/phpunit/tests/Checker/Checks/Menu_Image_Icon_Check_Tests.php new file mode 100644 index 000000000..318def5b9 --- /dev/null +++ b/tests/phpunit/tests/Checker/Checks/Menu_Image_Icon_Check_Tests.php @@ -0,0 +1,98 @@ +run( $check_result ); + + $warnings = $check_result->get_warnings(); + + $this->assertNotEmpty( $warnings ); + $this->assertArrayHasKey( 'load.php', $warnings ); + // One warning per flagged icon in the fixture (png, jpg, gif, webp, ico, bmp, png?v=2). + $this->assertSame( 7, $check_result->get_warning_count() ); + + // Confirm these are warnings, not errors. + $this->assertSame( 0, $check_result->get_error_count() ); + + // Check that the warning code is correct. + $found_menu_image_icon_warning = false; + foreach ( $warnings['load.php'] as $line => $columns ) { + foreach ( $columns as $column => $messages ) { + foreach ( $messages as $message ) { + if ( 'menu_image_icon' === $message['code'] ) { + $found_menu_image_icon_warning = true; + break 3; + } + } + } + } + $this->assertTrue( $found_menu_image_icon_warning, 'Expected menu_image_icon warning code not found.' ); + } + + /** + * Test that dashicons, SVGs, and valid icon values do not trigger warnings. + */ + public function test_no_errors_for_clean_plugin() { + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-menu-image-icon-without-errors/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check = new Menu_Image_Icon_Check(); + $check->run( $check_result ); + + $warnings = $check_result->get_warnings(); + + $this->assertEmpty( $warnings ); + $this->assertSame( 0, $check_result->get_warning_count() ); + $this->assertSame( 0, $check_result->get_error_count() ); + } + + /** + * Test that the check returns the correct categories. + */ + public function test_get_categories() { + $check = new Menu_Image_Icon_Check(); + $categories = $check->get_categories(); + + $this->assertContains( Check_Categories::CATEGORY_PLUGIN_REPO, $categories ); + } + + /** + * Test that the check has a description. + */ + public function test_get_description() { + $check = new Menu_Image_Icon_Check(); + $description = $check->get_description(); + + $this->assertNotEmpty( $description ); + $this->assertIsString( $description ); + } + + /** + * Test that the check has a documentation URL. + */ + public function test_get_documentation_url() { + $check = new Menu_Image_Icon_Check(); + $url = $check->get_documentation_url(); + + $this->assertNotEmpty( $url ); + $this->assertStringContainsString( 'https://', $url ); + } +} From 9ef405e3e8b45db9cfb5a1aa9fd5b4b01a3747c8 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Sun, 9 Aug 2026 00:56:44 +0600 Subject: [PATCH 2/3] fix(ci): suppress PHPMD complexity on menu image icon parser - add PHPMD NPath and cyclomatic complexity suppressions in Menu_Image_Icon_Check - match established suppression pattern in Direct_File_Access_Check and Plugin_Header_Fields_Check Errors fixed: - PHPMD.NPathComplexity: file_scan_add_menu_page_icons() 2841 > 200 - PHPMD.CyclomaticComplexity: file_scan_add_menu_page_icons() 20 >= 20 PHP 8.5 compatible. All CI checks passing. Refs #1438 --- includes/Checker/Checks/Plugin_Repo/Menu_Image_Icon_Check.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/includes/Checker/Checks/Plugin_Repo/Menu_Image_Icon_Check.php b/includes/Checker/Checks/Plugin_Repo/Menu_Image_Icon_Check.php index 7d06a53d8..b01e3711b 100644 --- a/includes/Checker/Checks/Plugin_Repo/Menu_Image_Icon_Check.php +++ b/includes/Checker/Checks/Plugin_Repo/Menu_Image_Icon_Check.php @@ -118,6 +118,9 @@ protected function look_for_menu_image_icons( Check_Result $result, array $php_f * * @since 2.1.0 * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + * * @param array $php_files List of absolute PHP file paths. * @return array List of matches containing the file, line, column, and icon string. */ From 52aa1849ce44f1cbb7bd4cd29717e1de5804ef19 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Mon, 10 Aug 2026 16:38:29 +0600 Subject: [PATCH 3/3] fix(checker): harden menu_image_icon parser against edge cases - Skip method calls ($obj->add_menu_page) and static calls (Class::add_menu_page) to avoid false positives - Handle PHP 8+ nullsafe operator (?->) via text check for PHP 7.4 compat - Track square and curly bracket nesting in argument splitting - Guard file_get_contents() false return to prevent TypeError - Update docblocks to accurately describe token-based parsing logic - Add test fixtures for method and static call skip cases --- .../Plugin_Repo/Menu_Image_Icon_Check.php | 34 +++++++++++++++---- .../load.php | 6 ++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/includes/Checker/Checks/Plugin_Repo/Menu_Image_Icon_Check.php b/includes/Checker/Checks/Plugin_Repo/Menu_Image_Icon_Check.php index b01e3711b..999aeaa00 100644 --- a/includes/Checker/Checks/Plugin_Repo/Menu_Image_Icon_Check.php +++ b/includes/Checker/Checks/Plugin_Repo/Menu_Image_Icon_Check.php @@ -113,8 +113,8 @@ protected function look_for_menu_image_icons( Check_Result $result, array $php_f /** * Scans PHP files for add_menu_page() calls with a quoted string icon parameter. * - * The icon URL is the sixth parameter of add_menu_page(). A regex is used to count - * to the sixth parameter, capturing the icon string along with the file position. + * The icon URL is the sixth parameter of add_menu_page(). A token-based parser tracks + * argument nesting to extract the icon string along with the file position. * * @since 2.1.0 * @@ -149,6 +149,14 @@ private static function file_scan_add_menu_page_icons( array $php_files ) { continue; } + $previous = $index - 1; + while ( $previous >= 0 && in_array( $scanned[ $previous ]['id'], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) { + $previous -= 1; + } + if ( $previous >= 0 && ( '?->' === $scanned[ $previous ]['text'] || in_array( $scanned[ $previous ]['id'], array( T_OBJECT_OPERATOR, T_DOUBLE_COLON ), true ) ) ) { + continue; + } + $open = $index + 1; while ( $open < $count && in_array( $scanned[ $open ]['id'], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) { $open += 1; @@ -157,8 +165,10 @@ private static function file_scan_add_menu_page_icons( array $php_files ) { continue; } - $args = array( '' ); - $depth = 1; + $args = array( '' ); + $depth = 1; + $square_depth = 0; + $curly_depth = 0; for ( $cursor = $open + 1; $cursor < $count; $cursor++ ) { $text = $scanned[ $cursor ]['text']; if ( '(' === $text ) { @@ -168,7 +178,15 @@ private static function file_scan_add_menu_page_icons( array $php_files ) { if ( $depth < 1 ) { break; } - } elseif ( ',' === $text && 1 === $depth ) { + } elseif ( '[' === $text ) { + $square_depth += 1; + } elseif ( ']' === $text ) { + $square_depth -= 1; + } elseif ( '{' === $text ) { + $curly_depth += 1; + } elseif ( '}' === $text ) { + $curly_depth -= 1; + } elseif ( ',' === $text && 1 === $depth && 0 === $square_depth && 0 === $curly_depth ) { $args[] = ''; continue; } @@ -232,7 +250,7 @@ private function is_raster_image_icon( $icon ) { /** * Gets the contents of the given file. * - * This is a caching wrapper around the native file_get_contents() function. + * Reads the contents of the given file. * * @since 2.1.0 * @@ -241,7 +259,9 @@ private function is_raster_image_icon( $icon ) { */ private static function file_contents( $file ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents - return file_get_contents( $file ); + $contents = file_get_contents( $file ); + + return false === $contents ? '' : $contents; } /** diff --git a/tests/phpunit/testdata/plugins/test-plugin-menu-image-icon-without-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-menu-image-icon-without-errors/load.php index 97e47996f..87d3ce596 100644 --- a/tests/phpunit/testdata/plugins/test-plugin-menu-image-icon-without-errors/load.php +++ b/tests/phpunit/testdata/plugins/test-plugin-menu-image-icon-without-errors/load.php @@ -49,6 +49,12 @@ $icon_url = 'img/icon.png'; add_menu_page( 'Plugin C', 'Plugin C', 'manage_options', 'plugin-c', 'plugin_c_page', $icon_url, 36 ); +// Method call with same name — not a WordPress function call. +$obj->add_menu_page( 'Plugin D', 'Plugin D', 'manage_options', 'plugin-d', 'plugin_d_page', 'img/icon.png', 37 ); + +// Static method call with same name — not a WordPress function call. +Plugin_Manager::add_menu_page( 'Plugin E', 'Plugin E', 'manage_options', 'plugin-e', 'plugin_e_page', 'img/icon.png', 38 ); + /** * Callback functions for admin pages. */