From a3e91b5be6a886da6df701695c8518aba50b8d07 Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Tue, 7 Dec 2021 23:00:48 +0100 Subject: [PATCH] Add allow-plugins config value (#10314) Fixes #5659 - Automatically switch off plugins by default in July 2022 - reword hash into object in schema Co-authored-by: Nils Adermann --- doc/06-config.md | 33 ++++- res/composer-schema.json | 44 +++--- src/Composer/Command/ConfigCommand.php | 31 +++- src/Composer/Config.php | 18 ++- src/Composer/Config/JsonConfigSource.php | 2 +- src/Composer/Plugin/PluginManager.php | 135 +++++++++++++++++- .../installed-versions/composer.json | 3 + .../installed-versions2/composer.json | 3 + .../Test/Plugin/PluginInstallerTest.php | 12 +- 9 files changed, 249 insertions(+), 32 deletions(-) diff --git a/doc/06-config.md b/doc/06-config.md index 6afbce1aa..7bde6bfb8 100644 --- a/doc/06-config.md +++ b/doc/06-config.md @@ -24,6 +24,37 @@ helper is available: } ``` +## allow-plugins + +Defaults to `null` (allow all plugins implicitly) for backwards compatibility until July 2022. +At that point the default will become `{}` and plugins will not load anymore unless allowed. + +As of Composer 2.2.0, the `allow-plugins` option adds a layer of security +allowing you to restrict which Composer plugins are able to execute code during +a Composer run. + +When a new plugin is first activated, which is not yet listed in the config option, +Composer will print a warning. If you run Composer interactively it will +prompt you to decide if you want to execute the plugin or not. + +Use this setting to allow only packages you trust to execute code. Set it to +an object with package name patterns as keys. The values are **true** to allow +and **false** to disallow while suppressing further warnings and prompts. + +```json +{ + "config": { + "allow-plugins": { + "third-party/required-plugin": true, + "my-organization/*": true, + "unnecessary/plugin": false + } + } +} +``` + +You can also set the config option itself to `false` to disallow all plugins, or `true` to allow all plugins to run (NOT recommended). + ## use-include-path Defaults to `false`. If `true`, the Composer autoloader will also look for classes @@ -33,7 +64,7 @@ in the PHP include path. Defaults to `dist` and can be any of `source`, `dist` or `auto`. This option allows you to set the install method Composer will prefer to use. Can -optionally be a hash of patterns for more granular install preferences. +optionally be an object with package name patterns for keys for more granular install preferences. ```json { diff --git a/res/composer-schema.json b/res/composer-schema.json index 02d529777..f636fc9f5 100644 --- a/res/composer-schema.json +++ b/res/composer-schema.json @@ -138,42 +138,42 @@ }, "require": { "type": "object", - "description": "This is a hash of package name (keys) and version constraints (values) that are required to run this package.", + "description": "This is an object of package name (keys) and version constraints (values) that are required to run this package.", "additionalProperties": { "type": "string" } }, "require-dev": { "type": "object", - "description": "This is a hash of package name (keys) and version constraints (values) that this package requires for developing it (testing tools and such).", + "description": "This is an object of package name (keys) and version constraints (values) that this package requires for developing it (testing tools and such).", "additionalProperties": { "type": "string" } }, "replace": { "type": "object", - "description": "This is a hash of package name (keys) and version constraints (values) that can be replaced by this package.", + "description": "This is an object of package name (keys) and version constraints (values) that can be replaced by this package.", "additionalProperties": { "type": "string" } }, "conflict": { "type": "object", - "description": "This is a hash of package name (keys) and version constraints (values) that conflict with this package.", + "description": "This is an object of package name (keys) and version constraints (values) that conflict with this package.", "additionalProperties": { "type": "string" } }, "provide": { "type": "object", - "description": "This is a hash of package name (keys) and version constraints (values) that this package provides in addition to this package's name.", + "description": "This is an object of package name (keys) and version constraints (values) that this package provides in addition to this package's name.", "additionalProperties": { "type": "string" } }, "suggest": { "type": "object", - "description": "This is a hash of package name (keys) and descriptions (values) that this package suggests work well with it (this will be suggested to the user during installation).", + "description": "This is an object of package name (keys) and descriptions (values) that this package suggests work well with it (this will be suggested to the user during installation).", "additionalProperties": { "type": "string" } @@ -217,7 +217,7 @@ "properties": { "psr-0": { "type": "object", - "description": "This is a hash of namespaces (keys) and the directories they can be found into (values, can be arrays of paths) by the autoloader.", + "description": "This is an object of namespaces (keys) and the directories they can be found into (values, can be arrays of paths) by the autoloader.", "additionalProperties": { "type": ["string", "array"], "items": { @@ -227,7 +227,7 @@ }, "psr-4": { "type": "object", - "description": "This is a hash of namespaces (keys) and the PSR-4 directories they can map to (values, can be arrays of paths) by the autoloader.", + "description": "This is an object of namespaces (keys) and the PSR-4 directories they can map to (values, can be arrays of paths) by the autoloader.", "additionalProperties": { "type": ["string", "array"], "items": { @@ -283,11 +283,18 @@ "properties": { "platform": { "type": "object", - "description": "This is a hash of package name (keys) and version (values) that will be used to mock the platform packages on this machine.", + "description": "This is an object of package name (keys) and version (values) that will be used to mock the platform packages on this machine.", "additionalProperties": { "type": ["string", "boolean"] } }, + "allow-plugins": { + "type": ["object", "boolean"], + "description": "This is an object of {\"pattern\": true|false} with packages which are allowed to be loaded as plugins, or true to allow all, false to allow none. Defaults to {} which prompts when an unknown plugin is added.", + "additionalProperties": { + "type": ["boolean"] + } + }, "process-timeout": { "type": "integer", "description": "The timeout in seconds for process executions, defaults to 300 (5mins)." @@ -302,7 +309,10 @@ }, "preferred-install": { "type": ["string", "object"], - "description": "The install method Composer will prefer to use, defaults to auto and can be any of source, dist, auto, or a hash of {\"pattern\": \"preference\"}." + "description": "The install method Composer will prefer to use, defaults to auto and can be any of source, dist, auto, or an object of {\"pattern\": \"preference\"}.", + "additionalProperties": { + "type": ["string"] + } }, "notify-on-install": { "type": "boolean", @@ -317,21 +327,21 @@ }, "github-oauth": { "type": "object", - "description": "A hash of domain name => github API oauth tokens, typically {\"github.com\":\"\"}.", + "description": "An object of domain name => github API oauth tokens, typically {\"github.com\":\"\"}.", "additionalProperties": { "type": "string" } }, "gitlab-oauth": { "type": "object", - "description": "A hash of domain name => gitlab API oauth tokens, typically {\"gitlab.com\":\"\"}.", + "description": "An object of domain name => gitlab API oauth tokens, typically {\"gitlab.com\":\"\"}.", "additionalProperties": { "type": "string" } }, "gitlab-token": { "type": "object", - "description": "A hash of domain name => gitlab private tokens, typically {\"gitlab.com\":\"\"}.", + "description": "An object of domain name => gitlab private tokens, typically {\"gitlab.com\":\"\"}.", "additionalProperties": { "type": "string" } @@ -342,7 +352,7 @@ }, "bearer": { "type": "object", - "description": "A hash of domain name => bearer authentication token, for example {\"example.com\":\"\"}.", + "description": "An object of domain name => bearer authentication token, for example {\"example.com\":\"\"}.", "additionalProperties": { "type": "string" } @@ -372,7 +382,7 @@ }, "http-basic": { "type": "object", - "description": "A hash of domain name => {\"username\": \"...\", \"password\": \"...\"}.", + "description": "An object of domain name => {\"username\": \"...\", \"password\": \"...\"}.", "additionalProperties": { "type": "object", "required": ["username", "password"], @@ -631,7 +641,7 @@ "properties": { "psr-0": { "type": "object", - "description": "This is a hash of namespaces (keys) and the directories they can be found in (values, can be arrays of paths) by the autoloader.", + "description": "This is an object of namespaces (keys) and the directories they can be found in (values, can be arrays of paths) by the autoloader.", "additionalProperties": { "type": ["string", "array"], "items": { @@ -641,7 +651,7 @@ }, "psr-4": { "type": "object", - "description": "This is a hash of namespaces (keys) and the PSR-4 directories they can map to (values, can be arrays of paths) by the autoloader.", + "description": "This is an object of namespaces (keys) and the PSR-4 directories they can map to (values, can be arrays of paths) by the autoloader.", "additionalProperties": { "type": ["string", "array"], "items": { diff --git a/src/Composer/Command/ConfigCommand.php b/src/Composer/Command/ConfigCommand.php index 503accb14..e51ba1a7c 100644 --- a/src/Composer/Command/ConfigCommand.php +++ b/src/Composer/Command/ConfigCommand.php @@ -214,7 +214,7 @@ EOT protected function execute(InputInterface $input, OutputInterface $output) { // Open file in editor - if ($input->getOption('editor')) { + if (true === $input->getOption('editor')) { $editor = escapeshellcmd(Platform::getEnv('EDITOR')); if (!$editor) { if (Platform::isWindows()) { @@ -235,20 +235,20 @@ EOT return 0; } - if (!$input->getOption('global')) { + if (false === $input->getOption('global')) { $this->config->merge($this->configFile->read(), $this->configFile->getPath()); $this->config->merge(array('config' => $this->authConfigFile->exists() ? $this->authConfigFile->read() : array()), $this->authConfigFile->getPath()); } // List the configuration of the file settings - if ($input->getOption('list')) { + if (true === $input->getOption('list')) { $this->listConfiguration($this->config->all(), $this->config->raw(), $output, null, (bool) $input->getOption('source')); return 0; } $settingKey = $input->getArgument('setting-key'); - if (!$settingKey || !is_string($settingKey)) { + if (!is_string($settingKey)) { return 0; } @@ -446,6 +446,7 @@ EOT 'github-expose-hostname' => array($booleanValidator, $booleanNormalizer), 'htaccess-protect' => array($booleanValidator, $booleanNormalizer), 'lock' => array($booleanValidator, $booleanNormalizer), + 'allow-plugins' => array($booleanValidator, $booleanNormalizer), 'platform-check' => array( function ($val) { return in_array($val, array('php-only', 'true', 'false', '1', '0'), true); @@ -553,6 +554,28 @@ EOT return 0; } + // handle allow-plugins config setting elements true or false to add/remove + if (Preg::isMatch('{^allow-plugins\.([a-zA-Z0-9/*-]+)}', $settingKey, $matches)) { + if ($input->getOption('unset')) { + $this->configSource->removeConfigSetting($settingKey); + + return 0; + } + + if (true !== $booleanValidator($values[0])) { + throw new \RuntimeException(sprintf( + '"%s" is an invalid value', + $values[0] + )); + } + + $normalizedValue = $booleanNormalizer($values[0]); + + $this->configSource->addConfigSetting($settingKey, $normalizedValue); + + return 0; + } + // handle properties $uniqueProps = array( 'name' => array('is_string', function ($val) { diff --git a/src/Composer/Config.php b/src/Composer/Config.php index aa3fc3981..6b00ba46c 100644 --- a/src/Composer/Config.php +++ b/src/Composer/Config.php @@ -34,6 +34,7 @@ class Config public static $defaultConfig = array( 'process-timeout' => 300, 'use-include-path' => false, + 'allow-plugins' => null, // null for BC for now, will become array() after July 2022 'use-parent-dir' => 'prompt', 'preferred-install' => 'dist', 'notify-on-install' => true, @@ -117,6 +118,12 @@ class Config { // load defaults $this->config = static::$defaultConfig; + + // TODO after July 2022 remove this and update the default value above in self::$defaultConfig + remove note from 06-config.md + if (strtotime('2022-07-01') < time()) { + $this->config['allow-plugins'] = array(); + } + $this->repositories = static::$defaultRepositories; $this->useEnvironment = (bool) $useEnvironment; $this->baseDir = $baseDir; @@ -165,7 +172,7 @@ class Config /** * Merges new config values with the existing ones (overriding) * - * @param array $config + * @param array{config?: array, repositories?: array} $config * @param string $source * * @return void @@ -175,10 +182,15 @@ class Config // override defaults with given config if (!empty($config['config']) && is_array($config['config'])) { foreach ($config['config'] as $key => $val) { - if (in_array($key, array('bitbucket-oauth', 'github-oauth', 'gitlab-oauth', 'gitlab-token', 'http-basic', 'bearer')) && isset($this->config[$key])) { + if (in_array($key, array('bitbucket-oauth', 'github-oauth', 'gitlab-oauth', 'gitlab-token', 'http-basic', 'bearer'), true) && isset($this->config[$key])) { $this->config[$key] = array_merge($this->config[$key], $val); $this->setSourceOfConfigValue($val, $key, $source); - } elseif (in_array($key, array('gitlab-domains', 'github-domains')) && isset($this->config[$key])) { + } elseif (in_array($key, array('allow-plugins'), true) && isset($this->config[$key]) && is_array($this->config[$key])) { + // merging $val first to get the local config on top of the global one, then appending the global config, + // then merging local one again to make sure the values from local win over global ones for keys present in both + $this->config[$key] = array_merge($val, $this->config[$key], $val); + $this->setSourceOfConfigValue($val, $key, $source); + } elseif (in_array($key, array('gitlab-domains', 'github-domains'), true) && isset($this->config[$key])) { $this->config[$key] = array_unique(array_merge($this->config[$key], $val)); $this->setSourceOfConfigValue($val, $key, $source); } elseif ('preferred-install' === $key && isset($this->config[$key])) { diff --git a/src/Composer/Config/JsonConfigSource.php b/src/Composer/Config/JsonConfigSource.php index d2db24afd..b650309e4 100644 --- a/src/Composer/Config/JsonConfigSource.php +++ b/src/Composer/Config/JsonConfigSource.php @@ -287,7 +287,7 @@ class JsonConfigSource implements ConfigSourceInterface } catch (JsonValidationException $e) { // restore contents to the original state file_put_contents($this->file->getPath(), $contents); - throw new \RuntimeException('Failed to update composer.json with a valid format, reverting to the original content. Please report an issue to us with details (command you run and a copy of your composer.json).', 0, $e); + throw new \RuntimeException('Failed to update composer.json with a valid format, reverting to the original content. Please report an issue to us with details (command you run and a copy of your composer.json). '.PHP_EOL.implode(PHP_EOL, $e->getErrors()), 0, $e); } if ($newFile) { diff --git a/src/Composer/Plugin/PluginManager.php b/src/Composer/Plugin/PluginManager.php index d1a9e79fe..01e708e57 100644 --- a/src/Composer/Plugin/PluginManager.php +++ b/src/Composer/Plugin/PluginManager.php @@ -15,6 +15,7 @@ namespace Composer\Plugin; use Composer\Composer; use Composer\EventDispatcher\EventSubscriberInterface; use Composer\IO\IOInterface; +use Composer\Package\BasePackage; use Composer\Package\CompletePackage; use Composer\Package\Package; use Composer\Package\Version\VersionParser; @@ -52,6 +53,16 @@ class PluginManager /** @var array */ protected $registeredPlugins = array(); + /** + * @var array|null + */ + private $allowPluginRules; + + /** + * @var array|null + */ + private $allowGlobalPluginRules; + /** @var int */ private static $classCounter = 0; @@ -70,6 +81,9 @@ class PluginManager $this->globalComposer = $globalComposer; $this->versionParser = new VersionParser(); $this->disablePlugins = $disablePlugins; + + $this->allowPluginRules = $this->parseAllowedPlugins($composer->getConfig()->get('allow-plugins')); + $this->allowGlobalPluginRules = $this->parseAllowedPlugins($globalComposer !== null ? $globalComposer->getConfig()->get('allow-plugins') : false); } /** @@ -84,7 +98,7 @@ class PluginManager } $repo = $this->composer->getRepositoryManager()->getLocalRepository(); - $globalRepo = $this->globalComposer ? $this->globalComposer->getRepositoryManager()->getLocalRepository() : null; + $globalRepo = $this->globalComposer !== null ? $this->globalComposer->getRepositoryManager()->getLocalRepository() : null; $this->loadRepository($repo, false); if ($globalRepo) { $this->loadRepository($globalRepo, true); @@ -150,6 +164,11 @@ class PluginManager return; } + if (!$this->isPluginAllowed($package->getName(), $isGlobalPlugin)) { + $this->io->writeError('Skipped loading "'.$package->getName() . '" '.($isGlobalPlugin ? '(installed globally) ' : '').'as it is not in config.allow-plugins', true, IOInterface::DEBUG); + return; + } + if ($package->getType() === 'composer-plugin') { $requiresComposer = null; foreach ($package->getRequires() as $link) { /** @var Link $link */ @@ -194,7 +213,7 @@ class PluginManager $classes = is_array($extra['class']) ? $extra['class'] : array($extra['class']); $localRepo = $this->composer->getRepositoryManager()->getLocalRepository(); - $globalRepo = $this->globalComposer ? $this->globalComposer->getRepositoryManager()->getLocalRepository() : null; + $globalRepo = $this->globalComposer !== null ? $this->globalComposer->getRepositoryManager()->getLocalRepository() : null; $rootPackage = clone $this->composer->getPackage(); $rootPackageRepo = new RootPackageRepository($rootPackage); @@ -357,6 +376,13 @@ class PluginManager */ public function addPlugin(PluginInterface $plugin, $isGlobalPlugin = false, PackageInterface $sourcePackage = null) { + if ($sourcePackage === null) { + trigger_error('Calling PluginManager::addPlugin without $sourcePackage is deprecated, if you are using this please get in touch with us to explain the use case', E_USER_DEPRECATED); + } elseif (!$this->isPluginAllowed($sourcePackage->getName(), $isGlobalPlugin)) { + $this->io->writeError('Skipped loading "'.get_class($plugin).' from '.$sourcePackage->getName() . '" '.($isGlobalPlugin ? '(installed globally) ' : '').' as it is not in config.allow-plugins', true, IOInterface::DEBUG); + return; + } + $details = array(); if ($sourcePackage) { $details[] = 'from '.$sourcePackage->getName(); @@ -597,4 +623,109 @@ class PluginManager return $capabilities; } + + /** + * @param array|bool|null $allowPluginsConfig + * @return array|null + */ + private function parseAllowedPlugins($allowPluginsConfig) + { + if (null === $allowPluginsConfig) { + return null; + } + + if (true === $allowPluginsConfig) { + return array('{}' => true); + } + + if (false === $allowPluginsConfig) { + return array('{^$}D' => false); + } + + $rules = array(); + foreach ($allowPluginsConfig as $pattern => $allow) { + $rules[BasePackage::packageNameToRegexp($pattern)] = $allow; + } + + return $rules; + } + + /** + * @param string $package + * @param bool $isGlobalPlugin + * @return bool + */ + private function isPluginAllowed($package, $isGlobalPlugin) + { + static $warned = array(); + $rules = $isGlobalPlugin ? $this->allowGlobalPluginRules : $this->allowPluginRules; + + if ($rules === null) { + if (!$this->io->isInteractive()) { + if (!isset($warned['all'])) { + $this->io->writeError('For additional security you should declare the allow-plugins config with a list of packages names that are allowed to run code. See https://getcomposer.org/allow-plugins'); + $this->io->writeError('You have until July 2022 to add the setting. Composer will then switch the default behavior to disallow all plugins.'); + $warned['all'] = true; + } + + // if no config is defined we allow all plugins for BC + return true; + } + + // keep going and prompt the user + $rules = array(); + } + + foreach ($rules as $pattern => $allow) { + if (Preg::isMatch($pattern, $package)) { + return $allow === true; + } + } + + if (!isset($warned[$package])) { + if ($this->io->isInteractive()) { + $composer = $isGlobalPlugin && $this->globalComposer !== null ? $this->globalComposer : $this->composer; + + $this->io->writeError(''.$package.($isGlobalPlugin ? ' (installed globally)' : '').' contains a Composer plugin which is currently not in your allow-plugins config. See https://getcomposer.org/allow-plugins'); + while (true) { + switch ($answer = $this->io->ask('Do you trust "'.$package.'" to execute code and wish to enable it now? (writes "allow-plugins" to composer.json) [y,n,d,?] ', '?')) { + case 'y': + case 'n': + case 'd': + $allow = $answer === 'y'; + + // persist answer in current rules to avoid prompting again if the package gets reloaded + if ($isGlobalPlugin) { + $this->allowGlobalPluginRules[BasePackage::packageNameToRegexp($package)] = $allow; + } else { + $this->allowPluginRules[BasePackage::packageNameToRegexp($package)] = $allow; + } + + // persist answer in composer.json if it wasn't simply discarded + if ($answer === 'y' || $answer === 'n') { + $composer->getConfig()->getConfigSource()->addConfigSetting('allow-plugins.'.$package, $allow); + } + + return $allow; + + case '?': + default: + $this->io->writeError(array( + 'y - add package to allow-plugins in composer.json and let it run immediately', + 'n - add package (as disallowed) to allow-plugins in composer.json to suppress further prompts', + 'd - discard this, do not change composer.json and do not allow the plugin to run', + '? - print help' + )); + break; + } + } + } else { + $this->io->writeError(''.$package.($isGlobalPlugin ? ' (installed globally)' : '').' contains a Composer plugin which is blocked by your allow-plugins config. You may add it to the list if you consider it safe. See https://getcomposer.org/allow-plugins'); + $this->io->writeError('You can run "composer '.($isGlobalPlugin ? 'global ' : '').'config --no-plugins allow-plugins.'.$package.' [true|false]" to enable it (true) or keep it disabled and suppress this warning (false)'); + } + $warned[$package] = true; + } + + return false; + } } diff --git a/tests/Composer/Test/Fixtures/functional/installed-versions/composer.json b/tests/Composer/Test/Fixtures/functional/installed-versions/composer.json index 2b6ea3312..82f69f905 100644 --- a/tests/Composer/Test/Fixtures/functional/installed-versions/composer.json +++ b/tests/Composer/Test/Fixtures/functional/installed-versions/composer.json @@ -17,5 +17,8 @@ }, "autoload": { "classmap": ["Hooks.php"] + }, + "config": { + "allow-plugins": true } } diff --git a/tests/Composer/Test/Fixtures/functional/installed-versions2/composer.json b/tests/Composer/Test/Fixtures/functional/installed-versions2/composer.json index 59ef7cad3..91752170b 100644 --- a/tests/Composer/Test/Fixtures/functional/installed-versions2/composer.json +++ b/tests/Composer/Test/Fixtures/functional/installed-versions2/composer.json @@ -17,5 +17,8 @@ }, "autoload": { "classmap": ["Hooks.php"] + }, + "config": { + "allow-plugins": true } } diff --git a/tests/Composer/Test/Plugin/PluginInstallerTest.php b/tests/Composer/Test/Plugin/PluginInstallerTest.php index 07468f4f0..6466571d7 100644 --- a/tests/Composer/Test/Plugin/PluginInstallerTest.php +++ b/tests/Composer/Test/Plugin/PluginInstallerTest.php @@ -114,16 +114,17 @@ class PluginInstallerTest extends TestCase $this->composer->setEventDispatcher(new EventDispatcher($this->composer, $this->io)); $this->composer->setPackage(new RootPackage('dummy/root', '1.0.0.0', '1.0.0')); - $this->pm = new PluginManager($this->io, $this->composer); - $this->composer->setPluginManager($this->pm); - $config->merge(array( 'config' => array( 'vendor-dir' => $this->directory.'/Fixtures/', 'home' => $this->directory.'/Fixtures', 'bin-dir' => $this->directory.'/Fixtures/bin', + 'allow-plugins' => true, ), )); + + $this->pm = new PluginManager($this->io, $this->composer); + $this->composer->setPluginManager($this->pm); } protected function tearDown() @@ -145,7 +146,10 @@ class PluginInstallerTest extends TestCase $plugins = $this->pm->getPlugins(); $this->assertEquals('installer-v1', $plugins[0]->version); // @phpstan-ignore-line - $this->assertEquals('activate v1'.PHP_EOL, $this->io->getOutput()); + $this->assertEquals( + 'activate v1'.PHP_EOL, + $this->io->getOutput() + ); } public function testInstallMultiplePlugins()