2 namespace Drush\Commands\pm;
4 use Composer\Semver\Comparator;
5 use Consolidation\AnnotatedCommand\CommandData;
6 use Consolidation\OutputFormatters\StructuredData\RowsOfFields;
7 use Drush\Commands\DrushCommands;
10 use Webmozart\PathUtil\Path;
13 * Check Drupal Composer packages for security updates.
15 class SecurityUpdateCommands extends DrushCommands
21 protected $securityUpdates;
24 * Check Drupal Composer packages for pending security updates.
26 * This uses the Drupal security advisories package to determine if updates
29 * @see https://github.com/drupal-composer/drupal-security-advisories
31 * @command pm:security
32 * @aliases sec,pm-security
34 * @table-style default
37 * version: Installed Version
38 * min-version: Suggested version
39 * @default-fields name,version,min-version
41 * @return \Consolidation\OutputFormatters\StructuredData\RowsOfFields
45 public function security()
47 $this->securityUpdates = [];
48 $security_advisories_composer_json = $this->fetchAdvisoryComposerJson();
49 $composer_lock_data = $this->loadSiteComposerLock();
50 $this->registerAllSecurityUpdates($composer_lock_data, $security_advisories_composer_json);
51 if ($this->securityUpdates) {
53 drush_set_context('DRUSH_EXIT_CODE', DRUSH_FRAMEWORK_ERROR);
54 $result = new RowsOfFields($this->securityUpdates);
57 $this->logger()->success("<info>There are no outstanding security updates for Drupal projects.</info>");
62 * Emit suggested Composer command for security updates.
64 * @hook post-command pm:security
66 public function suggestComposerCommand($result, CommandData $commandData)
68 if (!empty($this->securityUpdates)) {
69 $suggested_command = 'composer require ';
70 foreach ($this->securityUpdates as $package) {
71 $suggested_command .= $package['name'] . ':^' . $package['min-version'] . ' ';
73 $suggested_command .= '--update-with-dependencies';
74 $this->logger()->warning("One or more of your dependencies has an outstanding security update. Please apply update(s) immediately.");
75 $this->logger()->notice("Try running: <comment>$suggested_command</comment>");
76 $this->logger()->notice("If that fails due to a conflict then you must update one or more root dependencies.");
81 * Fetches the generated composer.json from drupal-security-advisories.
87 protected function fetchAdvisoryComposerJson()
90 $response_body = file_get_contents('https://raw.githubusercontent.com/drupal-composer/drupal-security-advisories/8.x/composer.json');
91 } catch (Exception $e) {
92 throw new Exception("Unable to fetch drupal-security-advisories information.");
94 $security_advisories_composer_json = json_decode($response_body, true);
95 return $security_advisories_composer_json;
99 * Loads the contents of the local Drupal application's composer.lock file.
105 protected function loadSiteComposerLock()
107 $composer_root = Drush::bootstrapManager()->getComposerRoot();
108 $composer_lock_file_name = getenv('COMPOSER') ? str_replace(
113 $composer_lock_file_name .= '.lock';
114 $composer_lock_file_path = Path::join(
116 $composer_lock_file_name
118 if (!file_exists($composer_lock_file_path)) {
119 throw new Exception("Cannot find $composer_lock_file_path!");
121 $composer_lock_contents = file_get_contents($composer_lock_file_path);
122 $composer_lock_data = json_decode($composer_lock_contents, true);
123 if (!array_key_exists('packages', $composer_lock_data)) {
124 throw new Exception("No packages were found in $composer_lock_file_path! Contents:\n $composer_lock_contents");
126 return $composer_lock_data;
130 * Register all available security updates in $this->securityUpdates.
131 * @param array $composer_lock_data
132 * The contents of the local Drupal application's composer.lock file.
133 * @param array $security_advisories_composer_json
134 * The composer.json array from drupal-security-advisories.
136 protected function registerAllSecurityUpdates($composer_lock_data, $security_advisories_composer_json)
138 $both = $composer_lock_data['packages-dev'] + $composer_lock_data['packages'];
139 foreach ($both as $package) {
140 $name = $package['name'];
141 $this->registerPackageSecurityUpdates($security_advisories_composer_json, $name, $package);
146 * Determines if update is available based on a conflict constraint.
148 * @param string $conflict_constraint
149 * The constraint for the conflicting, insecure package version.
151 * @param array $package
152 * The package to be evaluated.
153 * @param string $name
154 * The human readable display name for the package.
157 * An associative array containing name, version, and min-version keys.
159 public static function determineUpdatesFromConstraint(
160 $conflict_constraint,
164 // Only parse constraints that follow pattern like "<1.0.0".
165 if (substr($conflict_constraint, 0, 1) == '<') {
166 $min_version = substr($conflict_constraint, 1);
167 if (Comparator::lessThan(
173 'version' => $package['version'],
174 // Assume that conflict constraint of <1.0.0 indicates that
175 // 1.0.0 is the available, secure version.
176 'min-version' => $min_version,
179 } // Compare exact versions that are insecure.
181 '/^[[:digit:]](?![-*><=~ ])/',
184 $exact_version = $conflict_constraint;
185 if (Comparator::equalTo(
189 $version_parts = explode('.', $package['version']);
190 if (count($version_parts) == 3) {
192 $min_version = implode('.', $version_parts);
195 'version' => $package['version'],
196 // Assume that conflict constraint of 1.0.0 indicates that
197 // 1.0.1 is the available, secure version.
198 'min-version' => $min_version,
207 * Registers available security updates for a given package.
209 * @param array $security_advisories_composer_json
210 * The composer.json array from drupal-security-advisories.
211 * @param string $name
212 * The human readable display name for the package.
213 * @param array $package
214 * The package to be evaluated.
216 protected function registerPackageSecurityUpdates(
217 $security_advisories_composer_json,
221 if (empty($this->securityUpdates[$name]) &&
222 !empty($security_advisories_composer_json['conflict'][$name])) {
223 $conflict_constraints = explode(
225 $security_advisories_composer_json['conflict'][$name]
227 foreach ($conflict_constraints as $conflict_constraint) {
228 $available_update = $this->determineUpdatesFromConstraint(
229 $conflict_constraint,
233 if ($available_update) {
234 $this->securityUpdates[$name] = $available_update;