Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions bin/console
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
4 changes: 4 additions & 0 deletions src/Command/AbstractCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
108 changes: 108 additions & 0 deletions src/Command/FetchSecurityAdvisoriesCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php

namespace PrestaShop\Traces\Command;

use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class FetchSecurityAdvisoriesCommand extends AbstractCommand
{
/**
* @var array<string>
*/
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));
}
}
119 changes: 119 additions & 0 deletions src/Command/GenerateTopSecurityCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

namespace PrestaShop\Traces\Command;

use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class GenerateTopSecurityCommand extends AbstractCommand
{
// Only research-side credits are ranked here. Remediation credits
// (remediation_developer / reviewer / verifier) go to core developers who
// are already surfaced in the PR and commit leaderboards; counting them
// again would double-credit them and bury the security researchers this
// board exists to highlight.
protected const RESEARCH_TYPES = ['reporter', 'finder', 'analyst', 'coordinator'];

protected function configure(): void
{
$this->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<array{ghsa_id:string|null, credits:array<array{login:string, avatar_url:string|null, html_url:string|null, type:string|null}>}> $advisories */
$advisories = json_decode(file_get_contents(self::FILE_SECURITY_ADVISORIES) ?: '', true);
/** @var array<string, mixed> $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<array{ghsa_id:string|null, credits:array<array{login:string, avatar_url:string|null, html_url:string|null, type:string|null}>}> $advisories
* @param array<string, mixed> $contributors
*
* @return array{updatedAt: string, items: array<array{rank:int, login:string, name:string, avatar_url:string, html_url:string, count:int}>}
*/
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];
}
}
31 changes: 31 additions & 0 deletions src/Service/Github.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<array<string, mixed>>
*/
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<array<string, mixed>> $content */
$content = ResponseMediator::getContent($response);
$advisories = array_merge($advisories, $content);
++$page;
} while (count($content) === 100);

return $advisories;
}
}