drupalorg-1.0.x-dev/src/Plugin/QueueWorker/DrupalOrgSecurityIssueWebhookQueueWorker.php

src/Plugin/QueueWorker/DrupalOrgSecurityIssueWebhookQueueWorker.php
<?php

namespace Drupal\drupalorg\Plugin\QueueWorker;

use Drupal\Component\Render\FormattableMarkup;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Mail\MailManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Queue\QueueWorkerBase;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\drupalorg\ProjectService;
use Drupal\drupalorg\Traits\GitLabClientTrait;
use Drupal\drupalorg\UserService;
use Gitlab\ResultPager;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Defines 'drupalorg_security_issue_webhook_queue_worker' queue worker.
 *
 * Run via `drush` like this:
 * `drush queue:run drupalorg_security_issue_webhook_queue_worker`.
 *
 * @QueueWorker(
 *   id = "drupalorg_security_issue_webhook_queue_worker",
 *   title = @Translation("Security Issue Webhook Queue Worker")
 * )
 */
class DrupalOrgSecurityIssueWebhookQueueWorker extends QueueWorkerBase implements ContainerFactoryPluginInterface {

  use StringTranslationTrait;
  use GitLabClientTrait;

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('logger.factory')->get('drupalorg'),
      $container->get('drupalorg.project_service'),
      $container->get('drupalorg.user_service'),
      $container->get('entity_type.manager'),
      $container->get('plugin.manager.mail'),
    );
  }

  /**
   * {@inheritdoc}
   */
  public function __construct(
    array $configuration,
    $plugin_id,
    $plugin_definition,
    protected LoggerInterface $logger,
    protected ProjectService $projectService,
    protected UserService $userService,
    protected EntityTypeManagerInterface $entityTypeManager,
    protected MailManagerInterface $mailManager,
  ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);
  }

  /**
   * {@inheritdoc}
   */
  public function processItem($data) {
    try {
      $gitlab_client = $this->getGitLabClient();
      $project = $gitlab_client->projects()->show($data['project_id']);

      if (!empty($data['iid'])) {
        // Check for issue needing movement to another project.
        if ($this->copyToProject($project, $data['iid'])) {
          // No further processing if moved.
          return;
        }
        if ($data['object_kind'] === 'issue') {
          $this->processIssue($project, $data['iid'], $data);
        }
        elseif ($data['object_kind'] === 'note' && $data['action'] === 'create') {
          // Check for note processing.
          $this->processNote($project, $data['iid'], $data['object_id']);
        }
      }
    }
    catch (\Throwable $e) {
      $this->logger->error('Could process security issue queue item @data. Code @code. Message: @message', [
        '@data' => print_r($data, TRUE),
        '@code' => $e->getCode(),
        '@message' => $e->getMessage(),
      ]);
    }
  }

  /**
   * Move issues in security/issues project with 'project_name:' prefix.
   *
   * @param array $project
   *   GitLab project API response.
   * @param int $iid
   *   Issue ID.
   *
   * @return bool
   *   TRUE if the issue was moved to a new project and should skip further
   *   processing.
   */
  private function copyToProject(array $project, int $iid): bool {
    try {
      $gitlab_client = $this->getGitLabClient();
      $is_from_triage = $project['path_with_namespace'] === 'drupal-security/issues';
      $gitlab_issue = $gitlab_client->issues()->show($project['id'], $iid);

      // Make sure it is confidential.
      if ($is_from_triage && !$gitlab_issue['confidential']) {
        $gitlab_client->issues()->update($gitlab_issue['project_id'], $gitlab_issue['iid'], [
          'confidential' => TRUE,
        ]);
        $gitlab_client->issues()->addNote($gitlab_issue['project_id'], $gitlab_issue['iid'], '**_Always report security issues as confidential!_**');
        // Not moved, but we don’t want to do further processing anyway. The
        // updates will trigger subsequent webhooks.
        return TRUE;
      }

      // Look for project machine name prefix.
      if (!preg_match('/^([^:]*):/', $gitlab_issue['title'], $match)) {
        $this->logger->notice('Not moving security issue @iid: no project prefix', [
          '@iid' => $iid,
        ]);
        return FALSE;
      }
      $project_node = $this->projectService->getProjectByMachineName($match[1]);
      if (empty($project_node)) {
        $this->logger->notice('Not moving security issue @iid: project @name not found', [
          '@iid' => $iid,
          '@name' => $match[1],
        ]);
        return FALSE;
      }

      $project_machine_name = $project_node->get('field_project_machine_name')->value;

      if ($is_from_triage) {
        $name = $iid . '-' . $project_machine_name . '-security';
      }
      elseif (preg_match('/^(?<iid>\d+)-(?<name>.*)-security$/', $project['path'], $match)) {
        // Always move if in drupal-security/issues. Otherwise, do not move if
        // the project machine name has not changed.
        if ($match['name'] === $project_machine_name) {
          return FALSE;
        }
        $name = $match['iid'] . '-' . $project_machine_name . '-security';
      }
      else {
        // Not in triage and not well-formed name.
        $this->logger->warning('Not moving security issue @iid: in unexpected state.', [
          '@iid' => $iid,
        ]);
        return FALSE;
      }

      $repository_info = $this->projectService->getProjectRepositoryInformation($project_node);
      if (empty($repository_info['gitlab_project_id'])) {
        throw new \Exception('GitLab project ID for ' . $project_machine_name . ' not found.');
      }

      // Fork project.
      try {
        if (!empty($gitlab_client->projects()->show('security/' . $name))) {
          $this->logger->notice('Not moving @issue for @project, fork @name already exists', [
            '@issue' => $gitlab_issue['web_url'],
            '@project' => $project_machine_name,
            '@name' => $name,
          ]);
          return FALSE;
        }
      }
      catch (\Throwable $e) {
        // Exception for 404 is expected, checking for project existence.
      }
      $this->logger->notice('Moving @issue for @project (@gitlab_project_id)', [
        '@issue' => $gitlab_issue['web_url'],
        '@project' => $project_machine_name,
        '@gitlab_project_id' => $repository_info['gitlab_project_id'],
      ]);
      $fork_project = $gitlab_client->projects()->fork($repository_info['gitlab_project_id'], [
        'namespace_path' => 'security',
        'path' => $name,
        'name' => $name,
        'visibility' => 'private',
        'mr_default_target_self' => TRUE,
      ]);

      // Add reporter & maintainers.
      if ($project_machine_name !== 'drupal') {
        $members = (new ResultPager($gitlab_client))->fetchAll($gitlab_client->projects(), 'members', [$repository_info['gitlab_project_id']]);
        $member_ids = array_column($members, 'id');
        $member_ids[] = $gitlab_issue['author']['id'];
        foreach ($member_ids as $gitlab_user_id) {
          try {
            $gitlab_client->projects()->addMember($fork_project['id'], $gitlab_user_id, 30);
          }
          catch (\Throwable $e) {
            $this->logger->notice('Failed to add member for security issue @iid. Code @code. Message: @message', [
              '@iid' => $iid,
              '@code' => $e->getCode(),
              '@message' => $e->getMessage(),
            ]);
          }
        }
      }

      // Turn on issues, turn off other features.
      $options = $this->getForkDefaultOptions();
      $options['issues_access_level'] = 'enabled';
      $gitlab_client->projects()->update($fork_project['id'], $options);

      // Move issue.
      $moved_issue = $gitlab_client->issues()->move($gitlab_issue['project_id'], $gitlab_issue['iid'], $fork_project['id']);

      // Lock original issue.
      $close_update = [
        'state_event' => 'close',
      ];
      if ($is_from_triage) {
        $move_note = 'Thanks for reporting this security issue. For Drupal security team triage and working on any needed fixes the issue has been moved. All followups should take place at ' . $moved_issue['web_url'];
        $close_update['discussion_locked'] = TRUE;
      }
      else {
        $move_note = 'Moved security issue to new project. All followups should take place at ' . $moved_issue['web_url'];
      }
      $gitlab_client->issues()->addNote($gitlab_issue['project_id'], $gitlab_issue['iid'], $move_note);
      $gitlab_client->issues()->update($gitlab_issue['project_id'], $gitlab_issue['iid'], $close_update);

      // Comment on new issue.
      if ($is_from_triage) {
        $body = [
          'Thanks for reporting this security issue. Drupal security issues follow a coordinated disclosure policy; if a security fix is needed, the advisory and release are made public at a coordinated time. _Do not discuss this outside of this issue_.',
          'Maintainers: Please help triage this issue by validating if you can reproduce the issue, and evaluating its impact. If a fix is needed, please use this private fork to work on a fix and create a merge request. As the project’s maintainer, you are responsible for making security fixes when needed.',
          'Do not commit any code until directed to do so. Please review [Contacted by the security team. Now what?](https://www.drupal.org/drupal-security-team/contacted-by-the-security-team-now-what)',
        ];
        $gitlab_client->issues()->addNote($moved_issue['project_id'], $moved_issue['iid'], implode(PHP_EOL . PHP_EOL, $body));
      }

      // Update forked project description.
      $gitlab_client->projects()->update($fork_project['id'], [
        'description' => 'For collaboration on ' . $moved_issue['web_url'],
      ]);

      // Update the issue description. Remove confidential hint & previous
      // project information.
      $issue_description = preg_replace('#^Keep `/confidential` above#m', '', $moved_issue['description']);
      $issue_description = preg_replace('#---\n\n\*\*Project-.*$#s', '', $issue_description);
      $body = [
        $issue_description,
        '---',
        new FormattableMarkup('**Project-[@machine_name](:url)** / [all security issues for @title](:search_url) / [people with access for this issue](:members_url)', [
          '@machine_name' => $project_node->get('field_project_machine_name')->value,
          ':url' => Url::fromUri('https://www.drupal.org/project/' . $project_node->get('field_project_machine_name')->value)->toString(),
          '@title' => $project_node->getTitle(),
          ':search_url' => Url::fromUri($this->getGitLabUrl() . '/search', [
            'query' => [
              'group_id' => $fork_project['namespace']['id'],
              'scope' => 'issues',
              'search' => '"Project-' . $project_node->get('field_project_machine_name')->value . '"',
            ],
          ])->toString(),
          ':members_url' => Url::fromUri($this->getGitLabUrl() . '/' . $fork_project['path_with_namespace'] . '/-/project_members')->toString(),
        ]),
      ];
      $stable_supported = FALSE;
      if ($supported_branches = $this->projectService->getVersions($project_node, TRUE)) {
        /** @var \Drupal\node\Entity\Node[] $release_nodes */
        $release_nodes = $this->entityTypeManager->getStorage('node')->loadMultiple(array_column($supported_branches, 'recommended_release'));
        $body_supported_branches = [];
        foreach ($supported_branches as $label => $branch) {
          $body_supported_branches[] = new FormattableMarkup('- `@label*` [@title](:url)', [
            '@label' => $label,
            '@title' => $release_nodes[$branch->recommended_release]->getTitle(),
            ':url' => Url::fromUri('https://www.drupal.org/project/' . $project_node->get('field_project_machine_name')->value . '/releases/' . $release_nodes[$branch->recommended_release]->get('field_release_version')->value)->toString(),
          ]);
          if (empty($release_nodes[$branch->recommended_release]->get('field_release_version_extra')->value)) {
            $stable_supported = TRUE;
          }
        }
        $body[] = 'Supported versions:' . PHP_EOL . implode(PHP_EOL, $body_supported_branches);
      }
      if (!$stable_supported) {
        $body[] = '_There are no stable releases that are supported._ An advisory will not be published. Maintainers, you can use this issue to coordinate in private, or you may handle this in a public issue. Unless instructed otherwise, do not mark a release resolving this issue as a security release.';
      }
      $issue_update = [
        'description' => implode(PHP_EOL . PHP_EOL, $body),
      ];
      if ($is_from_triage) {
        $issue_update['labels'] = 'Security status::unvalidated';
      }
      $gitlab_client->issues()->update($moved_issue['project_id'], $moved_issue['iid'], $issue_update);

      // Send emails to maintainers, they may not see GitLab’s notifications.
      if (isset($members)) {
        foreach (array_column($members, 'name') as $member_name) {
          if ($maintainer = $this->userService->getUserByGitUsername($member_name)) {
            $this->mailManager->mail('drupalorg', 'security_issue_opened', $maintainer->getEmail(), $maintainer->getPreferredLangcode(), [
              'maintainer_name' => $maintainer->getDisplayName(),
              'project_node' => $project_node,
              'issue_url' => $moved_issue['web_url'],
            ], 'security@drupal.org');
          }
          else {
            $this->logger->warning('Could not find GitLab user @member_name while moving security issue @issue_url.', [
              '@member_name' => $member_name,
              '@issue_url' => $moved_issue['web_url'],
            ]);
          }
        }
      }
    }
    catch (\Throwable $e) {
      $this->logger->error('Could not move security issue @iid. Code @code. (At @at) Message: @message', [
        '@iid' => $iid,
        '@code' => $e->getCode(),
        '@at' => $e->getFile() . ':' . $e->getLine(),
        '@message' => $e->getMessage(),
      ]);
    }

    return TRUE;
  }

  /**
   * Process a security issue event from GitLab.
   *
   * @param array $project
   *   GitLab project API response.
   * @param int $iid
   *   GitLab issue ID.
   * @param array $data
   *   Queue item data constructed by
   *   WebhooksController::securityIssueWebhook().
   */
  private function processIssue(array $project, int $iid, array $data): void {
    try {
      $gitlab_client = $this->getGitLabClient();
      $note_body = [];
      $issue = $gitlab_client->issues()->show($project['id'], $iid);

      // Check for added Security advisory::needed label.
      if (!empty($data['current_labels']['Security advisory::needed']) && empty($data['previous_labels']['Security advisory::needed'])) {
        $project_node = $this->projectService->getProjectByRepositoryPath($project['forked_from_project']['id']);
        if (empty($project_node)) {
          throw new \Exception('GitLab project ID ' . $project['forked_from_project']['id'] . ' not found.');
        }
        $url = Url::fromUri('https://www.drupal.org/node/add/sa', [
          'query' => [
            'field_project' => $project_node->get('field_project_machine_name')->value,
            'issue' => $issue['web_url'],
          ],
        ]);
        $note_body[] = 'Maintainers, please [draft an advisory for this issue](' . $url->toString() . ')';
      }

      if (!empty($note_body)) {
        $gitlab_client->issues()->addNote($project['id'], $iid, implode(PHP_EOL . PHP_EOL, $note_body));
      }
    }
    catch (\Throwable $e) {
      $this->logger->error('Exception in security issue processing @project @iid. Code @code. Message: @message', [
        '@project' => $project['name'],
        '@iid' => $iid,
        '@code' => $e->getCode(),
        '@message' => $e->getMessage(),
      ]);
    }

  }

  /**
   * Process note added to a confidential security issue in GitLab.
   *
   * @param array $project
   *   GitLab project API response.
   * @param int $iid
   *   Issue ID.
   * @param int $note_id
   *   Note ID.
   */
  private function processNote($project, $iid, $note_id): void {
    try {
      $gitlab_client = $this->getGitLabClient();

      // Fetch note body.
      $note = $gitlab_client->issues()->showNote($project['id'], $iid, $note_id);

      // Check if a security team member posted the note.
      $security_team_gitlab_user_ids = array_column((new ResultPager($gitlab_client))->fetchAll($gitlab_client->groups(), 'allMembers', ['security']), 'id', 'id');
      if (isset($security_team_gitlab_user_ids[$note['author']['id']])) {
        // Extract names for access.
        preg_match_all('#^/access(?<names>.*)$#m', $note['body'], $matches);
        $names_to_add = preg_split('/[\s,]+@?/', implode(' ', $matches['names']), -1, PREG_SPLIT_NO_EMPTY);
        preg_match_all('#^/remove-access(?<names>.*)$#m', $note['body'], $matches);
        $names_to_remove = preg_split('/[\s,]+@?/', implode(' ', $matches['names']), -1, PREG_SPLIT_NO_EMPTY);
        if (empty($names_to_add) && empty($names_to_remove)) {
          return;
        }

        $existing_names = array_column((new ResultPager($gitlab_client))->fetchAll($gitlab_client->projects(), 'members', [$project['id']]), 'id', 'username');
        $added_names = [];
        foreach ($names_to_add as $name) {
          // Check if they already have access.
          if (!empty($existing_names[$name])) {
            continue;
          }

          // Load user, validating the name.
          if ($users = $gitlab_client->users()->all(['username' => $name])) {
            // Add them.
            $gitlab_client->projects()->addMember($project['id'], $users[0]['id'], 30);
            $added_names[] = '@' . $users[0]['username'];
          }
        }
        $removed_names = [];
        foreach ($names_to_remove as $name) {
          if (!empty($existing_names[$name])) {
            $gitlab_client->projects()->removeMember($project['id'], $existing_names[$name]);
            $removed_names[] = '@' . $name;
          }
        }
        if (empty($added_names) && empty($removed_names)) {
          return;
        }

        // Comment to mention additions. Fetch discussion ID of note first.
        $discussions = (new ResultPager($gitlab_client))->fetchAll(
          $gitlab_client->issues(),
          'showDiscussions',
          [$project['id'], (int) $iid]
        );
        foreach ($discussions as $discussion) {
          if (isset($discussion['notes']) && isset(array_column($discussion['notes'], 'id', 'id')[$note_id])) {
            $body = [];
            if (!empty($added_names)) {
              $body[] = "Access granted to " . implode(' ', $added_names);
              $body[] = 'Please review the discussion above and follow up on this security issue.';
            }
            if (!empty($removed_names)) {
              $body[] = "Access removed from " . implode(' ', $removed_names);
            }
            $gitlab_client->issues()->addDiscussionNote($project['id'], $iid, $discussion['id'], implode(PHP_EOL . PHP_EOL, $body));
            break;
          }
        }
      }
    }
    catch (\Throwable $e) {
      $this->logger->error('Exception in security issue note processing @project @iid. Code @code. Message: @message', [
        '@project' => $project['name'],
        '@iid' => $iid,
        '@code' => $e->getCode(),
        '@message' => $e->getMessage(),
      ]);
    }
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc