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(),
]);
}
}
}
