Skip to content
Snippets Groups Projects
Commit 28e682cf authored by Wim Leers's avatar Wim Leers Committed by Adam G-H
Browse files

Issue #3340022 by Wim Leers, phenaproxima: Tighten ComposerPluginsValidator:...

Issue #3340022 by Wim Leers, phenaproxima: Tighten ComposerPluginsValidator: support only specified version constraint
parent 721822a4
No related branches found
No related tags found
No related merge requests found
...@@ -50,8 +50,12 @@ final class InstalledPackagesList extends \ArrayObject { ...@@ -50,8 +50,12 @@ final class InstalledPackagesList extends \ArrayObject {
* {@inheritdoc} * {@inheritdoc}
*/ */
public function offsetGet(mixed $key): ?InstalledPackage { public function offsetGet(mixed $key): ?InstalledPackage {
// Overridden to provide a clearer return type hint. // Overridden to provide a clearer return type hint and compatibility with
return parent::offsetGet($key); // the null-safe operator.
if ($this->offsetExists($key)) {
return parent::offsetGet($key);
}
return NULL;
} }
/** /**
......
...@@ -4,7 +4,7 @@ declare(strict_types = 1); ...@@ -4,7 +4,7 @@ declare(strict_types = 1);
namespace Drupal\package_manager\Validator; namespace Drupal\package_manager\Validator;
use Drupal\Component\Render\FormattableMarkup; use Composer\Semver\Semver;
use Drupal\Component\Serialization\Json; use Drupal\Component\Serialization\Json;
use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\StringTranslation\StringTranslationTrait;
...@@ -13,29 +13,28 @@ use Drupal\package_manager\Event\PreApplyEvent; ...@@ -13,29 +13,28 @@ use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent; use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\PreOperationStageEvent; use Drupal\package_manager\Event\PreOperationStageEvent;
use Drupal\package_manager\Event\StatusCheckEvent; use Drupal\package_manager\Event\StatusCheckEvent;
use Drupal\package_manager\PathExcluder\VendorHardeningExcluder;
use Drupal\package_manager\PathLocator; use Drupal\package_manager\PathLocator;
use PhpTuf\ComposerStager\Domain\Exception\RuntimeException; use PhpTuf\ComposerStager\Domain\Exception\RuntimeException;
use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/** /**
* Validates the allowed composer plugins, both in active and stage. * Validates the allowed Composer plugins, both in active and stage.
* *
* Composer plugins can make far-reaching changes on the filesystem. That is why * Composer plugins can make far-reaching changes on the filesystem. That is why
* they can cause Package Manager (more specifically the infrastructure it uses: * they can cause Package Manager (more specifically the infrastructure it uses:
* php-tuf/composer-stager) to not work reliably; potentially even break a site! * php-tuf/composer-stager) to not work reliably; potentially even break a site!
* *
* This validator restricts the use of composer plugins: * This validator restricts the use of Composer plugins:
* - using arbitrary composer plugins is discouraged by composer, but disallowed * - Allowing all plugins to run indiscriminately is discouraged by Composer,
* by this module (it is too risky): * but disallowed by this module (it is too risky):
* @code config.allowed-plugins = true @endcode is forbidden. * @code config.allowed-plugins = true @endcode is forbidden.
* - installed composer plugins that are not allowed (in composer.json's * - Installed Composer plugins that are not allowed (in composer.json's
* @code config.allowed-plugins @endcode) are not executed by composer, so * @code config.allowed-plugins @endcode) are not executed by Composer, so
* these are safe * these are safe.
* - installed composer plugins that are allowed need to be either explicitly * - Installed Composer plugins that are allowed need to be either explicitly
* supported by this validator (and they may need their own validation to * supported by this validator (they may still need their own validation to
* ensure their configuration is safe, for example Drupal core's vendor * ensure their configuration is safe, for example Drupal core's vendor
* hardening plugin), or explicitly trusted, by adding it to the * hardening plugin), or explicitly trusted by adding it to the
* @code package_manager.settings @endcode configuration's * @code package_manager.settings @endcode configuration's
* @code additional_trusted_composer_plugins @endcode list. * @code additional_trusted_composer_plugins @endcode list.
* *
...@@ -55,48 +54,49 @@ final class ComposerPluginsValidator implements EventSubscriberInterface { ...@@ -55,48 +54,49 @@ final class ComposerPluginsValidator implements EventSubscriberInterface {
use StringTranslationTrait; use StringTranslationTrait;
/** /**
* Composer plugins known to modify other packages, but that are validated. * Composer plugins known to modify other packages, but are validated.
* *
* (The validation guarantees they are safe to use.) * (The validation guarantees they are safe to use.)
* *
* Keys are composer plugin package names, values are associated validators or * @var string[]
* excluders necessary to make those composer plugins work reliably with the * Keys are Composer plugin package names, values are version constraints
* Package Manager module. * for those plugins that this validator explicitly supports.
*
* @var string[][]
*/ */
private const SUPPORTED_PLUGINS_THAT_DO_MODIFY = [ private const SUPPORTED_PLUGINS_THAT_DO_MODIFY = [
// cSpell:disable // cSpell:disable
'cweagans/composer-patches' => ComposerPatchesValidator::class, // @see \Drupal\package_manager\Validator\ComposerPatchesValidator
'drupal/core-vendor-hardening' => VendorHardeningExcluder::class, 'cweagans/composer-patches' => '^1.7.3',
// @see \Drupal\package_manager\PathExcluder\VendorHardeningExcluder
'drupal/core-vendor-hardening' => '*',
// cSpell:enable // cSpell:enable
]; ];
/** /**
* The composer plugins are known not to modify other packages. * Composer plugins known to NOT modify other packages.
* *
* @var string[] * @var string[]
* Keys are Composer plugin package names, values are version constraints
* for those plugins that this validator explicitly supports.
*/ */
private const SUPPORTED_PLUGINS_THAT_DO_NOT_MODIFY = [ private const SUPPORTED_PLUGINS_THAT_DO_NOT_MODIFY = [
// cSpell:disable // cSpell:disable
'composer/installers', 'composer/installers' => '^2.0',
'dealerdirect/phpcodesniffer-composer-installer', 'dealerdirect/phpcodesniffer-composer-installer' => '^0.7.1 || ^1.0.0',
'drupal/core-composer-scaffold', 'drupal/core-composer-scaffold' => '*',
'drupal/core-project-message', 'drupal/core-project-message' => '*',
'phpstan/extension-installer', 'phpstan/extension-installer' => '^1.1',
// cSpell:enable // cSpell:enable
]; ];
/** /**
* The additional trusted composer plugin package names. * The additional trusted Composer plugin package names.
* *
* Note: these are normalized package names. * Note: these are normalized package names.
* *
* @var string[] * @var string[]
* @see \Composer\Package\PackageInterface::getName() * Keys are package names, values are version constraints.
* @see \Composer\Package\PackageInterface::getPrettyName()
*/ */
protected array $additionalTrustedComposerPlugins; private array $additionalTrustedComposerPlugins;
/** /**
* Constructs a new ComposerPluginsValidator. * Constructs a new ComposerPluginsValidator.
...@@ -114,9 +114,14 @@ final class ComposerPluginsValidator implements EventSubscriberInterface { ...@@ -114,9 +114,14 @@ final class ComposerPluginsValidator implements EventSubscriberInterface {
protected PathLocator $pathLocator, protected PathLocator $pathLocator,
) { ) {
$settings = $config_factory->get('package_manager.settings'); $settings = $config_factory->get('package_manager.settings');
$this->additionalTrustedComposerPlugins = array_map( $this->additionalTrustedComposerPlugins = array_fill_keys(
[__CLASS__, 'normalizePackageName'], array_map(
$settings->get('additional_trusted_composer_plugins') [__CLASS__, 'normalizePackageName'],
$settings->get('additional_trusted_composer_plugins')
),
// For now, additional_trusted_composer_plugins cannot specify a version
// constraint.
'*'
); );
} }
...@@ -133,17 +138,6 @@ final class ComposerPluginsValidator implements EventSubscriberInterface { ...@@ -133,17 +138,6 @@ final class ComposerPluginsValidator implements EventSubscriberInterface {
return strtolower($package_name); return strtolower($package_name);
} }
/**
* @return string[]
*/
private function getSupportedPlugins(): array {
return array_merge(
array_keys(self::SUPPORTED_PLUGINS_THAT_DO_MODIFY),
self::SUPPORTED_PLUGINS_THAT_DO_NOT_MODIFY,
$this->additionalTrustedComposerPlugins,
);
}
/** /**
* Validates the allowed Composer plugins, both in active and stage. * Validates the allowed Composer plugins, both in active and stage.
*/ */
...@@ -160,44 +154,76 @@ final class ComposerPluginsValidator implements EventSubscriberInterface { ...@@ -160,44 +154,76 @@ final class ComposerPluginsValidator implements EventSubscriberInterface {
: $this->pathLocator->getProjectRoot(); : $this->pathLocator->getProjectRoot();
try { try {
// @see https://getcomposer.org/doc/06-config.md#allow-plugins // @see https://getcomposer.org/doc/06-config.md#allow-plugins
$value = Json::decode($this->inspector->getConfig('allow-plugins', $dir)); $allowed_plugins = Json::decode($this->inspector->getConfig('allow-plugins', $dir));
} }
catch (RuntimeException $exception) { catch (RuntimeException $exception) {
$event->addErrorFromThrowable($exception, $this->t('Unable to determine Composer <code>allow-plugins</code> setting.')); $event->addErrorFromThrowable($exception, $this->t('Unable to determine Composer <code>allow-plugins</code> setting.'));
return; return;
} }
if ($value === 1) { if ($allowed_plugins === 1) {
$event->addError([$this->t('All composer plugins are allowed because <code>config.allow-plugins</code> is configured to <code>true</code>. This is an unacceptable security risk.')]); $event->addError([$this->t('All composer plugins are allowed because <code>config.allow-plugins</code> is configured to <code>true</code>. This is an unacceptable security risk.')]);
return; return;
} }
// Only packages with `true` as a value are actually executed by composer. // TRICKY: additional trusted Composer plugins is listed first, to allow
assert(is_array($value)); // site owners who know what they're doing to use unsupported versions of
$allowed_plugins = array_keys(array_filter($value)); // supported Composer plugins.
// Normalized allowed plugins: keys are normalized package names, values are $trusted_plugins = $this->additionalTrustedComposerPlugins
// the original package names. + self::SUPPORTED_PLUGINS_THAT_DO_MODIFY
$normalized_allowed_plugins = array_combine( + self::SUPPORTED_PLUGINS_THAT_DO_NOT_MODIFY;
assert(is_array($allowed_plugins));
// Only packages with `true` as a value are actually executed by Composer.
$allowed_plugins = array_keys(array_filter($allowed_plugins));
// The keys are normalized package names, and the values are the original,
// non-normalized package names.
$allowed_plugins = array_combine(
array_map([__CLASS__, 'normalizePackageName'], $allowed_plugins), array_map([__CLASS__, 'normalizePackageName'], $allowed_plugins),
$allowed_plugins $allowed_plugins
); );
$unsupported_plugins = array_diff_key($normalized_allowed_plugins, array_flip($this->getSupportedPlugins()));
if ($unsupported_plugins) { $installed_packages = $this->inspector->getInstalledPackagesList($dir);
$unsupported_plugins_messages = array_map( // Determine which plugins are both trusted by us, AND allowed by Composer's
fn (string $raw_allowed_plugin_name) => new FormattableMarkup( // configuration.
"<code>@package_name</code>", $supported_plugins = array_intersect_key($allowed_plugins, $trusted_plugins);
[ // Create an array whose keys are the names of those plugins, and the values
'@package_name' => $raw_allowed_plugin_name, // are their installed versions.
] $supported_plugins_installed_versions = array_combine(
), $supported_plugins,
$unsupported_plugins array_map(
); fn (string $name): ?string => $installed_packages[$name]?->version,
$supported_plugins
)
);
// Find the plugins whose installed versions aren't in the supported range.
$unsupported_installed_versions = array_filter(
$supported_plugins_installed_versions,
fn (?string $version, string $name): bool => $version && !Semver::satisfies($version, $trusted_plugins[$name]),
ARRAY_FILTER_USE_BOTH
);
$untrusted_plugins = array_diff_key($allowed_plugins, $trusted_plugins);
$messages = array_map(
fn (string $raw_name) => $this->t('<code>@name</code>', ['@name' => $raw_name]),
$untrusted_plugins
);
foreach ($unsupported_installed_versions as $name => $installed_version) {
$messages[] = $this->t("<code>@name</code> is supported, but only version <code>@supported_version</code>, found <code>@installed_version</code>.", [
'@name' => $name,
'@supported_version' => $trusted_plugins[$name],
'@installed_version' => $installed_version,
]);
}
if ($messages) {
$summary = $this->formatPlural( $summary = $this->formatPlural(
count($unsupported_plugins), count($messages),
'An unsupported Composer plugin was detected.', 'An unsupported Composer plugin was detected.',
'Unsupported Composer plugins were detected.', 'Unsupported Composer plugins were detected.',
); );
$event->addError($unsupported_plugins_messages, $summary); $event->addError($messages, $summary);
} }
} }
......
...@@ -50,7 +50,7 @@ ...@@ -50,7 +50,7 @@
}, },
"cweagans/composer-patches": { "cweagans/composer-patches": {
"type": "path", "type": "path",
"version": "24.12.1999", "version": "1.7.333",
"url": "../path_repos/cweagans--composer-patches", "url": "../path_repos/cweagans--composer-patches",
"options": { "options": {
"symlink": false "symlink": false
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "681e4c106deb0665ca393b0fc797b685", "content-hash": "672a2db4be3ba5e4f025ddea6b7dacd7",
"packages": [ "packages": [
{ {
"name": "drupal/core", "name": "drupal/core",
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
"name": "cweagans/composer-patches", "name": "cweagans/composer-patches",
"description": "A fake version of cweagans/composer-patches", "description": "A fake version of cweagans/composer-patches",
"type": "composer-plugin", "type": "composer-plugin",
"version": "24.12.1999", "version": "1.7.333",
"extra": { "extra": {
"class": "\\cweagans\\Fake\\ComposerPatches" "class": "\\cweagans\\Fake\\ComposerPatches"
}, },
......
...@@ -219,7 +219,7 @@ class ComposerPatchesValidatorTest extends PackageManagerKernelTestBase { ...@@ -219,7 +219,7 @@ class ComposerPatchesValidatorTest extends PackageManagerKernelTestBase {
]); ]);
} }
if ($in_stage & static::REQUIRE_PACKAGE_FROM_ROOT && !($in_active & static::REQUIRE_PACKAGE_FROM_ROOT)) { if ($in_stage & static::REQUIRE_PACKAGE_FROM_ROOT && !($in_active & static::REQUIRE_PACKAGE_FROM_ROOT)) {
$stage_manipulator->requirePackage('cweagans/composer-patches', '24.12.1999'); $stage_manipulator->requirePackage('cweagans/composer-patches', '1.7.333');
} }
if (!($in_stage & static::REQUIRE_PACKAGE_FROM_ROOT) && $in_active & static::REQUIRE_PACKAGE_FROM_ROOT) { if (!($in_stage & static::REQUIRE_PACKAGE_FROM_ROOT) && $in_active & static::REQUIRE_PACKAGE_FROM_ROOT) {
$stage_manipulator $stage_manipulator
......
...@@ -204,6 +204,22 @@ class ComposerPluginsValidatorTest extends PackageManagerKernelTestBase { ...@@ -204,6 +204,22 @@ class ComposerPluginsValidatorTest extends PackageManagerKernelTestBase {
[], [],
]; ];
yield 'a supported composer plugin for which any version is supported: party like it is Drupal 99!' => [
[
'allow-plugins.drupal/core-composer-scaffold' => TRUE,
],
[
[
'name' => 'drupal/core-composer-scaffold',
'version' => '99.0.0',
'type' => 'composer-plugin',
'require' => ['composer-plugin-api' => '*'],
'extra' => ['class' => 'AnyClass'],
],
],
[],
];
yield 'one UNsupported but disallowed plugin — pretty package name' => [ yield 'one UNsupported but disallowed plugin — pretty package name' => [
[ [
'allow-plugins.composer/plugin-a' => FALSE, 'allow-plugins.composer/plugin-a' => FALSE,
...@@ -308,6 +324,52 @@ class ComposerPluginsValidatorTest extends PackageManagerKernelTestBase { ...@@ -308,6 +324,52 @@ class ComposerPluginsValidatorTest extends PackageManagerKernelTestBase {
), ),
], ],
]; ];
yield 'one supported composer plugin but incompatible version — newer version' => [
[
'allow-plugins.phpstan/extension-installer' => TRUE,
],
[
[
'name' => 'phpstan/extension-installer',
'version' => '20.1',
'type' => 'composer-plugin',
'require' => ['composer-plugin-api' => '*'],
'extra' => ['class' => 'AnyClass'],
],
],
[
ValidationResult::createError(
[
new TranslatableMarkup('<code>phpstan/extension-installer</code> is supported, but only version <code>^1.1</code>, found <code>20.1</code>.'),
],
new TranslatableMarkup('An unsupported Composer plugin was detected.'),
),
],
];
yield 'one supported composer plugin but incompatible version — older version' => [
[
'allow-plugins.dealerdirect/phpcodesniffer-composer-installer' => TRUE,
],
[
[
'name' => 'dealerdirect/phpcodesniffer-composer-installer',
'version' => '0.6.1',
'type' => 'composer-plugin',
'require' => ['composer-plugin-api' => '*'],
'extra' => ['class' => 'AnyClass'],
],
],
[
ValidationResult::createError(
[
new TranslatableMarkup('<code>dealerdirect/phpcodesniffer-composer-installer</code> is supported, but only version <code>^0.7.1 || ^1.0.0</code>, found <code>0.6.1</code>.'),
],
new TranslatableMarkup('An unsupported Composer plugin was detected.'),
),
],
];
} }
/** /**
...@@ -335,6 +397,8 @@ class ComposerPluginsValidatorTest extends PackageManagerKernelTestBase { ...@@ -335,6 +397,8 @@ class ComposerPluginsValidatorTest extends PackageManagerKernelTestBase {
[ [
new TranslatableMarkup('<code>not-cweagans/not-composer-patches</code>'), new TranslatableMarkup('<code>not-cweagans/not-composer-patches</code>'),
new TranslatableMarkup('<code>also-not-cweagans/also-not-composer-patches</code>'), new TranslatableMarkup('<code>also-not-cweagans/also-not-composer-patches</code>'),
new TranslatableMarkup('<code>phpstan/extension-installer</code> is supported, but only version <code>^1.1</code>, found <code>20.1</code>.'),
new TranslatableMarkup('<code>dealerdirect/phpcodesniffer-composer-installer</code> is supported, but only version <code>^0.7.1 || ^1.0.0</code>, found <code>0.6.1</code>.'),
], ],
new TranslatableMarkup('Unsupported Composer plugins were detected.'), new TranslatableMarkup('Unsupported Composer plugins were detected.'),
), ),
......
...@@ -30,6 +30,13 @@ trait ComposerInstallersTrait { ...@@ -30,6 +30,13 @@ trait ComposerInstallersTrait {
'url' => $package_path, 'url' => $package_path,
'options' => [ 'options' => [
'symlink' => FALSE, 'symlink' => FALSE,
'versions' => [
// Explicitly state the version contained by this path repository,
// otherwise Composer will infer the version based on the git clone or
// fall back to `dev-master`.
// @see https://getcomposer.org/doc/05-repositories.md#path
'composer/installers' => $package_list['composer/installers']->version,
],
], ],
], JSON_UNESCAPED_SLASHES); ], JSON_UNESCAPED_SLASHES);
$working_dir_option = "--working-dir=$dir"; $working_dir_option = "--working-dir=$dir";
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment