ComposerPluginsValidator.php

Namespace

Drupal\package_manager\Validator

File

core/modules/package_manager/src/Validator/ComposerPluginsValidator.php

View source
<?php

declare (strict_types=1);
namespace Drupal\package_manager\Validator;

use Composer\Semver\Semver;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\ComposerInspector;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\PreOperationStageEvent;
use Drupal\package_manager\Event\StatusCheckEvent;
use Drupal\package_manager\PathLocator;
use PhpTuf\ComposerStager\API\Exception\RuntimeException;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
 * Validates the allowed Composer plugins, both in active and stage.
 *
 * Composer plugins can make far-reaching changes on the filesystem. That is why
 * they can cause Package Manager (more specifically the infrastructure it uses:
 * php-tuf/composer-stager) to not work reliably; potentially even break a site!
 *
 * This validator restricts the use of Composer plugins:
 * - Allowing all plugins to run indiscriminately is discouraged by Composer,
 *   but disallowed by this module (it is too risky):
 *   `config.allowed-plugins = true` is forbidden.
 * - Installed Composer plugins that are not allowed (in composer.json's
 *   `config.allowed-plugins ) are not executed by Composer, so
 *   these are safe.
 * - Installed Composer plugins that are allowed need to be either explicitly
 *   supported by this validator (they may still need their own validation to
 *   ensure their configuration is safe, for example Drupal core's vendor
 *   hardening plugin), or explicitly trusted by adding it to the
 *   `package_manager.settings` configuration's
 *   `additional_trusted_composer_plugins` list.
 *
 * @todo Determine how other Composer plugins will be supported in
 *    https://drupal.org/i/3339417.
 *
 * @see https://getcomposer.org/doc/04-schema.md#type
 * @see https://getcomposer.org/doc/articles/plugins.md
 *
 * @internal
 *   This is an internal part of Package Manager and may be changed or removed
 *   at any time without warning. External code should not interact with this
 *   class.
 */
final class ComposerPluginsValidator implements EventSubscriberInterface {
    use StringTranslationTrait;
    
    /**
     * Composer plugins known to modify other packages, but are validated.
     *
     * The validation guarantees they are safe to use.
     *
     * @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_MODIFY = [
        // @see \Drupal\package_manager\Validator\ComposerPatchesValidator
'cweagans/composer-patches' => '^1.7.3 || ^2',
        // @see \Drupal\package_manager\PathExcluder\VendorHardeningExcluder
'drupal/core-vendor-hardening' => '*',
        'php-http/discovery' => '*',
    ];
    
    /**
     * Composer plugins known to NOT modify other packages.
     *
     * @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 = [
        'composer/installers' => '^2.0',
        'dealerdirect/phpcodesniffer-composer-installer' => '^0.7.1 || ^1.0.0',
        'drupal/core-composer-scaffold' => '*',
        'drupal/core-project-message' => '*',
        'phpstan/extension-installer' => '^1.1',
        PhpTufValidator::PLUGIN_NAME => '^1',
    ];
    
    /**
     * The additional trusted Composer plugin package names.
     *
     * The package names are normalized.
     *
     * @var string[]
     *   Keys are package names, values are version constraints.
     */
    private array $additionalTrustedComposerPlugins;
    public function __construct(ConfigFactoryInterface $config_factory, ComposerInspector $inspector, PathLocator $pathLocator) {
        $settings = $config_factory->get('package_manager.settings');
        $this->additionalTrustedComposerPlugins = array_fill_keys(array_map([
            __CLASS__,
            'normalizePackageName',
        ], $settings->get('additional_trusted_composer_plugins')), '*');
    }
    
    /**
     * Normalizes a package name.
     *
     * @param string $package_name
     *   A package name.
     *
     * @return string
     *   The normalized package name.
     */
    private static function normalizePackageName(string $package_name) : string {
        return strtolower($package_name);
    }
    
    /**
     * Validates the allowed Composer plugins, both in active and stage.
     */
    public function validate(PreOperationStageEvent $event) : void {
        $stage = $event->stage;
        // When about to copy the changes from the stage directory to the active
        // directory, use the stage directory's composer instead of the active.
        // Because composer plugins may be added or removed; the only thing that
        // matters is the set of composer plugins that *will* apply — if a composer
        // plugin is being removed, that's fine.
        $dir = $event instanceof PreApplyEvent ? $stage->getStageDirectory() : $this->pathLocator
            ->getProjectRoot();
        try {
            $allowed_plugins = $this->inspector
                ->getAllowPluginsConfig($dir);
        } catch (RuntimeException $exception) {
            $event->addErrorFromThrowable($exception, $this->t('Unable to determine Composer <code>allow-plugins</code> setting.'));
            return;
        }
        if ($allowed_plugins === TRUE) {
            $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;
        }
        // TRICKY: additional trusted Composer plugins is listed first, to allow
        // site owners who know what they're doing to use unsupported versions of
        // supported Composer plugins.
        $trusted_plugins = $this->additionalTrustedComposerPlugins + self::SUPPORTED_PLUGINS_THAT_DO_MODIFY + 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), $allowed_plugins);
        $installed_packages = $this->inspector
            ->getInstalledPackagesList($dir);
        // Determine which plugins are both trusted by us, AND allowed by Composer's
        // configuration.
        $supported_plugins = array_intersect_key($allowed_plugins, $trusted_plugins);
        // Create an array whose keys are the names of those plugins, and the values
        // are their installed versions.
        $supported_plugins_installed_versions = array_combine($supported_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(count($messages), 'An unsupported Composer plugin was detected.', 'Unsupported Composer plugins were detected.');
            $event->addError($messages, $summary);
        }
    }
    
    /**
     * {@inheritdoc}
     */
    public static function getSubscribedEvents() : array {
        return [
            PreCreateEvent::class => 'validate',
            PreApplyEvent::class => 'validate',
            StatusCheckEvent::class => 'validate',
        ];
    }

}

Classes

Title Deprecated Summary
ComposerPluginsValidator Validates the allowed Composer plugins, both in active and stage.

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.