Unverified Commit 699f02b0 authored by larowlan's avatar larowlan
Browse files

Issue #3041885 by tedbow, beautifulmind, dww, ayushmishra206, phenaproxima,...

Issue #3041885 by tedbow, beautifulmind, dww, ayushmishra206, phenaproxima, webchick, xjm, AaronMcHale, larowlan, benjifisher, heddn, catch, jhodgdon, longwave, mglaman, alexpott, mxr576, effulgentsia: Display relevant Security Advisories data for Drupal

(cherry picked from commit 79dd8321)
parent bf341805
......@@ -308,16 +308,18 @@
$settings['update_free_access'] = FALSE;
/**
* Fallback to HTTP for Update Manager.
*
* If your Drupal site fails to connect to updates.drupal.org using HTTPS to
* fetch Drupal core, module and theme update status, you may uncomment this
* setting and set it to TRUE to allow an insecure fallback to HTTP. Note that
* doing so will open your site up to a potential man-in-the-middle attack. You
* should instead attempt to resolve the issues before enabling this option.
* Fallback to HTTP for Update Manager and for fetching security advisories.
*
* If your site fails to connect to updates.drupal.org over HTTPS (either when
* fetching data on available updates, or when fetching the feed of critical
* security announcements), you may uncomment this setting and set it to TRUE to
* allow an insecure fallback to HTTP. Note that doing so will open your site up
* to a potential man-in-the-middle attack. You should instead attempt to
* resolve the issues before enabling this option.
* @see https://www.drupal.org/docs/system-requirements/php-requirements#openssl
* @see https://en.wikipedia.org/wiki/Man-in-the-middle_attack
* @see \Drupal\update\UpdateFetcher
* @see \Drupal\system\SecurityAdvisories\SecurityAdvisoriesFetcher
*/
# $settings['update_fetch_with_http_fallback'] = TRUE;
......
<?php
namespace Drupal\Core\Extension;
/**
* Provides an extension version value object.
*
* @internal
*
* @see https://www.drupal.org/drupalorg/docs/apis/update-status-xml
*/
final class ExtensionVersion {
/**
* The '8.x-' prefix is used on contrib extension version numbers.
*
* @var string
*/
const CORE_PREFIX = '8.x-';
/**
* The major version.
*
* @var string
*/
protected $majorVersion;
/**
* The minor version.
*
* @var string|null
*/
protected $minorVersion;
/**
* The version extra string.
*
* For example, if the extension version is '2.0.3-alpha1', then the version
* extra string is 'alpha1'.
*
* @var string|null
*/
protected $versionExtra;
/**
* Constructs an extension version object from a version string.
*
* @param string $version_string
* The version string.
*
* @return \Drupal\Core\Extension\ExtensionVersion
* The extension version instance.
*/
public static function createFromVersionString(string $version_string): ExtensionVersion {
$original_version = $version_string;
if (strpos($version_string, static::CORE_PREFIX) === 0 && $version_string !== '8.x-dev') {
$version_string = preg_replace('/8\.x-/', '', $version_string, 1);
}
else {
// Ensure the version string has no unsupported core prefixes.
$dot_x_position = strpos($version_string, '.x-');
if ($dot_x_position === 1 || $dot_x_position === 2) {
$after_core_prefix = explode('.x-', $version_string)[1];
if ($after_core_prefix !== 'dev') {
throw new \UnexpectedValueException("Unexpected version core prefix in $version_string. The only core prefix expected in \Drupal\Core\Extension\ExtensionVersion is: 8.x-");
}
}
}
$version_parts = explode('.', $version_string);
$major_version = $version_parts[0];
$version_parts_count = count($version_parts);
if ($version_parts_count === 2) {
$minor_version = NULL;
}
elseif ($version_parts_count === 3) {
$minor_version = $version_parts[1];
}
$last_part_split = explode('-', $version_parts[count($version_parts) - 1]);
$version_extra = count($last_part_split) === 1 ? NULL : $last_part_split[1];
if ($version_parts_count > 3
|| $version_parts_count < 2
|| !is_numeric($major_version)
|| ($version_parts_count === 3 && !is_numeric($version_parts[1]))
// The only case where a non-numeric version part other the extra part is
// allowed is in development versions like 8.x-1.x-dev, 1.2.x-dev or
// 1.x-dev.
|| (!is_numeric($last_part_split[0]) && $last_part_split !== 'x' && $version_extra !== 'dev')) {
throw new \UnexpectedValueException("Unexpected version number in: $original_version");
}
return new static($major_version, $minor_version, $version_extra);
}
/**
* Constructs an ExtensionVersion object.
*
* @param string $major_version
* The major version.
* @param string|null $minor_version
* The minor version.
* @param string|null $version_extra
* The extra version string.
*/
private function __construct(string $major_version, ?string $minor_version, ?string $version_extra) {
$this->majorVersion = $major_version;
$this->minorVersion = $minor_version;
$this->versionExtra = $version_extra;
}
/**
* Constructs an ExtensionVersion version object from a support branch.
*
* This can be used to determine the major version of the branch.
* ::getVersionExtra() will always return NULL for branches.
*
* @param string $branch
* The support branch.
*
* @return \Drupal\Core\Extension\ExtensionVersion
* The ExtensionVersion instance.
*/
public static function createFromSupportBranch(string $branch): ExtensionVersion {
if (substr($branch, -1) !== '.') {
throw new \UnexpectedValueException("Invalid support branch: $branch");
}
return static::createFromVersionString($branch . '0');
}
/**
* Gets the major version.
*
* @return string
* The major version.
*/
public function getMajorVersion(): string {
return $this->majorVersion;
}
/**
* Gets the minor version.
*
* @return string|null
* The minor version.
*/
public function getMinorVersion(): ?string {
return $this->minorVersion;
}
/**
* Gets the version extra string at the end of the version number.
*
* @return string|null
* The version extra string if available, or otherwise NULL.
*/
public function getVersionExtra(): ?string {
return $this->versionExtra;
}
}
......@@ -102,6 +102,16 @@ protected function prepareSettings() {
'value' => $this->apcuEnsureUniquePrefix,
'required' => TRUE,
];
// Disable fetching of advisories during tests to avoid outbound calls. This
// cannot be set in ::initConfig() because it would not stop these calls
// during install. Tests that need to have the security advisories
// functionality enabled should override this method and unset this
// variable.
// @see \Drupal\Tests\system\Functional\SecurityAdvisories\SecurityAdvisoryTest::writeSettings()
$settings['config']['system.advisories']['enabled'] = (object) [
'value' => FALSE,
'required' => TRUE,
];
$this->writeSettings($settings);
// Allow for test-specific overrides.
$settings_testing_file = DRUPAL_ROOT . '/' . $this->originalSite . '/settings.testing.php';
......
......@@ -55,6 +55,10 @@ protected function lockHttpClientToFixtures() {
],
],
]);
// Rebuild the container in case there is already an instantiated service
// that has a dependency on the http_client service.
$this->container->get('kernel')->rebuildContainer();
$this->container = $this->container->get('kernel')->getContainer();
}
/**
......
......@@ -303,6 +303,17 @@ system.theme.global:
type: theme_settings
label: 'Theme global settings'
system.advisories:
type: config_object
label: 'Security advisory settings'
mapping:
enabled:
type: boolean
label: 'Display critical security advisories'
interval_hours:
type: integer
label: 'How often to check for security advisories, in hours'
block.settings.system_branding_block:
type: block_settings
label: 'Branding block'
......
<?php
namespace Drupal\system\EventSubscriber;
use Drupal\Core\Config\ConfigCrudEvent;
use Drupal\Core\Config\ConfigEvents;
use Drupal\system\SecurityAdvisories\SecurityAdvisoriesFetcher;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Defines a config subscriber for changes to 'system.advisories'.
*/
class AdvisoriesConfigSubscriber implements EventSubscriberInterface {
/**
* The security advisory fetcher service.
*
* @var \Drupal\system\SecurityAdvisories\SecurityAdvisoriesFetcher
*/
protected $securityAdvisoriesFetcher;
/**
* Constructs a new ConfigSubscriber object.
*
* @param \Drupal\system\SecurityAdvisories\SecurityAdvisoriesFetcher $security_advisories_fetcher
* The security advisory fetcher service.
*/
public function __construct(SecurityAdvisoriesFetcher $security_advisories_fetcher) {
$this->securityAdvisoriesFetcher = $security_advisories_fetcher;
}
/**
* Deletes the stored response from the security advisories feed, if needed.
*
* The stored response will only be deleted if the 'interval_hours' config
* setting is reduced from the previous value.
*
* @param \Drupal\Core\Config\ConfigCrudEvent $event
* The configuration event.
*/
public function onConfigSave(ConfigCrudEvent $event): void {
$saved_config = $event->getConfig();
if ($saved_config->getName() === 'system.advisories' && $event->isChanged('interval_hours')) {
$original_interval = $saved_config->getOriginal('interval_hours');
if ($original_interval && $saved_config->get('interval_hours') < $original_interval) {
// If the new interval is less than the original interval, delete the
// stored results.
$this->securityAdvisoriesFetcher->deleteStoredResponse();
}
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
$events[ConfigEvents::SAVE][] = ['onConfigSave'];
return $events;
}
}
<?php
namespace Drupal\system\SecurityAdvisories;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Extension\ModuleExtensionList;
use Drupal\Core\Extension\ProfileExtensionList;
use Drupal\Core\Extension\ThemeExtensionList;
use Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface;
use Drupal\Core\Site\Settings;
use Drupal\Core\Utility\ProjectInfo;
use Drupal\Core\Extension\ExtensionVersion;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;
use GuzzleHttp\RequestOptions;
use Psr\Log\LoggerInterface;
/**
* Defines a service to get security advisories.
*/
final class SecurityAdvisoriesFetcher {
/**
* The key to use to store the advisories feed response.
*/
protected const ADVISORIES_JSON_EXPIRABLE_KEY = 'advisories_response';
/**
* The 'system.advisories' configuration.
*
* @var \Drupal\Core\Config\ImmutableConfig
*/
protected $config;
/**
* The HTTP client.
*
* @var \GuzzleHttp\Client
*/
protected $httpClient;
/**
* The expirable key/value store for the advisories JSON response.
*
* @var \Drupal\Core\KeyValueStore\KeyValueStoreExpirableInterface
*/
protected $keyValueExpirable;
/**
* Array of extension lists, keyed by extension type.
*
* @var \Drupal\Core\Extension\ExtensionList[]
*/
protected $extensionLists = [];
/**
* The logger.
*
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
/**
* Whether to fall back to HTTP if the HTTPS request fails.
*
* @var bool
*/
protected $withHttpFallback;
/**
* Constructs a new SecurityAdvisoriesFetcher object.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface $key_value_factory
* The expirable key/value factory.
* @param \GuzzleHttp\Client $client
* The HTTP client.
* @param \Drupal\Core\Extension\ModuleExtensionList $module_list
* The module extension list.
* @param \Drupal\Core\Extension\ThemeExtensionList $theme_list
* The theme extension list.
* @param \Drupal\Core\Extension\ProfileExtensionList $profile_list
* The profile extension list.
* @param \Psr\Log\LoggerInterface $logger
* The logger.
* @param \Drupal\Core\Site\Settings $settings
* The settings instance.
*/
public function __construct(ConfigFactoryInterface $config_factory, KeyValueExpirableFactoryInterface $key_value_factory, Client $client, ModuleExtensionList $module_list, ThemeExtensionList $theme_list, ProfileExtensionList $profile_list, LoggerInterface $logger, Settings $settings) {
$this->config = $config_factory->get('system.advisories');
$this->keyValueExpirable = $key_value_factory->get('system');
$this->httpClient = $client;
$this->extensionLists['module'] = $module_list;
$this->extensionLists['theme'] = $theme_list;
$this->extensionLists['profile'] = $profile_list;
$this->logger = $logger;
$this->withHttpFallback = $settings->get('update_fetch_with_http_fallback', FALSE);
}
/**
* Gets security advisories that are applicable for the current site.
*
* @param bool $allow_outgoing_request
* (optional) Whether to allow an outgoing request to fetch the advisories
* if there is no stored JSON response. Defaults to TRUE.
* @param int $timeout
* (optional) The timeout in seconds for the request. Defaults to 0, which
* is no timeout.
*
* @return \Drupal\system\SecurityAdvisories\SecurityAdvisory[]|null
* The upstream security advisories, if any. NULL if there was a problem
* retrieving the JSON feed, or if there was no stored response and
* $allow_outgoing_request was set to FALSE.
*
* @throws \GuzzleHttp\Exception\TransferException
* Thrown if an error occurs while retrieving security advisories.
*/
public function getSecurityAdvisories(bool $allow_outgoing_request = TRUE, int $timeout = 0): ?array {
$advisories = [];
$json_payload = $this->keyValueExpirable->get(self::ADVISORIES_JSON_EXPIRABLE_KEY);
// If $json_payload is not an array then it was not set in this method or
// has expired in which case we should try to retrieve the advisories.
if (!is_array($json_payload)) {
if (!$allow_outgoing_request) {
return NULL;
}
$response = $this->doRequest($timeout);
$interval_seconds = $this->config->get('interval_hours') * 60 * 60;
$json_payload = Json::decode($response);
if (is_array($json_payload)) {
// Only store and use the response if it could be successfully
// decoded to an array from the JSON string.
// This value will be deleted if the 'advisories.interval_hours' config
// is changed to a lower value.
// @see \Drupal\update\EventSubscriber\ConfigSubscriber::onConfigSave()
$this->keyValueExpirable->setWithExpire(self::ADVISORIES_JSON_EXPIRABLE_KEY, $json_payload, $interval_seconds);
}
else {
$this->logger->error('The security advisory JSON feed from Drupal.org could not be decoded.');
return NULL;
}
}
foreach ($json_payload as $advisory_data) {
try {
$sa = SecurityAdvisory::createFromArray($advisory_data);
}
catch (\UnexpectedValueException $unexpected_value_exception) {
// Ignore items in the feed that are in an invalid format. Although
// this is highly unlikely we should still display the items that are
// in the correct format.
watchdog_exception('system', $unexpected_value_exception, 'Invalid security advisory format: ' . Json::encode($advisory_data));
continue;
}
if ($this->isApplicable($sa)) {
$advisories[] = $sa;
}
}
return $advisories;
}
/**
* Deletes the stored JSON feed response, if any.
*/
public function deleteStoredResponse(): void {
$this->keyValueExpirable->delete(self::ADVISORIES_JSON_EXPIRABLE_KEY);
}
/**
* Determines if an advisory matches the existing version of a project.
*
* @param \Drupal\system\SecurityAdvisories\SecurityAdvisory $sa
* The security advisory.
*
* @return bool
* TRUE if the security advisory matches the existing version of the
* project, or FALSE otherwise.
*/
protected function matchesExistingVersion(SecurityAdvisory $sa): bool {
if ($existing_version = $this->getProjectExistingVersion($sa)) {
$existing_project_version = ExtensionVersion::createFromVersionString($existing_version);
$insecure_versions = $sa->getInsecureVersions();
// If a site codebase has a development version of any project, including
// core, we cannot be certain if their development build has the security
// vulnerabilities that make any of the versions in $insecure_versions
// insecure. Therefore, we should err on the side of assuming the site's
// code does have the security vulnerabilities and show the advisories.
// This will result in some sites seeing advisories that do not affect
// their versions, but it will make it less likely that sites with the
// security vulnerabilities will not see the advisories.
if ($existing_project_version->getVersionExtra() === 'dev') {
foreach ($insecure_versions as $insecure_version) {
try {
$insecure_project_version = ExtensionVersion::createFromVersionString($insecure_version);
}
catch (\UnexpectedValueException $exception) {
// An invalid version string should not halt the evaluation of valid
// versions in $insecure_versions. Version numbers that start with
// core prefix besides '8.x-' are allowed in $insecure_versions,
// but will never match and will throw an exception.
continue;
}
if ($existing_project_version->getMajorVersion() === $insecure_project_version->getMajorVersion()) {
if ($existing_project_version->getMinorVersion() === NULL) {
// If the dev version doesn't specify a minor version, matching on
// the major version alone is considered a match.
return TRUE;
}
if ($existing_project_version->getMinorVersion() === $insecure_project_version->getMinorVersion()) {
// If the dev version specifies a minor version, then the insecure
// version must match on the minor version.
return TRUE;
}
}
}
}
else {
// If the existing version is not a dev version, then it must match an
// insecure version exactly.
return in_array($existing_version, $insecure_versions, TRUE);
}
}
return FALSE;
}
/**
* Gets the information for an extension affected by the security advisory.
*
* @param \Drupal\system\SecurityAdvisories\SecurityAdvisory $sa
* The security advisory.
*
* @return mixed[]|null
* The information as set in the info.yml file and then processed by the
* corresponding extension list for the first extension found that matches
* the project name of the security advisory. If no matching extension is
* found NULL is returned.
*/
protected function getMatchingExtensionInfo(SecurityAdvisory $sa): ?array {
if (!isset($this->extensionLists[$sa->getProjectType()])) {
return NULL;
}
$project_info = new ProjectInfo();
// The project name on the security advisory will not always match the
// machine name for the extension, so we need to search through all
// extensions of the expected type to find the matching project.
foreach ($this->extensionLists[$sa->getProjectType()]->getList() as $extension) {
if ($project_info->getProjectName($extension) === $sa->getProject()) {
return $extension->info;
}
}
return NULL;
}
/**
* Gets the existing project version.
*
* @param \Drupal\system\SecurityAdvisories\SecurityAdvisory $sa
* The security advisory.
*
* @return string|null
* The project version, or NULL if the project does not exist on
* the site.
*/
protected function getProjectExistingVersion(SecurityAdvisory $sa): ?string {
if ($sa->isCoreAdvisory()) {
return \Drupal::VERSION;
}
$extension_info = $this->getMatchingExtensionInfo($sa);
return $extension_info['version'] ?? NULL;
}
/**
* Determines if a security advisory is applicable for the current site.
*
* @param \Drupal\system\SecurityAdvisories\SecurityAdvisory $sa
* The security advisory.
*
* @return bool
* TRUE if the advisory is applicable for the current site, or FALSE
* otherwise.
*/
protected function isApplicable(SecurityAdvisory $sa): bool {
// Only projects that are in the site's codebase can be applicable. Core
// will always be in the codebase, and other projects are in the codebase if
// ::getProjectInfo() finds a matching extension for the project name.
if ($sa->isCoreAdvisory() || $this->getMatchingExtensionInfo($sa)) {