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..999aeaa00
--- /dev/null
+++ b/includes/Checker/Checks/Plugin_Repo/Menu_Image_Icon_Check.php
@@ -0,0 +1,324 @@
+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 token-based parser tracks
+ * argument nesting to extract the icon string along with the file position.
+ *
+ * @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.
+ */
+ 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;
+ }
+
+ $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;
+ }
+ if ( $open >= $count || '(' !== $scanned[ $open ]['text'] ) {
+ continue;
+ }
+
+ $args = array( '' );
+ $depth = 1;
+ $square_depth = 0;
+ $curly_depth = 0;
+ 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 ) {
+ $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;
+ }
+ $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.
+ *
+ * Reads the contents of the given file.
+ *
+ * @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
+ $contents = file_get_contents( $file );
+
+ return false === $contents ? '' : $contents;
+ }
+
+ /**
+ * 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 @@
+