From a9d9be35234e7f1182ebcfc9ba859679521a4657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Fri, 25 Nov 2022 11:55:52 +0100 Subject: [PATCH] Add sniff that will check that capabilities are used correctly. The sniff will check if the functions that are accepting capabilities as the argument actually use capabilities and not roles. It was rewritten to include helper functions from PHPCSUtils, cleanup code, and add additional tests. The list of core functions that are using capabilities as a parameter was updated with changes up to WordPress 6.1.0, as well as the list of capabilities. The sniff is also compatible with PHP 8 (named arguments). Co-authored-by: Juliette <663378+jrfnl@users.noreply.github.com> Co-authored-by: Ulrich Pogson Co-authored-by: Kevin Haig Co-authored-by: Gary Jones --- WordPress-Extra/ruleset.xml | 3 + WordPress/Docs/WP/CapabilitiesStandard.xml | 69 +++ WordPress/Sniffs/WP/CapabilitiesSniff.php | 478 ++++++++++++++++++ WordPress/Tests/WP/CapabilitiesUnitTest.1.inc | 117 +++++ WordPress/Tests/WP/CapabilitiesUnitTest.2.inc | 14 + WordPress/Tests/WP/CapabilitiesUnitTest.3.inc | 14 + WordPress/Tests/WP/CapabilitiesUnitTest.php | 130 +++++ 7 files changed, 825 insertions(+) create mode 100644 WordPress/Docs/WP/CapabilitiesStandard.xml create mode 100644 WordPress/Sniffs/WP/CapabilitiesSniff.php create mode 100644 WordPress/Tests/WP/CapabilitiesUnitTest.1.inc create mode 100644 WordPress/Tests/WP/CapabilitiesUnitTest.2.inc create mode 100644 WordPress/Tests/WP/CapabilitiesUnitTest.3.inc create mode 100644 WordPress/Tests/WP/CapabilitiesUnitTest.php diff --git a/WordPress-Extra/ruleset.xml b/WordPress-Extra/ruleset.xml index 2c469a593e..111d39faa5 100644 --- a/WordPress-Extra/ruleset.xml +++ b/WordPress-Extra/ruleset.xml @@ -84,6 +84,9 @@ + + + diff --git a/WordPress/Docs/WP/CapabilitiesStandard.xml b/WordPress/Docs/WP/CapabilitiesStandard.xml new file mode 100644 index 0000000000..a65848e8e1 --- /dev/null +++ b/WordPress/Docs/WP/CapabilitiesStandard.xml @@ -0,0 +1,69 @@ + + + + + + + + 'manage_sites' ) ) { } + ]]> + + + 'manage_site', $user->ID ); + ]]> + + + + + + + + 'manage_options', + 'options_page_slug', + 'project_options_page_cb' +); + ]]> + + + 'author', + 'options_page_slug', + 'project_options_page_cb' +); + ]]> + + + + + + + + 'read' ) ) { } + ]]> + + + 'level_6' ) ) { } + ]]> + + + diff --git a/WordPress/Sniffs/WP/CapabilitiesSniff.php b/WordPress/Sniffs/WP/CapabilitiesSniff.php new file mode 100644 index 0000000000..b980be1d54 --- /dev/null +++ b/WordPress/Sniffs/WP/CapabilitiesSniff.php @@ -0,0 +1,478 @@ + The key is the name of a function we're targetting, + * the value is an array containing the 1-based parameter position + * of the "capability" parameter within the function, as well as + * the name of the parameter as declared in the function. + * If the parameter name has been renamed since the release of PHP 8.0, + * the parameter can be set as an array. + */ + protected $target_functions = array( + 'add_comments_page' => array( + 'position' => 3, + 'name' => 'capability', + ), + 'add_dashboard_page' => array( + 'position' => 3, + 'name' => 'capability', + ), + 'add_links_page' => array( + 'position' => 3, + 'name' => 'capability', + ), + 'add_management_page' => array( + 'position' => 3, + 'name' => 'capability', + ), + 'add_media_page' => array( + 'position' => 3, + 'name' => 'capability', + ), + 'add_menu_page' => array( + 'position' => 3, + 'name' => 'capability', + ), + 'add_object_page' => array( // Deprecated since WP 4.5.0. + 'position' => 3, + 'name' => 'capability', + ), + 'add_options_page' => array( + 'position' => 3, + 'name' => 'capability', + ), + 'add_pages_page' => array( + 'position' => 3, + 'name' => 'capability', + ), + 'add_plugins_page' => array( + 'position' => 3, + 'name' => 'capability', + ), + 'add_posts_page' => array( + 'position' => 3, + 'name' => 'capability', + ), + 'add_submenu_page' => array( + 'position' => 4, + 'name' => 'capability', + ), + 'add_theme_page' => array( + 'position' => 3, + 'name' => 'capability', + ), + 'add_users_page' => array( + 'position' => 3, + 'name' => 'capability', + ), + 'add_utility_page' => array( // Deprecated since WP 4.5.0. + 'position' => 3, + 'name' => 'capability', + ), + 'author_can' => array( + 'position' => 2, + 'name' => 'capability', + ), + 'current_user_can' => array( + 'position' => 1, + 'name' => 'capability', + ), + 'current_user_can_for_blog' => array( + 'position' => 2, + 'name' => 'capability', + ), + 'map_meta_cap' => array( + 'position' => 1, + 'name' => 'cap', + ), + 'user_can' => array( + 'position' => 2, + 'name' => 'capability', + ), + ); + + /** + * List of core roles which should not to be used directly. + * + * @since 3.0.0 + * + * @var array Role available in WP Core. + */ + private $core_roles = array( + 'super_admin' => true, + 'administrator' => true, + 'editor' => true, + 'author' => true, + 'contributor' => true, + 'subscriber' => true, + ); + + /** + * List of known primitive and meta core capabilities. + * + * Sources: + * - {@link https://wordpress.org/support/article/roles-and-capabilities/ Roles and Capabilities handbook page} + * - The `map_meta_cap()` function in the `src/wp-includes/capabilities.php` file. + * - The tests in the `tests/phpunit/tests/user/capabilities.php` file. + * + * List is sorted alphabetically. + * + * {@internal To be updated after every major release. Last updated for WordPress 6.1.0.} + * + * @since 3.0.0 + * + * @var array All capabilities available in core. + */ + private $core_capabilities = array( + 'activate_plugin' => true, + 'activate_plugins' => true, + 'add_comment_meta' => true, + 'add_post_meta' => true, + 'add_term_meta' => true, + 'add_user_meta' => true, + 'add_users' => true, + 'assign_categories' => true, + 'assign_post_tags' => true, + 'assign_term' => true, + 'create_app_password' => true, + 'create_sites' => true, + 'create_users' => true, + 'customize' => true, + 'deactivate_plugin' => true, + 'deactivate_plugins' => true, + 'delete_app_password' => true, + 'delete_app_passwords' => true, + 'delete_block' => true, // Only seen in tests. + 'delete_blocks' => true, // Alias for 'delete_posts', but supported. + 'delete_categories' => true, + 'delete_comment_meta' => true, + 'delete_others_blocks' => true, // Alias for 'delete_others_posts', but supported. + 'delete_others_pages' => true, + 'delete_others_posts' => true, + 'delete_page' => true, // Alias, but supported. + 'delete_pages' => true, + 'delete_plugins' => true, + 'delete_post_tags' => true, + 'delete_post' => true, // Alias, but supported. + 'delete_post_meta' => true, + 'delete_posts' => true, + 'delete_private_blocks' => true, // Alias for 'delete_private_posts', but supported. + 'delete_private_pages' => true, + 'delete_private_posts' => true, + 'delete_published_blocks' => true, // Alias for 'delete_published_posts', but supported. + 'delete_published_pages' => true, + 'delete_published_posts' => true, + 'delete_site' => true, + 'delete_sites' => true, + 'delete_term' => true, + 'delete_term_meta' => true, + 'delete_themes' => true, + 'delete_user' => true, // Alias for 'delete_users', but supported. + 'delete_user_meta' => true, + 'delete_users' => true, + 'edit_app_password' => true, + 'edit_categories' => true, + 'edit_block' => true, // Only seen in tests. + 'edit_blocks' => true, // Alias for 'edit_posts', but supported. + 'edit_comment' => true, // Alias, but supported. + 'edit_comment_meta' => true, + 'edit_css' => true, + 'edit_dashboard' => true, + 'edit_files' => true, + 'edit_others_blocks' => true, // Alias for 'edit_others_posts', but supported. + 'edit_others_pages' => true, + 'edit_others_posts' => true, + 'edit_page' => true, // Alias, but supported. + 'edit_pages' => true, + 'edit_plugins' => true, + 'edit_post_tags' => true, + 'edit_post' => true, // Alias, but supported. + 'edit_post_meta' => true, + 'edit_posts' => true, + 'edit_private_blocks' => true, // Alias for 'edit_private_posts', but supported. + 'edit_private_pages' => true, + 'edit_private_posts' => true, + 'edit_published_blocks' => true, // Alias for 'edit_published_posts', but supported. + 'edit_published_pages' => true, + 'edit_published_posts' => true, + 'edit_term' => true, + 'edit_term_meta' => true, + 'edit_theme_options' => true, + 'edit_themes' => true, + 'edit_user' => true, // Alias for 'edit_users', but supported. + 'edit_user_meta' => true, + 'edit_users' => true, + 'erase_others_personal_data' => true, + 'export' => true, + 'export_others_personal_data' => true, + 'import' => true, + 'install_languages' => true, + 'install_plugins' => true, + 'install_themes' => true, + 'list_app_passwords' => true, + 'list_users' => true, + 'manage_categories' => true, + 'manage_links' => true, + 'manage_network' => true, + 'manage_network_options' => true, + 'manage_network_plugins' => true, + 'manage_network_themes' => true, + 'manage_network_users' => true, + 'manage_options' => true, + 'manage_post_tags' => true, + 'manage_privacy_options' => true, + 'manage_sites' => true, + 'moderate_comments' => true, + 'publish_blocks' => true, // Alias for 'publish_posts', but supported. + 'publish_pages' => true, + 'publish_post' => true, // Alias, but supported. + 'publish_posts' => true, + 'promote_user' => true, + 'promote_users' => true, + 'read' => true, + 'read_block' => true, // Only seen in tests. + 'read_post' => true, // Alias, but supported. + 'read_page' => true, // Alias, but supported. + 'read_app_password' => true, + 'read_private_blocks' => true, // Alias for 'read_private_posts', but supported. + 'read_private_pages' => true, + 'read_private_posts' => true, + 'remove_user' => true, // Alias for 'remove_users', but supported. + 'remove_users' => true, + 'resume_plugin' => true, // Alias for 'resume_plugins', but supported. + 'resume_plugins' => true, + 'resume_theme' => true, // Alias for 'resume_themes', but supported. + 'resume_themes' => true, + 'setup_network' => true, + 'switch_themes' => true, + 'unfiltered_html' => true, + 'unfiltered_upload' => true, + 'update_core' => true, + 'update_https' => true, + 'update_languages' => true, + 'update_plugins' => true, + 'update_php' => true, + 'update_themes' => true, + 'upgrade_network' => true, + 'upload_files' => true, + 'upload_plugins' => true, + 'upload_themes' => true, + 'view_site_health_checks' => true, + ); + + /** + * List of deprecated core capabilities. + * + * User Levels were deprecated in version 3.0. + * + * {@internal To be updated after every major release. Last updated for WordPress 6.1.0.} + * + * @link https://github.com/WordPress/wordpress-develop/blob/master/tests/phpunit/tests/user/capabilities.php + * + * @since 3.0.0 + * + * @var array All deprecated capabilities in core. + */ + private $deprecated_capabilities = array( + 'level_10' => '3.0.0', + 'level_9' => '3.0.0', + 'level_8' => '3.0.0', + 'level_7' => '3.0.0', + 'level_6' => '3.0.0', + 'level_5' => '3.0.0', + 'level_4' => '3.0.0', + 'level_3' => '3.0.0', + 'level_2' => '3.0.0', + 'level_1' => '3.0.0', + 'level_0' => '3.0.0', + ); + + /** + * Process the parameters of a matched function. + * + * @since 3.0.0 + * + * @param int $stackPtr The position of the current token in the stack. + * @param array $group_name The name of the group which was matched. + * @param string $matched_content The token content (function name) which was matched. + * @param array $parameters Array with information about the parameters. + * + * @return void + */ + public function process_parameters( $stackPtr, $group_name, $matched_content, $parameters ) { + $function_name_lc = strtolower( $matched_content ); + $function_details = $this->target_functions[ $function_name_lc ]; + + $parameter = PassedParameters::getParameterFromStack( + $parameters, + $function_details['position'], + $function_details['name'] + ); + + if ( false === $parameter ) { + return; + } + + // If the parameter is anything other than T_CONSTANT_ENCAPSED_STRING throw a warning and bow out. + $first_non_empty = null; + for ( $i = $parameter['start']; $i <= $parameter['end']; $i++ ) { + if ( isset( Tokens::$emptyTokens[ $this->tokens[ $i ]['code'] ] ) ) { + continue; + } + + if ( \T_CONSTANT_ENCAPSED_STRING !== $this->tokens[ $i ]['code'] + || null !== $first_non_empty + ) { + // Throw warning at low severity. + $this->phpcsFile->addWarning( + 'Couldn\'t determine the value passed to the $%s parameter in function call to %s(). Please check if it matches a valid capability. Found: %s', + $i, + 'Undetermined', + array( + $function_details['name'], + $matched_content, + $parameter['clean'], + ), + 3 // Message severity set to below default. + ); + return; + } + + $first_non_empty = $i; + } + + if ( null === $first_non_empty ) { + // Parse error. Bow out. + return; + } + + /* + * As of this point we know that the `$capabilities` parameter only contains the one token + * and that that token is a `T_CONSTANT_ENCAPSED_STRING`. + */ + $matched_parameter = TextStrings::stripQuotes( $this->tokens[ $first_non_empty ]['content'] ); + + if ( isset( $this->core_capabilities[ $matched_parameter ] ) ) { + return; + } + + if ( empty( $matched_parameter ) ) { + $this->phpcsFile->addError( + 'An empty string is not a valid capability. Empty string found as the $%s parameter in a function call to %s()"', + $first_non_empty, + 'Invalid', + array( + $function_details['name'], + $matched_content, + ) + ); + return; + } + + // Check if additional capabilities were registered via the ruleset and if the found capability matches any of those. + $custom_capabilities = $this->merge_custom_array( $this->custom_capabilities, array() ); + if ( isset( $custom_capabilities[ $matched_parameter ] ) ) { + return; + } + + if ( isset( $this->deprecated_capabilities[ $matched_parameter ] ) ) { + $this->get_wp_version_from_cli( $this->phpcsFile ); + $is_error = version_compare( $this->deprecated_capabilities[ $matched_parameter ], $this->minimum_supported_version, '<' ); + + $data = array( + $matched_parameter, + $matched_content, + $this->deprecated_capabilities[ $matched_parameter ], + ); + + MessageHelper::addMessage( + $this->phpcsFile, + 'The capability "%s", found in the function call to %s(), has been deprecated since WordPress version %s.', + $first_non_empty, + $is_error, + 'Deprecated', + $data + ); + return; + } + + if ( isset( $this->core_roles[ $matched_parameter ] ) ) { + $this->phpcsFile->addError( + 'Capabilities should be used instead of roles. Found "%s" in function call to %s()', + $first_non_empty, + 'RoleFound', + array( + $matched_parameter, + $matched_content, + ) + ); + return; + } + + $this->phpcsFile->addWarning( + 'Found unknown capability "%s" in function call to %s(). Please check the spelling of the capability. If this is a custom capability, please verify the capability is registered with WordPress via a call to WP_Role(s)->add_cap().' . \PHP_EOL . 'Custom capabilities can be made known to this sniff by setting the "custom_capabilities" property in the PHPCS ruleset.', + $first_non_empty, + 'Unknown', + array( + $matched_parameter, + $matched_content, + ) + ); + } + +} diff --git a/WordPress/Tests/WP/CapabilitiesUnitTest.1.inc b/WordPress/Tests/WP/CapabilitiesUnitTest.1.inc new file mode 100644 index 0000000000..40226dcdc5 --- /dev/null +++ b/WordPress/Tests/WP/CapabilitiesUnitTest.1.inc @@ -0,0 +1,117 @@ +ID ); // OK. + +/* + * Low severity warnings, usually these need to be manually checked. + */ +add_posts_page( 'page_title', 'menu_title', 'admin' . 'istrator', 'menu_slug', 'function' ); // Low severity warning. +if ( author_can( $post, $capability ) ) { } // Low severity warning. +add_submenu_page( + 'parent_slug', + 'page_title', + 'menu_title', + $variable, // Low severity warning. + 'menu_slug', + 'function' +); +add_menu_page( $pagetitle, $menu_title, $subscriber, 'handle', 'function', 'icon_url' ); // Low severity warning. +add_plugins_page( 'page_title', 'menu_title', $cap, 'menu_slug', 'function' ); // Low severity warning. +add_options_page( $pagetitle, $menu_title, CONSTANT, 'menu_slug', 'function' ); // Low severity warning. +add_posts_page( 'page_title', 'menu_title', self /* comment */ :: CAPABILITY, 'menu_slug', 'function' ); // Low severity warning. +add_posts_page( 'page_title', 'menu_title', 'admin' /* comment */ . 'istrator', 'menu_slug', 'function' ); // Low severity warning. +add_menu_page( + $p, + $t, // Comment. + $capability, // Low severity warning. +); +add_menu_page( $p, $t, 'admin' . 'istrator' ); // Low severity warning. +add_menu_page($p, $t, $caps['level'] ); // Low severity warning. + +// Parse error, but just making sure we account for all possibilities. +add_menu_page($p, $t, 'level_' 'level' ); // Low severity warning. + +/* + * Empty capability parameter. + */ +if ( author_can( $post, '' ) ) { } // Error. + +/* + * Deprecated capabilities. + */ +// phpcs:set WordPress.WP.Capabilities minimum_supported_version 2.9 +if ( author_can( $post, 'level_3' ) ) { } // Warning. + +// phpcs:set WordPress.WP.Capabilities minimum_supported_version 5.9 +if ( author_can( $post, 'level_5' ) ) { } // Error. +add_options_page( 'page_title', 'menu_title', 'level_10', 'menu_slug', 'function' ); // Error. + +/* + * Unknown capabilities, could be that they need to be set in the property, but weren't. + */ +if ( author_can( $post, 'custom_cap' ) ) { } // Warning. +if ( current_user_can( 'foo_bar' ) ) { } // Warning. +if ( current_user_can_for_blog( '3', 'custom_cap' ) ) { } // Warning. +add_users_page( 'page_title', 'menu_title', 'foo_bar', 'menu_slug', 'function' ); // Warning. +add_management_page( 'page_title', 'menu_title', 'foo_bar', 'menu_slug', 'function' ); // Warning. +add_menu_page( $pagetitle, 'menu_title', 'foo_bar', 'handle', 'function', 'icon_url' ); // Warning. + +/* + * Roles found instead of capabilities. + */ +add_posts_page( 'page_title', 'menu_title', 'administrator', 'menu_slug', 'function' ); // Error. +add_media_page( 'page_title', 'menu_title', 'editor', 'menu_slug', 'function' ); // Error. +add_pages_page( 'page_title', 'menu_title', 'author', 'menu_slug', 'function' ); // Error. +add_comments_page( 'page_title', 'menu_title', 'contributor', 'menu_slug', 'function' ); // Error. +add_theme_page( 'page_title', $menu_title, 'subscriber', 'menu_slug', 'function' ); // Error. +add_plugins_page( 'page_title', 'menu_title', 'super_admin', 'menu_slug', 'function' ); // Error. +add_users_page( 'page_title', 'menu_title', 'administrator', 'menu_slug', 'function' ); // Error. +add_management_page( 'page_title', 'menu_title', 'editor', 'menu_slug', 'function' ); // Error. +if ( current_user_can( 'super_admin' ) ) { } // Error. +if( current_user_can_for_blog( '1', 'editor' ) ) { } // Error. +add_dashboard_page( + 'page_title', + 'menu_title', + 'super_admin' /* Comment */, // Error. + 'menu_slug', + 'function' +); +add_utility_page( + 'page_title' + ,'menu_title' + ,'super_admin' // Error. + ,'menu_slug' + ,'function' + ,'icon_url' +); + +// PHP 8.0 named parameters support. +add_menu_page( capability: 'foobar', page_title: $p, menu_title: $m ); // Warning. + +/* + * Testing handling of the custom capabilities properties. + */ +// phpcs:set WordPress.WP.Capabilities custom_capabilities[] custom_cap,foo_bar +if ( current_user_can( 'foo_bar' ) ) { } // OK. +if ( author_can( $post, 'custom_cap' ) ) { } // OK. +if ( author_can( $post, 'custom_capability' ) ) { } // Warning. + +// phpcs:set WordPress.WP.Capabilities custom_capabilities[] + +// Making sure that the warnings and errors are showing up in the case where we unset the custom capabilities. +if ( author_can( $post, 'custom_cap' ) ) { } // Warning. +map_meta_cap( 'editor', $user->ID ); // Error. + +// Another parse error, but the sniff should still handle this correctly (by bowing out). +add_menu_page( $p, $t, /* deliberately empty */, $slug, ); + +add_menu_page( [] ); // Should bow out because the parameter is not found. + +$obj->current_user_can( 'foo_bar' ); // Ok. We're not checking for method calls. +My\NamespaceS\add_posts_page( 'page_title', 'menu_title', 'administrator', 'menu_slug', 'function' ); // Ok. We're not checking namespaced functions. + +// Parse error, should be handled correctly by bowing out. +add_posts_page( 'page_title', diff --git a/WordPress/Tests/WP/CapabilitiesUnitTest.2.inc b/WordPress/Tests/WP/CapabilitiesUnitTest.2.inc new file mode 100644 index 0000000000..acc1d5aaf1 --- /dev/null +++ b/WordPress/Tests/WP/CapabilitiesUnitTest.2.inc @@ -0,0 +1,14 @@ +warningSeverity = 3; + } elseif ( 'CapabilitiesUnitTest.2.inc' === $filename ) { + Helper::setConfigData( 'minimum_supported_wp_version', '2.9', true, $config ); + } elseif ( 'CapabilitiesUnitTest.3.inc' === $filename ) { + Helper::setConfigData( 'minimum_supported_wp_version', '6.1', true, $config ); + } else { + // Delete for other files. + Helper::setConfigData( 'minimum_supported_wp_version', null, true, $config ); + } + } + + /** + * Returns the lines where errors should occur. + * + * @param string $testFile The name of the file being tested. + * + * @return array => + */ + public function getErrorList( $testFile = '' ) { + switch ( $testFile ) { + case 'CapabilitiesUnitTest.1.inc': + return array( + 40 => 1, + 49 => 1, + 50 => 1, + 65 => 1, + 66 => 1, + 67 => 1, + 68 => 1, + 69 => 1, + 70 => 1, + 71 => 1, + 72 => 1, + 73 => 1, + 74 => 1, + 78 => 1, + 85 => 1, + 106 => 1, + ); + + case 'CapabilitiesUnitTest.3.inc': + return array( + 10 => 1, + 12 => 1, + 14 => 1, + ); + + default: + return array(); + } + } + + /** + * Returns the lines where warnings should occur. + * + * @param string $testFile The name of the file being tested. + * + * @return array => + */ + public function getWarningList( $testFile = '' ) { + switch ( $testFile ) { + case 'CapabilitiesUnitTest.1.inc': + return array( + 11 => 1, + 12 => 1, + 17 => 1, + 21 => 1, + 22 => 1, + 23 => 1, + 24 => 1, + 25 => 1, + 29 => 1, + 31 => 1, + 32 => 1, + 35 => 1, + 46 => 1, + 55 => 1, + 56 => 1, + 57 => 1, + 58 => 1, + 59 => 1, + 60 => 1, + 92 => 1, + 100 => 1, + 105 => 1, + ); + + case 'CapabilitiesUnitTest.2.inc': + return array( + 10 => 1, + 12 => 1, + 14 => 1, + ); + + default: + return array(); + } + } +}