SecurityAdvisoriesFetcher.php

Same filename in other branches
  1. 9 core/modules/system/src/SecurityAdvisories/SecurityAdvisoriesFetcher.php
  2. 11.x core/modules/system/src/SecurityAdvisories/SecurityAdvisoriesFetcher.php

Namespace

Drupal\system\SecurityAdvisories

File

core/modules/system/src/SecurityAdvisories/SecurityAdvisoriesFetcher.php

View source
<?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\Error;
use Drupal\Core\Utility\ProjectInfo;
use Drupal\Core\Extension\ExtensionVersion;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\RequestOptions;
use Psr\Http\Client\ClientExceptionInterface;
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\ClientInterface $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, ClientInterface $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 \Psr\Http\Client\ClientExceptionInterface
     *   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.
                Error::logException($this->logger, $unexpected_value_exception, 'Invalid security advisory format: @advisory', [
                    '@advisory' => 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)) {
            // Public service announcements are always applicable because they are not
            // dependent on the version of the project that is currently present on
            // the site. Other advisories are only applicable if they match the
            // existing version.
            return $sa->isPsa() || $this->matchesExistingVersion($sa);
        }
        return FALSE;
    }
    
    /**
     * Makes an HTTPS GET request, with a possible HTTP fallback.
     *
     * This method will fall back to HTTP if the HTTPS request fails and the site
     * setting 'update_fetch_with_http_fallback' is set to TRUE.
     *
     * @param int $timeout
     *   The timeout in seconds for the request.
     *
     * @return string
     *   The response.
     */
    protected function doRequest(int $timeout) : string {
        $options = [
            RequestOptions::TIMEOUT => $timeout,
        ];
        if (!$this->withHttpFallback) {
            // If not using an HTTP fallback just use HTTPS and do not catch any
            // exceptions.
            $response = $this->httpClient
                ->get('https://updates.drupal.org/psa.json', $options);
        }
        else {
            try {
                $response = $this->httpClient
                    ->get('https://updates.drupal.org/psa.json', $options);
            } catch (ClientExceptionInterface $exception) {
                Error::logException($this->logger, $exception);
                $response = $this->httpClient
                    ->get('http://updates.drupal.org/psa.json', $options);
            }
        }
        return (string) $response->getBody();
    }

}

Classes

Title Deprecated Summary
SecurityAdvisoriesFetcher Defines a service to get security advisories.

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