diff --git a/automatic_updates.info.yml b/automatic_updates.info.yml index 54689a376789f85e63b4eaf07091556b1e29ba8d..3ba06a9eb1774016f6316ad6b73b1442a946f657 100644 --- a/automatic_updates.info.yml +++ b/automatic_updates.info.yml @@ -1,5 +1,5 @@ name: 'Automatic Updates' type: module description: 'Drupal Automatic Updates' -core: 8.7 -package: 'Maintenance' +core: 8.x +package: 'Security' diff --git a/automatic_updates.module b/automatic_updates.module new file mode 100644 index 0000000000000000000000000000000000000000..5157e76f2e011f53663f5195aed3b47ec2d08c97 --- /dev/null +++ b/automatic_updates.module @@ -0,0 +1,38 @@ +<?php + +/** + * @file + * Contains automatic_updates.module.. + */ + +/** + * Implements hook_page_top(). + */ +function automatic_updates_page_top(array &$page_top) { + /** @var \Drupal\Core\Routing\AdminContext $admin_context */ + $admin_context = \Drupal::service('router.admin_context'); + $route_match = \Drupal::routeMatch(); + if ($admin_context->isAdminRoute($route_match->getRouteObject()) && \Drupal::currentUser()->hasPermission('administer site configuration')) { + $disabled_routes = [ + 'update.theme_update', + 'system.theme_install', + 'update.module_update', + 'update.module_install', + 'update.status', + 'update.report_update', + 'update.report_install', + 'update.settings', + 'system.status', + 'update.confirmation_page', + ]; + // These routes don't need additional nagging. + if (in_array(\Drupal::routeMatch()->getRouteName(), $disabled_routes, TRUE)) { + return; + } + /** @var \Drupal\automatic_updates\Services\AutomaticUpdatesPsaInterface $psa */ + $psa = \Drupal::service('automatic_updates.psa'); + foreach ($psa->getPublicServiceMessages() as $psa) { + \Drupal::messenger()->addError($psa); + } + } +} diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml new file mode 100644 index 0000000000000000000000000000000000000000..26d646aca95ac75c9a7d6c87d23cd4daa7a498d1 --- /dev/null +++ b/automatic_updates.services.yml @@ -0,0 +1,15 @@ +services: + logger.channel.automatic_updates: + parent: logger.channel_base + arguments: ['automatic_updates'] + automatic_updates.psa: + class: Drupal\automatic_updates\Services\AutomaticUpdatesPsa + arguments: + - '@config.factory' + - '@cache.default' + - '@datetime.time' + - '@http_client' + - '@extension.list.module' + - '@extension.list.profile' + - '@extension.list.theme' + - '@logger.channel.automatic_updates' diff --git a/config/install/automatic_updates.settings.yml b/config/install/automatic_updates.settings.yml new file mode 100644 index 0000000000000000000000000000000000000000..2dc7865e6537becafe131b5f5c341e1e45a62671 --- /dev/null +++ b/config/install/automatic_updates.settings.yml @@ -0,0 +1,4 @@ +# Public service announcement URI endpoint. +# TODO: Update to correct end point once it is available. See +# https://www.drupal.org/project/automatic_updates/issues/3045273 +psa_endpoint: 'http://localhost/automatic_updates/test-json' diff --git a/config/schema/automatic_updates.schema.yml b/config/schema/automatic_updates.schema.yml new file mode 100644 index 0000000000000000000000000000000000000000..467d316c33677bb767d3a127a075132a44c603ba --- /dev/null +++ b/config/schema/automatic_updates.schema.yml @@ -0,0 +1,7 @@ +automatic_updates.settings: + type: config_object + label: 'Automatic updates settings' + mapping: + psa_endpoint: + type: string + label: 'Endpoint URI for PSAs' diff --git a/src/Services/AutomaticUpdatesPsa.php b/src/Services/AutomaticUpdatesPsa.php new file mode 100644 index 0000000000000000000000000000000000000000..5ca53f29005ec583284a2e4702dbfd3b1f645cb8 --- /dev/null +++ b/src/Services/AutomaticUpdatesPsa.php @@ -0,0 +1,227 @@ +<?php + +namespace Drupal\automatic_updates\Services; + +use Composer\Semver\VersionParser; +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Component\Version\Constraint; +use Drupal\Core\Cache\CacheBackendInterface; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\DependencyInjection\DependencySerializationTrait; +use Drupal\Core\Extension\ExtensionList; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use GuzzleHttp\Client; +use GuzzleHttp\Exception\TransferException; +use Psr\Log\LoggerInterface; + +/** + * Class AutomaticUpdatesPsa. + */ +class AutomaticUpdatesPsa implements AutomaticUpdatesPsaInterface { + use StringTranslationTrait; + use DependencySerializationTrait; + + /** + * Module's configuration. + * + * @var \Drupal\Core\Config\ImmutableConfig + */ + protected $config; + + /** + * The http client. + * + * @var \GuzzleHttp\Client + */ + protected $httpClient; + + /** + * The cache backend. + * + * @var \Drupal\Core\Cache\CacheBackendInterface + */ + protected $cache; + + /** + * The time service. + * + * @var \Drupal\Component\Datetime\TimeInterface + */ + protected $time; + + /** + * The module extension list. + * + * @var \Drupal\Core\Extension\ExtensionList + */ + protected $module; + + /** + * The profile extension list. + * + * @var \Drupal\Core\Extension\ExtensionList + */ + protected $profile; + + /** + * The theme extension list. + * + * @var \Drupal\Core\Extension\ExtensionList + */ + protected $theme; + + /** + * The logger. + * + * @var \Psr\Log\LoggerInterface + */ + protected $logger; + + /** + * AutomaticUpdatesPsa constructor. + * + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * The config factory. + * @param \Drupal\Core\Cache\CacheBackendInterface $cache + * The cache backend. + * @param \Drupal\Component\Datetime\TimeInterface $time + * The time service. + * @param \GuzzleHttp\Client $client + * The HTTP client. + * @param \Drupal\Core\Extension\ExtensionList $module + * The module extension list. + * @param \Drupal\Core\Extension\ExtensionList $profile + * The profile extension list. + * @param \Drupal\Core\Extension\ExtensionList $theme + * The theme extension list. + * @param \Psr\Log\LoggerInterface $logger + * The logger. + */ + public function __construct(ConfigFactoryInterface $config_factory, CacheBackendInterface $cache, TimeInterface $time, Client $client, ExtensionList $module, ExtensionList $profile, ExtensionList $theme, LoggerInterface $logger) { + $this->config = $config_factory->get('automatic_updates.settings'); + $this->cache = $cache; + $this->time = $time; + $this->httpClient = $client; + $this->module = $module; + $this->profile = $profile; + $this->theme = $theme; + $this->logger = $logger; + } + + /** + * {@inheritdoc} + */ + public function getPublicServiceMessages() { + $messages = []; + + if ($cache = $this->cache->get('automatic_updates_psa')) { + $response = $cache->data; + } + else { + try { + $psa_endpoint = $this->config->get('psa_endpoint'); + $response = $this->httpClient->get($psa_endpoint) + ->getBody() + ->getContents(); + // Set response in cache for 12 hours. + $this->cache->set('automatic_updates_psa', $response, $this->time->getCurrentTime() + 3600 * 12); + } + catch (TransferException $exception) { + $this->logger->error($exception->getMessage()); + return [$this->t('Drupal PSA endpoint :url is unreachable.', [':url' => $psa_endpoint])]; + } + } + + try { + $json_payload = json_decode($response); + foreach ($json_payload as $json) { + if ($json->project === 'core') { + $this->coreParser($messages, $json); + } + else { + $this->contribParser($messages, $json); + } + } + } + catch (\UnexpectedValueException $exception) { + $this->logger->error($exception->getMessage()); + $messages[] = $this->t('Drupal PSA endpoint service is malformed.'); + } + + return $messages; + } + + /** + * Parse core project JSON version strings. + * + * @param array $messages + * The messages array. + * @param object $json + * The JSON object. + */ + protected function coreParser(array &$messages, $json) { + $parser = new VersionParser(); + array_walk($json->secure_versions, function (&$version) { + $version = '<' . $version; + }); + $version_string = implode('||', $json->secure_versions); + $psa_constraint = $parser->parseConstraints($version_string); + $core_constraint = $parser->parseConstraints(\Drupal::VERSION); + if ($psa_constraint->matches($core_constraint)) { + $messages[] = $this->t('Drupal Core PSA: <a href=":url">:message</a>', [ + ':message' => $json->title, + ':url' => $json->link, + ]); + } + } + + /** + * Parse contrib project JSON version strings. + * + * @param array $messages + * The messages array. + * @param object $json + * The JSON object. + */ + protected function contribParser(array &$messages, $json) { + $extension_list = $json->type; + if (!property_exists($this, $extension_list)) { + $this->logger->error('Extension list of type "%extension" does not exist.', ['%extension' => $extension_list]); + return; + } + foreach ($json->extensions as $extension_name) { + if ($this->{$extension_list}->exists($extension_name)) { + $extension = $this->{$extension_list}->getAllAvailableInfo()[$extension_name]; + if (empty($extension['version'])) { + continue; + } + $this->contribMessage($messages, $json, $extension['version']); + } + } + } + + /** + * Add a contrib message PSA, if appropriate. + * + * @param array $messages + * The messages array. + * @param object $json + * The JSON object. + * @param string $extension_version + * The extension version. + */ + protected function contribMessage(array &$messages, $json, $extension_version) { + array_walk($json->secure_versions, function (&$version) { + $version = \Drupal::CORE_COMPATIBILITY . '-' . $version; + }); + $version_string = implode('||', $json->secure_versions); + $constraint = new Constraint($extension_version, \Drupal::CORE_COMPATIBILITY); + if (!$constraint->isCompatible($version_string)) { + $messages[] = $this->t('Drupal Contrib Project PSA: <a href=":url">:message</a>', [ + ':message' => $json->title, + ':url' => $json->link, + ]); + } + } + +} diff --git a/src/Services/AutomaticUpdatesPsaInterface.php b/src/Services/AutomaticUpdatesPsaInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..67ce4f2f1b98e34cf3fae96275c65aa04c5983ed --- /dev/null +++ b/src/Services/AutomaticUpdatesPsaInterface.php @@ -0,0 +1,18 @@ +<?php + +namespace Drupal\automatic_updates\Services; + +/** + * Interface AutomaticUpdatesPsaInterface. + */ +interface AutomaticUpdatesPsaInterface { + + /** + * Get public service messages. + * + * @return array + * A return of translatable strings. + */ + public function getPublicServiceMessages(); + +} diff --git a/tests/modules/test_automatic_updates/src/Controller/JsonTestController.php b/tests/modules/test_automatic_updates/src/Controller/JsonTestController.php index 90bbb34ca0404f0b43f0bb89342918187bdab4e9..cb72134a7939da980154ef446d1168bcf2e1dca9 100644 --- a/tests/modules/test_automatic_updates/src/Controller/JsonTestController.php +++ b/tests/modules/test_automatic_updates/src/Controller/JsonTestController.php @@ -20,17 +20,66 @@ class JsonTestController extends ControllerBase { $feed[] = [ 'title' => 'Critical Release - PSA-2019-02-19', 'link' => 'https://www.drupal.org/psa-2019-02-19', - 'project' => 'drupal/core', - 'modules' => ['forum', 'node'], - 'version' => '>=8.0.0 <8.6.10 || >=8.0.0 <8.5.11', + 'project' => 'core', + 'extensions' => [], + 'type' => 'module', + 'secure_versions' => [ + '7.99', + '8.10.99', + '8.9.99', + '8.8.99', + '8.7.99', + '8.6.99', + '8.5.99', + ], 'pubDate' => 'Tue, 19 Feb 2019 14:11:01 +0000', ]; $feed[] = [ - 'title' => 'Critical Release - PSA-Fictional PSA', - 'link' => 'https://www.drupal.org/psa-fictional-psa', - 'project' => 'drupal/core', - 'modules' => ['system'], - 'version' => '>=8.6.10 || >=8.5.11', + 'title' => 'Critical Release - PSA-Really Old', + 'link' => 'https://www.drupal.org/psa', + 'project' => 'core', + 'extensions' => [], + 'type' => 'module', + 'secure_versions' => [ + '7.0', + '8.4.0', + ], + 'pubDate' => 'Tue, 19 Feb 2019 14:11:01 +0000', + ]; + $feed[] = [ + 'title' => 'Node - Moderately critical - Access bypass - SA-CONTRIB-2019', + 'link' => 'https://www.drupal.org/sa-contrib-2019', + 'project' => 'node', + 'extensions' => ['node'], + 'type' => 'module', + 'secure_versions' => ['8.10.99'], + 'pubDate' => 'Tue, 19 Mar 2019 12:50:00 +0000', + ]; + $feed[] = [ + 'title' => 'Standard - Moderately critical - Access bypass - SA-CONTRIB-2019', + 'link' => 'https://www.drupal.org/sa-contrib-2019', + 'project' => 'Standard Install Profile', + 'extensions' => ['standard'], + 'type' => 'profile', + 'secure_versions' => ['8.10.99'], + 'pubDate' => 'Tue, 19 Mar 2019 12:50:00 +0000', + ]; + $feed[] = [ + 'title' => 'Seven - Moderately critical - Access bypass - SA-CONTRIB-2019', + 'link' => 'https://www.drupal.org/sa-contrib-2019', + 'project' => 'seven', + 'extensions' => ['seven'], + 'type' => 'theme', + 'secure_versions' => ['8.10.99'], + 'pubDate' => 'Tue, 19 Mar 2019 12:50:00 +0000', + ]; + $feed[] = [ + 'title' => 'Foobar - Moderately critical - Access bypass - SA-CONTRIB-2019', + 'link' => 'https://www.drupal.org/sa-contrib-2019', + 'project' => 'foobar', + 'extensions' => ['foobar'], + 'type' => 'foobar', + 'secure_versions' => ['8.10.99'], 'pubDate' => 'Tue, 19 Mar 2019 12:50:00 +0000', ]; return new JsonResponse($feed); diff --git a/tests/modules/test_automatic_updates/test_automatic_updates.routing.yml b/tests/modules/test_automatic_updates/test_automatic_updates.routing.yml index 388108af395ec7a06ebdccd65635868eeec56dd7..1920578446758ecc6567b1a121808fa97f9f9e35 100644 --- a/tests/modules/test_automatic_updates/test_automatic_updates.routing.yml +++ b/tests/modules/test_automatic_updates/test_automatic_updates.routing.yml @@ -5,3 +5,10 @@ test_automatic_updates.json_test_controller: _title: 'JSON' requirements: _access: 'TRUE' +test_automatic_updates.json_test_denied_controller: + path: '/automatic_updates/test-json-denied' + defaults: + _controller: '\Drupal\test_automatic_updates\Controller\JsonTestController::json' + _title: 'JSON' + requirements: + _access: 'FALSE' diff --git a/tests/src/Functional/AutomaticUpdatesTest.php b/tests/src/Functional/AutomaticUpdatesTest.php index 23e089744a4657345c57aa3d42cd5cfb0b4a6479..823784db6663b975fbc6015399d9981c128d9935 100644 --- a/tests/src/Functional/AutomaticUpdatesTest.php +++ b/tests/src/Functional/AutomaticUpdatesTest.php @@ -2,7 +2,6 @@ namespace Drupal\Tests\automatic_updates\Functional; -use Composer\Semver\VersionParser; use Drupal\Core\Url; use Drupal\Tests\BrowserTestBase; @@ -35,24 +34,40 @@ class AutomaticUpdatesTest extends BrowserTestBase { */ protected function setUp() { parent::setUp(); - $this->user = $this->drupalCreateUser(['administer site configuration']); + $this->user = $this->drupalCreateUser([ + 'administer site configuration', + 'access administration pages', + ]); $this->drupalLogin($this->user); } /** - * Tests that the JSON is parsable. + * Tests that a PSA is displayed. */ - public function testJson() { - $this->drupalGet(Url::fromRoute('test_automatic_updates.json_test_controller')); - $json = json_decode($this->getSession()->getPage()->getContent(), TRUE); - $this->assertEquals($json[0]['title'], 'Critical Release - PSA-2019-02-19'); - $parser = new VersionParser(); - $constraint = $parser->parseConstraints($json[0]['version']); - $core_constraint = $parser->parseConstraints(\Drupal::VERSION); - $this->assertFALSE($constraint->matches($core_constraint)); - $constraint = $parser->parseConstraints($json[1]['version']); - $core_constraint = $parser->parseConstraints(\Drupal::VERSION); - $this->assertTRUE($constraint->matches($core_constraint)); + public function testPsa() { + $end_point = $this->buildUrl(Url::fromRoute('test_automatic_updates.json_test_controller')); + $this->config('automatic_updates.settings') + ->set('psa_endpoint', $end_point) + ->save(); + $this->drupalGet(Url::fromRoute('system.admin')); + $this->assertSession()->pageTextContains('Drupal Core PSA: Critical Release - PSA-2019-02-19'); + $this->assertSession()->pageTextNotContains('Drupal Core PSA: Critical Release - PSA-Really Old'); + $this->assertSession()->pageTextContains('Drupal Contrib Project PSA: Node - Moderately critical - Access bypass - SA-CONTRIB-2019'); + $this->assertSession()->pageTextContains('Drupal Contrib Project PSA: Seven - Moderately critical - Access bypass - SA-CONTRIB-2019'); + $this->assertSession()->pageTextContains('Drupal Contrib Project PSA: Standard - Moderately critical - Access bypass - SA-CONTRIB-2019'); + + // Test cache. + $end_point = $this->buildUrl(Url::fromRoute('test_automatic_updates.json_test_denied_controller')); + $this->config('automatic_updates.settings') + ->set('psa_endpoint', $end_point) + ->save(); + $this->drupalGet(Url::fromRoute('system.admin')); + $this->assertSession()->pageTextContains('Drupal Core PSA: Critical Release - PSA-2019-02-19'); + + // Test transmit errors with JSON endpoint. + drupal_flush_all_caches(); + $this->drupalGet(Url::fromRoute('system.admin')); + $this->assertSession()->pageTextContains('Drupal PSA endpoint http://localhost/automatic_updates/test-json-denied is unreachable.'); } }