Skip to content
Snippets Groups Projects
Commit 34a00b34 authored by Adam G-H's avatar Adam G-H
Browse files

Issue #3316617 by phenaproxima, tedbow, Wim Leers: Add a validator to check...

Issue #3316617 by phenaproxima, tedbow, Wim Leers: Add a validator to check that PHP-TUF's Composer integration is present and configured correctly
parent 119836ad
No related branches found
No related tags found
No related merge requests found
......@@ -68,6 +68,23 @@ function package_manager_help($route_name, RouteMatchInterface $route_match) {
$output .= ' </li>';
$output .= '</ul>';
$output .= '<h4 id="package-manager-tuf-info">' . t('Enabling PHP-TUF protection') . '</h4>';
$output .= '<p>' . t('Package Manager requires <a href=":php-tuf">PHP-TUF</a>, which implements <a href=":tuf">The Update Framework</a> as a way to help secure Composer package downloads via the <a href=":php-tuf-plugin">PHP-TUF Composer integration plugin</a>. This plugin must be installed and configured properly in order to use Package Manager.', [
':php-tuf' => 'https://github.com/php-tuf/php-tuf',
':tuf' => 'https://theupdateframework.io/',
':php-tuf-plugin' => 'https://github.com/php-tuf/composer-integration',
]) . '</p>';
$output .= '<p>' . t('To install and configure the plugin as needed, you can run the following commands:') . '</p>';
$output .= '<pre><code>';
$output .= "composer config allow-plugins.php-tuf/composer-integration true\n";
$output .= "composer require php-tuf/composer-integration";
$output .= '</code></pre>';
$output .= '<p>' . t('Package Manager currently requires the <code>https://packages.drupal.org</code> Composer repository to be defined in your <code>composer.json</code> file, since Drupal.org is currently the only package repository that has support for TUF. To set this up, run the following commands (assuming your site is based on the <code>drupal/recommended-project</code> or <code>drupal/legacy-project</code> templates):') . '</p>';
$output .= '<pre><code>';
$output .= "composer config --unset repositories.0\n";
$output .= "composer config repositories.drupal '{\"type\": \"composer\", \"url\": \"https://packages.drupal.org/8\", \"tuf\": true}'\n";
$output .= '</code></pre>';
$output .= '<h4 id="package-manager-faq-unsupported-composer-plugin">' . t('What if it says I have unsupported Composer plugins in my codebase?') . '</h4>';
$output .= '<p>' . t('A fresh Drupal installation only uses supported Composer plugins, but some modules or themes may depend on additional Composer plugins. Please <a href=":new-issue">create a new issue</a> when you encounter this.', [
':new-issue' => 'https://www.drupal.org/node/add/project-issue/automatic_updates',
......
......@@ -198,6 +198,9 @@ services:
class: Drupal\package_manager\Validator\PhpExtensionsValidator
tags:
- { name: event_subscriber }
# @todo Tag this service as an event subscriber in https://drupal.org/i/3358504,
# once packages.drupal.org supports TUF.
Drupal\package_manager\Validator\PhpTufValidator: {}
package_manager.update_processor:
class: Drupal\package_manager\PackageManagerUpdateProcessor
arguments:
......
......@@ -239,6 +239,7 @@ class ComposerInspector implements LoggerAwareInterface {
* but if it is a boolean, an array or a map, JSON decoding should be
* applied.
*
* @see ::getAllowPluginsConfig()
* @see \Composer\Command\ConfigCommand::execute()
*/
public function getConfig(string $key, string $context): ?string {
......@@ -502,4 +503,32 @@ class ComposerInspector implements LoggerAwareInterface {
};
}
/**
* Returns the value of `allow-plugins` config setting.
*
* @param string $dir
* The directory in which to run Composer.
*
* @return bool[]|bool
* An array of boolean flags to allow or disallow certain plugins, or TRUE
* if all plugins are allowed.
*
* @see https://getcomposer.org/doc/06-config.md#allow-plugins
*/
public function getAllowPluginsConfig(string $dir): array|bool {
// If `allow-plugins` is `false`, Composer 2.5.4 and earlier has no output.
$value = $this->getConfig('allow-plugins', $dir) ?? 'false';
// Try to convert the value we got back to a boolean. If that can't be done,
// assume it's an array of plugin-specific flags and parse it as JSON.
try {
$value = static::toBoolean($value);
}
catch (\UnhandledMatchError) {
$value = json_decode($value, TRUE, flags: JSON_THROW_ON_ERROR);
}
// An empty array indicates that no plugins are allowed.
return $value ?: [];
}
}
......@@ -85,6 +85,7 @@ final class ComposerPluginsValidator implements EventSubscriberInterface {
'drupal/core-project-message' => '*',
'phpstan/extension-installer' => '^1.1',
// cSpell:enable
PhpTufValidator::PLUGIN_NAME => '^1',
];
/**
......@@ -152,23 +153,13 @@ final class ComposerPluginsValidator implements EventSubscriberInterface {
? $stage->getStageDirectory()
: $this->pathLocator->getProjectRoot();
try {
// @see https://getcomposer.org/doc/06-config.md#allow-plugins
$value = $this->inspector->getConfig('allow-plugins', $dir);
$allowed_plugins = $this->inspector->getAllowPluginsConfig($dir);
}
catch (RuntimeException $exception) {
$event->addErrorFromThrowable($exception, $this->t('Unable to determine Composer <code>allow-plugins</code> setting.'));
return;
}
// Try to convert the value we got back to a boolean. If that can't be done,
// assume it's an array of plugin-specific flags and parse it as JSON.
try {
$allowed_plugins = ComposerInspector::toBoolean($value);
}
catch (\UnhandledMatchError) {
$allowed_plugins = json_decode($value, TRUE, flags: JSON_THROW_ON_ERROR);
}
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;
......
<?php
declare(strict_types = 1);
namespace Drupal\package_manager\Validator;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
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\PreRequireEvent;
use Drupal\package_manager\Event\StatusCheckEvent;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Validates that PHP-TUF is installed and correctly configured.
*
* In both the active and stage directories, this checks for the following
* conditions:
* - The PHP-TUF plugin is installed.
* - The plugin is not explicitly blocked by Composer's `allow-plugins`
* configuration.
* - Composer is aware of at least one repository hosted at
* packages.drupal.org (since that's currently the only server that supports
* TUF), and that those repositories have TUF support explicitly enabled.
*
* Note that this validator is currently not active, because the service
* definition is not tagged as an event subscriber. This will be changed in
* https://drupal.org/i/3358504, once TUF support is rolled out on
* packages.drupal.org.
*
* @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 PhpTufValidator implements EventSubscriberInterface {
use StringTranslationTrait;
/**
* The name of the PHP-TUF Composer integration plugin.
*
* @var string
*/
public const PLUGIN_NAME = 'php-tuf/composer-integration';
/**
* Constructs a PhpTufValidator object.
*
* @param \Drupal\package_manager\PathLocator $pathLocator
* The path locator service.
* @param \Drupal\package_manager\ComposerInspector $composerInspector
* The Composer inspector service.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler
* The module handler service.
*/
public function __construct(
private readonly PathLocator $pathLocator,
private readonly ComposerInspector $composerInspector,
private readonly ModuleHandlerInterface $moduleHandler
) {}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
StatusCheckEvent::class => 'validate',
PreCreateEvent::class => 'validate',
PreRequireEvent::class => 'validate',
PreApplyEvent::class => 'validate',
];
}
/**
* Reacts to a stage event by validating PHP-TUF configuration as needed.
*
* @param \Drupal\package_manager\Event\PreOperationStageEvent $event
* The event object.
*/
public function validate(PreOperationStageEvent $event): void {
$messages = $this->validateTuf($this->pathLocator->getProjectRoot());
if ($messages) {
$event->addError($messages, $this->t('The active directory is not protected by PHP-TUF, which is required to use Package Manager securely.'));
}
$stage = $event->stage;
if ($stage->stageDirectoryExists()) {
$messages = $this->validateTuf($stage->getStageDirectory());
if ($messages) {
$event->addError($messages, $this->t('The stage directory is not protected by PHP-TUF, which is required to use Package Manager securely.'));
}
}
}
/**
* Flags messages if PHP-TUF is not installed and configured properly.
*
* @param string $dir
* The directory to examine.
*
* @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
* The error messages, if any.
*/
private function validateTuf(string $dir): array {
$messages = [];
if ($this->moduleHandler->moduleExists('help')) {
$help_url = Url::fromRoute('help.page', ['name' => 'package_manager'])
->setOption('fragment', 'package-manager-tuf-info')
->toString();
}
// The Composer plugin must be installed.
$installed_packages = $this->composerInspector->getInstalledPackagesList($dir);
if (!isset($installed_packages[static::PLUGIN_NAME])) {
$message = $this->t('The <code>@plugin</code> plugin is not installed.', [
'@plugin' => static::PLUGIN_NAME,
]);
if (isset($help_url)) {
$message = $this->t('@message See <a href=":url">the help page</a> for more information on how to install the plugin.', [
'@message' => $message,
':url' => $help_url,
]);
}
$messages[] = $message;
}
// And it has to be explicitly enabled.
$allowed_plugins = $this->composerInspector->getAllowPluginsConfig($dir);
if ($allowed_plugins !== TRUE && empty($allowed_plugins[static::PLUGIN_NAME])) {
$message = $this->t('The <code>@plugin</code> plugin is not listed as an allowed plugin.', [
'@plugin' => static::PLUGIN_NAME,
]);
if (isset($help_url)) {
$message = $this->t('@message See <a href=":url">the help page</a> for more information on how to configure the plugin.', [
'@message' => $message,
':url' => $help_url,
]);
}
$messages[] = $message;
}
// Get the defined repositories that use packages.drupal.org.
$repositories = array_filter(
Json::decode($this->composerInspector->getConfig('repositories', $dir)),
fn (array $r): bool => str_starts_with($r['url'], 'https://packages.drupal.org')
);
// All packages.drupal.org repositories must have TUF protection.
foreach ($repositories as $repository) {
if (empty($repository['tuf'])) {
$messages[] = $this->t('TUF is not enabled for the @url repository.', [
'@url' => $repository['url'],
]);
}
}
// There must be at least one repository using packages.drupal.org, since
// that's the only repository which supports TUF right now.
if (empty($repositories)) {
$message = $this->t('The <code>https://packages.drupal.org</code> Composer repository must be defined in <code>composer.json</code>.');
if (isset($help_url)) {
$message = $this->t('@message See <a href=":url">the help page</a> for more information on how to set up this repository.', [
'@message' => $message,
':url' => $help_url,
]);
}
$messages[] = $message;
}
return $messages;
}
}
......@@ -410,4 +410,66 @@ class ComposerInspectorTest extends PackageManagerKernelTestBase {
$this->assertSame($is_metapackage, is_null($list['test/package']->path));
}
/**
* Data provider for ::testAllowedPlugins().
*
* @return array[]
* The test cases.
*/
public function providerAllowedPlugins(): array {
return [
'all plugins allowed' => [
['allow-plugins' => TRUE],
TRUE,
],
'no plugins allowed' => [
['allow-plugins' => FALSE],
[],
],
'some plugins allowed' => [
[
'allow-plugins.example/plugin-a' => TRUE,
'allow-plugins.example/plugin-b' => FALSE,
],
[
'example/plugin-a' => TRUE,
'example/plugin-b' => FALSE,
// The scaffold plugin is explicitly disallowed by the fake_site
// fixture.
'drupal/core-composer-scaffold' => FALSE,
],
],
];
}
/**
* Tests ComposerInspector's parsing of the allowed plugins list.
*
* @param array $config
* The Composer configuration to set.
* @param array|bool $expected_value
* The expected return value from getAllowPluginsConfig().
*
* @covers ::getAllowPluginsConfig
*
* @dataProvider providerAllowedPlugins
*/
public function testAllowedPlugins(array $config, bool|array $expected_value): void {
(new ActiveFixtureManipulator())
->addConfig($config)
->commitChanges();
$project_root = $this->container->get(PathLocator::class)->getProjectRoot();
$actual_value = $this->container->get(ComposerInspector::class)
->getAllowPluginsConfig($project_root);
if (is_array($expected_value)) {
ksort($expected_value);
}
if (is_array($actual_value)) {
ksort($actual_value);
}
$this->assertSame($expected_value, $actual_value);
}
}
......@@ -56,6 +56,7 @@ class LockFileValidatorTest extends PackageManagerKernelTestBase {
$inspector->getConfig('extra', $arguments)->willReturn('{}');
$inspector->getConfig('minimum-stability', $arguments)->willReturn('stable');
$inspector->getInstalledPackagesList($arguments)->willReturn(new InstalledPackagesList());
$inspector->getAllowPluginsConfig($arguments)->willReturn([]);
$inspector->validate($arguments);
$inspector->getRootPackageInfo($arguments)->willReturn([]);
$container->set('package_manager.composer_inspector', $inspector->reveal());
......
<?php
declare(strict_types = 1);
namespace Drupal\Tests\package_manager\Kernel;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\fixture_manipulator\ActiveFixtureManipulator;
use Drupal\fixture_manipulator\FixtureManipulator;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\PreRequireEvent;
use Drupal\package_manager\Exception\StageEventException;
use Drupal\package_manager\ValidationResult;
use Drupal\package_manager\Validator\PhpTufValidator;
/**
* @coversDefaultClass \Drupal\package_manager\Validator\PhpTufValidator
* @group package_manager
* @internal
*/
class PhpTufValidatorTest extends PackageManagerKernelTestBase {
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
(new ActiveFixtureManipulator())
->addConfig([
'repositories.drupal' => [
'type' => 'composer',
'url' => 'https://packages.drupal.org/8',
'tuf' => TRUE,
],
'allow-plugins.' . PhpTufValidator::PLUGIN_NAME => TRUE,
])
->addPackage([
'name' => PhpTufValidator::PLUGIN_NAME,
'type' => 'composer-plugin',
'require' => [
'composer-plugin-api' => '*',
],
'extra' => [
'class' => 'PhpTufComposerPlugin',
],
])
->commitChanges();
}
/**
* {@inheritdoc}
*/
public function register(ContainerBuilder $container) {
parent::register($container);
// @todo Remove this in https://drupal.org/i/3358504, once
// packages.drupal.org supports TUF.
$container->getDefinition(PhpTufValidator::class)
->addTag('event_subscriber');
}
/**
* Tests that there are no errors if the plugin is set up correctly.
*/
public function testPluginInstalledAndConfiguredProperly(): void {
$this->assertStatusCheckResults([]);
$this->assertResults([]);
}
/**
* Tests there is an error if the plugin is not installed in the project root.
*/
public function testPluginNotInstalledInProjectRoot(): void {
(new ActiveFixtureManipulator())
->removePackage(PhpTufValidator::PLUGIN_NAME)
->commitChanges();
$messages = [
t('The <code>php-tuf/composer-integration</code> plugin is not installed.'),
// Composer automatically removes the plugin from the `allow-plugins`
// list when the plugin package is removed.
t('The <code>php-tuf/composer-integration</code> plugin is not listed as an allowed plugin.'),
];
$result = ValidationResult::createError($messages, t('The active directory is not protected by PHP-TUF, which is required to use Package Manager securely.'));
$this->assertStatusCheckResults([$result]);
$this->assertResults([$result], PreCreateEvent::class);
}
/**
* Tests removing the plugin from the stage on pre-require.
*/
public function testPluginRemovedFromStagePreRequire(): void {
$this->getStageFixtureManipulator()
->removePackage(PhpTufValidator::PLUGIN_NAME);
$messages = [
t('The <code>php-tuf/composer-integration</code> plugin is not installed.'),
// Composer automatically removes the plugin from the `allow-plugins`
// list when the plugin package is removed.
t('The <code>php-tuf/composer-integration</code> plugin is not listed as an allowed plugin.'),
];
$result = ValidationResult::createError($messages, t('The stage directory is not protected by PHP-TUF, which is required to use Package Manager securely.'));
$this->assertResults([$result], PreRequireEvent::class);
}
/**
* Tests removing the plugin from the stage before applying it.
*/
public function testPluginRemovedFromStagePreApply(): void {
$stage = $this->createStage();
$stage->create();
$stage->require(['ext-json:*']);
(new FixtureManipulator())
->removePackage(PhpTufValidator::PLUGIN_NAME)
->commitChanges($stage->getStageDirectory());
$messages = [
t('The <code>php-tuf/composer-integration</code> plugin is not installed.'),
// Composer automatically removes the plugin from the `allow-plugins`
// list when the plugin package is removed.
t('The <code>php-tuf/composer-integration</code> plugin is not listed as an allowed plugin.'),
];
$result = ValidationResult::createError($messages, t('The stage directory is not protected by PHP-TUF, which is required to use Package Manager securely.'));
try {
$stage->apply();
$this->fail('Expected an exception but none was thrown.');
}
catch (StageEventException $e) {
$this->assertInstanceOf(PreApplyEvent::class, $e->event);
$this->assertValidationResultsEqual([$result], $e->event->getResults());
}
}
/**
* Data provider for testing invalid plugin configuration.
*
* @return array[]
* The test cases.
*/
public function providerInvalidConfiguration(): array {
return [
'plugin specifically disallowed' => [
[
'allow-plugins.' . PhpTufValidator::PLUGIN_NAME => FALSE,
],
[
t('The <code>php-tuf/composer-integration</code> plugin is not listed as an allowed plugin.'),
],
],
'all plugins disallowed' => [
[
'allow-plugins' => FALSE,
],
[
t('The <code>php-tuf/composer-integration</code> plugin is not listed as an allowed plugin.'),
],
],
'packages.drupal.org not defined' => [
[
'repositories.drupal' => FALSE,
],
[
t('The <code>https://packages.drupal.org</code> Composer repository must be defined in <code>composer.json</code>.'),
],
],
'packages.drupal.org not using TUF' => [
[
'repositories.drupal' => [
'type' => 'composer',
'url' => 'https://packages.drupal.org/8',
],
],
[
t('TUF is not enabled for the https://packages.drupal.org/8 repository.'),
],
],
];
}
/**
* Data provider for testing invalid plugin configuration in the stage.
*
* @return \Generator
* The test cases.
*/
public function providerInvalidConfigurationInStage(): \Generator {
foreach ($this->providerInvalidConfiguration() as $name => $arguments) {
$arguments[] = PreRequireEvent::class;
yield "$name on pre-require" => $arguments;
array_splice($arguments, -1, NULL, PreApplyEvent::class);
yield "$name on pre-apply" => $arguments;
}
}
/**
* Tests errors caused by invalid plugin configuration in the project root.
*
* @param array $config
* The Composer configuration to set.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $expected_messages
* The expected error messages.
*
* @dataProvider providerInvalidConfiguration
*/
public function testInvalidConfigurationInProjectRoot(array $config, array $expected_messages): void {
(new ActiveFixtureManipulator())->addConfig($config)->commitChanges();
$result = ValidationResult::createError($expected_messages, t('The active directory is not protected by PHP-TUF, which is required to use Package Manager securely.'));
$this->assertStatusCheckResults([$result]);
$this->assertResults([$result], PreCreateEvent::class);
}
/**
* Tests errors caused by invalid plugin configuration in the stage directory.
*
* @param array $config
* The Composer configuration to set.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $expected_messages
* The expected error messages.
* @param string $event_class
* The event before which the plugin's configuration should be changed.
*
* @dataProvider providerInvalidConfigurationInStage
*/
public function testInvalidConfigurationInStage(array $config, array $expected_messages, string $event_class): void {
$listener = function (PreRequireEvent|PreApplyEvent $event) use ($config): void {
(new FixtureManipulator())
->addConfig($config)
->commitChanges($event->stage->getStageDirectory());
};
$this->addEventTestListener($listener, $event_class);
// LockFileValidator will complain because we have not added, removed, or
// updated any packages in the stage. In this very specific situation, it's
// okay to disable that validator to remove the interference.
if ($event_class === PreApplyEvent::class) {
$lock_file_validator = $this->container->get('package_manager.validator.lock_file');
$this->container->get('event_dispatcher')
->removeSubscriber($lock_file_validator);
}
$result = ValidationResult::createError($expected_messages, t('The stage directory is not protected by PHP-TUF, which is required to use Package Manager securely.'));
$this->assertResults([$result], $event_class);
}
}
......@@ -4,8 +4,13 @@ declare(strict_types = 1);
namespace Drupal\Tests\automatic_updates\Functional;
use Drupal\Core\Url;
use Drupal\package_manager\Event\StatusCheckEvent;
use Drupal\package_manager\ValidationResult;
use Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber;
/**
* Tests package manager help link is clickable.
* Tests that links to online help in validation errors are clickable.
*
* @group automatic_updates
* @internal
......@@ -18,25 +23,34 @@ class ClickableHelpTest extends AutomaticUpdatesFunctionalTestBase {
protected static $modules = [
'automatic_updates',
'help',
'package_manager_test_validation',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'starterkit_theme';
protected $defaultTheme = 'stark';
/**
* Tests if composer executable is not present then the help link clickable.
* Tests that a link to online help in a validation error is clickable.
*/
public function testHelpLinkClickable(): void {
$url = Url::fromRoute('help.page', ['name' => 'package_manager'])
->toString();
$result = ValidationResult::createError([
t('A problem was found! <a href=":url">Read all about it.</a>', [':url' => $url]),
]);
TestSubscriber::setTestResult([$result], StatusCheckEvent::class);
$this->drupalLogin($this->createUser([
'administer site configuration',
]));
$this->config('package_manager.settings')
->set('executables.composer', '/not/matching/path/to/composer')
->save();
$this->drupalGet('admin/reports/status');
$this->assertSession()->linkByHrefExists('/admin/help/package_manager#package-manager-composer-related-faq');
$assert_session = $this->assertSession();
$assert_session->pageTextContains('A problem was found! Read all about it.');
$assert_session->linkExists('Read all about it.');
$assert_session->linkByHrefExists($url);
}
}
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