From 77d264973b670a9c2897523344710592c991033f Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Mon, 6 Jul 2026 10:25:21 +0200 Subject: [PATCH] feat: security researchers leaderboard (fetch + generate commands) Fetch published GitHub security advisories and their accepted credits across the organization, then build top_security.json ranking the people credited as security researchers (finder, reporter, analyst) on those advisories. Remediation credits are deliberately excluded: the core developers who ship the fix are already counted in the PR and commit leaderboards, so crediting them here would double-count them and bury the researchers this board is meant to surface. Co-Authored-By: Claude Fable 5 --- Makefile | 4 + README.md | 8 ++ bin/console | 4 + src/Command/AbstractCommand.php | 4 + .../FetchSecurityAdvisoriesCommand.php | 108 ++++++++++++++++ src/Command/GenerateTopSecurityCommand.php | 119 ++++++++++++++++++ src/Service/Github.php | 31 +++++ 7 files changed, 278 insertions(+) create mode 100644 src/Command/FetchSecurityAdvisoriesCommand.php create mode 100644 src/Command/GenerateTopSecurityCommand.php diff --git a/Makefile b/Makefile index fe9a76f..1ac5c0f 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,8 @@ clean: rm top_reviewers.json rm top_issues.json rm top_pullrequests.json + rm gh_security_advisories.json + rm top_security.json generate: php bin/console traces:fetch:repositories --config="./config.dist.yml" @@ -22,3 +24,5 @@ generate: php bin/console traces:generate:newcontributors --limitNew=10 --config="./config.dist.yml" php bin/console traces:generate:topcompanies --config="./config.dist.yml" php bin/console traces:generate:topstats --config="./config.dist.yml" + php bin/console traces:fetch:security-advisories + php bin/console traces:generate:topsecurity --config="./config.dist.yml" diff --git a/README.md b/README.md index 9181d9f..a74db1a 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,14 @@ The authentication use a Github Token. $ php bin/console traces:generate:topstats --config="config.yml" ## Files top_reviewers.json, top_issues.json, top_pullrequests.json are generated ## and contributors_prs.json is enriched with reviews / issuesOpened / pullRequestsOpened + + # 9- Fetch published security advisories and their credits + $ php bin/console traces:fetch:security-advisories + ## A file gh_security_advisories.json is generated + + # 10- Generate the security researchers leaderboard (research credits only) + $ php bin/console traces:generate:topsecurity --config="config.yml" + ## A file top_security.json is generated ``` ## Configuring diff --git a/bin/console b/bin/console index 923776d..d9b3fc9 100644 --- a/bin/console +++ b/bin/console @@ -7,8 +7,10 @@ use PrestaShop\Traces\Command\FetchIssuesCommand; use PrestaShop\Traces\Command\FetchPullRequestsAllCommand; use PrestaShop\Traces\Command\FetchPullRequestsMergedCommand; use PrestaShop\Traces\Command\FetchRepositoriesCommand; +use PrestaShop\Traces\Command\FetchSecurityAdvisoriesCommand; use PrestaShop\Traces\Command\GenerateNewContributorsCommand; use PrestaShop\Traces\Command\GenerateTopCompaniesCommand; +use PrestaShop\Traces\Command\GenerateTopSecurityCommand; use PrestaShop\Traces\Command\GenerateTopStatsCommand; use Symfony\Component\Console\Application; use Symfony\Component\Dotenv\Dotenv; @@ -27,7 +29,9 @@ $app->addCommand(new FetchIssuesCommand()); $app->addCommand(new FetchPullRequestsAllCommand()); $app->addCommand(new FetchPullRequestsMergedCommand()); $app->addCommand(new FetchRepositoriesCommand()); +$app->addCommand(new FetchSecurityAdvisoriesCommand()); $app->addCommand(new GenerateNewContributorsCommand()); $app->addCommand(new GenerateTopCompaniesCommand()); +$app->addCommand(new GenerateTopSecurityCommand()); $app->addCommand(new GenerateTopStatsCommand()); $app->run(); diff --git a/src/Command/AbstractCommand.php b/src/Command/AbstractCommand.php index 81926ea..08f84ed 100644 --- a/src/Command/AbstractCommand.php +++ b/src/Command/AbstractCommand.php @@ -35,6 +35,10 @@ class AbstractCommand extends Command protected const FILE_TOP_PULLREQUESTS = 'top_pullrequests.json'; + protected const FILE_SECURITY_ADVISORIES = 'gh_security_advisories.json'; + + protected const FILE_TOP_SECURITY = 'top_security.json'; + protected const FILE_GHLOGIN_WO_COMPANY = 'gh_loginsWOCompany.json'; protected const FILE_DATA_COMPANIES = 'var/data/companies.json'; diff --git a/src/Command/FetchSecurityAdvisoriesCommand.php b/src/Command/FetchSecurityAdvisoriesCommand.php new file mode 100644 index 0000000..2df6425 --- /dev/null +++ b/src/Command/FetchSecurityAdvisoriesCommand.php @@ -0,0 +1,108 @@ + + */ + protected array $orgRepositories = []; + + protected function configure(): void + { + $this->setName('traces:fetch:security-advisories') + ->setDescription('Fetch published security advisories and their credits from Github') + ->addOption( + 'ghtoken', + null, + InputOption::VALUE_OPTIONAL, + '', + isset($_ENV['GH_TOKEN']) ? (string) $_ENV['GH_TOKEN'] : null + ) + ->addOption('repository', 'r', InputOption::VALUE_OPTIONAL, 'GitHub repository'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + parent::execute($input, $output); + + if (!file_exists(self::FILE_REPOSITORIES)) { + $this->output->writeLn(self::FILE_REPOSITORIES . ' is missing. Please execute `php bin/console traces:fetch:repositories`'); + + return 1; + } + + $repository = $input->getOption('repository'); + if (!empty($repository)) { + $this->orgRepositories = [$repository]; + } else { + $this->orgRepositories = json_decode(file_get_contents(self::FILE_REPOSITORIES) ?: '', true); + } + + $time = time(); + $this->output->writeLn([count($this->orgRepositories) . ' repositories to scan.']); + $this->fetchOrgSecurityAdvisories(); + $this->output->writeLn(['', 'Output generated in ' . (time() - $time) . 's.']); + + return 0; + } + + protected function fetchOrgSecurityAdvisories(): void + { + $advisories = []; + if (file_exists(self::FILE_SECURITY_ADVISORIES)) { + unlink(self::FILE_SECURITY_ADVISORIES); + } + + foreach ($this->orgRepositories as $repository) { + $nodes = $this->github->getRepoSecurityAdvisories($repository); + + foreach ($nodes as $node) { + // Withdrawn advisories no longer stand + if (!empty($node['withdrawn_at'])) { + continue; + } + + $credits = []; + foreach ($node['credits_detailed'] ?? [] as $credit) { + if (($credit['state'] ?? '') !== 'accepted') { + continue; + } + $user = $credit['user'] ?? []; + if (empty($user['login'])) { + continue; + } + $credits[] = [ + 'login' => $user['login'], + 'avatar_url' => $user['avatar_url'] ?? null, + 'html_url' => $user['html_url'] ?? null, + 'type' => $credit['type'] ?? null, + ]; + } + + $advisories[] = [ + 'ghsa_id' => $node['ghsa_id'] ?? null, + 'cve_id' => $node['cve_id'] ?? null, + 'summary' => $node['summary'] ?? null, + 'severity' => $node['severity'] ?? null, + 'published_at' => $node['published_at'] ?? null, + 'html_url' => $node['html_url'] ?? null, + 'repository' => $repository, + 'credits' => $credits, + ]; + } + + $this->output->writeLn([ + 'Repository : PrestaShop/' . $repository + . ' > Advisories: ' . count($nodes) + . ' - Total: ' . count($advisories)]); + } + + file_put_contents(self::FILE_SECURITY_ADVISORIES, json_encode($advisories, JSON_PRETTY_PRINT)); + } +} diff --git a/src/Command/GenerateTopSecurityCommand.php b/src/Command/GenerateTopSecurityCommand.php new file mode 100644 index 0000000..45d98a3 --- /dev/null +++ b/src/Command/GenerateTopSecurityCommand.php @@ -0,0 +1,119 @@ +setName('traces:generate:topsecurity') + ->setDescription('Generate the security researchers leaderboard from fetched advisory credits') + ->addOption( + 'ghtoken', + null, + InputOption::VALUE_OPTIONAL, + '', + isset($_ENV['GH_TOKEN']) ? (string) $_ENV['GH_TOKEN'] : null + ) + ->addOption('config', 'c', InputOption::VALUE_OPTIONAL, '', 'config.dist.yml'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + // Identity comes from the fetched advisories (and contributors_prs.json + // when available); no GitHub API call is made. --ghtoken is kept for + // pipeline compatibility only. + parent::execute($input, $output); + + if (!file_exists(self::FILE_SECURITY_ADVISORIES)) { + $this->output->writeLn(self::FILE_SECURITY_ADVISORIES . ' is missing. Please execute `php bin/console traces:fetch:security-advisories`'); + + return 1; + } + + $this->fetchConfiguration($input->getOption('config')); + + /** @var array}> $advisories */ + $advisories = json_decode(file_get_contents(self::FILE_SECURITY_ADVISORIES) ?: '', true); + /** @var array $contributors */ + $contributors = file_exists(self::FILE_CONTRIBUTORS_PRS) + ? json_decode(file_get_contents(self::FILE_CONTRIBUTORS_PRS) ?: '', true) + : []; + + file_put_contents(self::FILE_TOP_SECURITY, json_encode($this->buildRanking($advisories, $contributors), JSON_PRETTY_PRINT)); + + $this->output->writeLn(['', 'Top security generated.']); + + return 0; + } + + /** + * @param array}> $advisories + * @param array $contributors + * + * @return array{updatedAt: string, items: array} + */ + public function buildRanking(array $advisories, array $contributors): array + { + // Count, per login, the distinct advisories where they hold a research + // credit. Remediation-only contributors are intentionally left out (see + // RESEARCH_TYPES). + $counts = []; + $identities = []; + foreach ($advisories as $advisory) { + $researchLogins = []; + foreach ($advisory['credits'] as $credit) { + if (!in_array($credit['type'] ?? '', self::RESEARCH_TYPES, true)) { + continue; + } + $login = $credit['login']; + $researchLogins[$login] = true; + if (!isset($identities[$login])) { + $identities[$login] = [ + 'avatar_url' => $credit['avatar_url'] ?? '', + 'html_url' => $credit['html_url'] ?? 'https://github.com/' . $login, + ]; + } + } + foreach (array_keys($researchLogins) as $login) { + $counts[$login] = ($counts[$login] ?? 0) + 1; + } + } + + $logins = array_keys($counts); + usort($logins, function (string $a, string $b) use ($counts): int { + return ($counts[$b] <=> $counts[$a]) ?: strcmp($a, $b); + }); + + $items = []; + $rank = 1; + foreach ($logins as $login) { + if (!$this->configKeepExcludedUsers && in_array($login, $this->configExclusions, true)) { + continue; + } + $contributor = isset($contributors[$login]) && is_array($contributors[$login]) ? $contributors[$login] : []; + $items[] = [ + 'rank' => $rank, + 'login' => $login, + 'name' => (string) ($contributor['name'] ?? $login), + 'avatar_url' => (string) ($contributor['avatar_url'] ?? $identities[$login]['avatar_url']), + 'html_url' => (string) ($contributor['html_url'] ?? $identities[$login]['html_url']), + 'count' => $counts[$login], + ]; + ++$rank; + } + + return ['updatedAt' => date('Y-m-d'), 'items' => $items]; + } +} diff --git a/src/Service/Github.php b/src/Service/Github.php index 6a81e7a..308f643 100644 --- a/src/Service/Github.php +++ b/src/Service/Github.php @@ -8,6 +8,7 @@ use Github\Api\User; use Github\Client; use Github\Exception\RuntimeException; +use Github\HttpClient\Message\ResponseMediator; use Github\ResultPager; use League\Flysystem\Adapter\Local; use League\Flysystem\Filesystem; @@ -71,4 +72,34 @@ public function getUser(string $login): array return $clientUser->show($login); } + + /** + * Published security advisories of a repository (the REST endpoint only + * exposes published ones to non-admin tokens). + * + * @return array> + */ + public function getRepoSecurityAdvisories(string $repository): array + { + $advisories = []; + $page = 1; + do { + try { + $response = $this->client->getHttpClient()->get(sprintf( + '/repos/PrestaShop/%s/security-advisories?per_page=100&page=%d', + rawurlencode($repository), + $page + )); + } catch (RuntimeException $e) { + // Repositories with advisories disabled answer 404 + return $advisories; + } + /** @var array> $content */ + $content = ResponseMediator::getContent($response); + $advisories = array_merge($advisories, $content); + ++$page; + } while (count($content) === 100); + + return $advisories; + } }