Skip to content

Commit 1827365

Browse files
committed
Export advisories in OSV format
Fixes #576
1 parent 486a92e commit 1827365

File tree

5 files changed

+271
-18
lines changed

5 files changed

+271
-18
lines changed

.github/workflows/export-osv.yaml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: Export to OSV format
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
8+
jobs:
9+
publish-web:
10+
runs-on: ubuntu-latest
11+
12+
steps:
13+
- uses: actions/checkout@v2
14+
with:
15+
# Required in order to extract dates from commit history
16+
fetch-depth: 0
17+
18+
- name: Setup PHP
19+
uses: shivammathur/setup-php@v2
20+
with:
21+
php-version: "8.0"
22+
coverage: none
23+
tools: composer
24+
25+
- name: Install dependencies
26+
run: composer install --prefer-dist --no-progress
27+
28+
- name: Export to OSV format
29+
run: |
30+
git config user.name github-actions
31+
git config user.email [email protected]
32+
php export-osv.php export
33+
git add packagist
34+
git stash
35+
git checkout osv
36+
echo `date` > published
37+
git add published
38+
git rm -r --ignore-unmatch packagist
39+
git commit -m "Update OSV data export"
40+
git stash pop
41+
git commit --amend --no-edit --allow-empty
42+
git push

.github/workflows/php.yaml

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,27 @@
11
name: Validation
22

33
on:
4-
push:
5-
pull_request:
4+
push:
5+
pull_request:
66

77
jobs:
8-
run:
9-
runs-on: ubuntu-latest
8+
run:
9+
runs-on: ubuntu-latest
1010

11-
name: Validation
12-
steps:
13-
- name: Checkout
14-
uses: actions/checkout@v2
11+
name: Validation
12+
steps:
13+
- name: Checkout
14+
uses: actions/checkout@v2
1515

16-
- name: Setup PHP
17-
uses: shivammathur/setup-php@v2
18-
with:
19-
php-version: "8.0"
20-
coverage: none
21-
tools: composer
16+
- name: Setup PHP
17+
uses: shivammathur/setup-php@v2
18+
with:
19+
php-version: "8.0"
20+
coverage: none
21+
tools: composer
2222

23-
- name: Install dependencies
24-
run: composer install --prefer-dist --no-progress
23+
- name: Install dependencies
24+
run: composer install --prefer-dist --no-progress
2525

26-
- name: Run tests
27-
run: php -d memory_limit=-1 validator.php
26+
- name: Run tests
27+
run: php -d memory_limit=-1 validator.php

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ not** serve as the primary source of information for security issues, it is
77
not authoritative for any referenced software, but it allows to centralize
88
information for convenience and easy consumption.
99

10+
We also export advisory data to the [OSV](https://github.com/ossf/osv-schema) format,
11+
see the [`osv`](https://github.com/FriendsOfPHP/security-advisories/tree/osv) branch.
12+
1013
License
1114
-------
1215

composer.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
"description": "Database of known security vulnerabilities in various PHP projects and libraries",
44
"require-dev": {
55
"composer/composer": "~1.0",
6+
"symfony/cache": "^5.3",
67
"symfony/console": "^4.0",
8+
"symfony/http-client": "^5.3",
79
"symfony/yaml": "^4.0"
810
},
911
"license": "Unlicense",

export-osv.php

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
<?php
2+
3+
/**
4+
* Script for exporting advisories to OSV format.
5+
*
6+
* Usage: `php export-osv.php export target_folder`
7+
*
8+
* @see https://ossf.github.io/osv-schema/
9+
*/
10+
11+
namespace FriendsOfPhp\SecurityAdvisories;
12+
13+
use Composer\Semver\Semver;
14+
use DirectoryIterator;
15+
use FilesystemIterator;
16+
use SplFileInfo;
17+
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
18+
use Symfony\Component\Console\Application;
19+
use Symfony\Component\Console\Command\Command;
20+
use Symfony\Component\Console\Input\InputArgument;
21+
use Symfony\Component\Console\Input\InputInterface;
22+
use Symfony\Component\Console\Output\OutputInterface;
23+
use Symfony\Component\HttpClient\HttpClient;
24+
use Symfony\Component\Yaml\Yaml;
25+
use Symfony\Contracts\Cache\CacheInterface;
26+
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
27+
28+
if (!is_file($autoloader = __DIR__ . '/vendor/autoload.php')) {
29+
echo 'Dependencies are not installed, please run "composer install" first!' . PHP_EOL;
30+
exit(1);
31+
}
32+
33+
require $autoloader;
34+
35+
final class ExportOsv extends Command
36+
{
37+
private const OSV_ECOSYSTEM = 'Packagist';
38+
private const OSV_PACKAGE_URL = 'https://packagist.org/packages/';
39+
private const OSV_PREFIX = 'PHPSEC';
40+
41+
protected function configure(): void
42+
{
43+
$this
44+
->setName('export')
45+
->setDescription('Export advisories in OSV format')
46+
->addArgument('target',InputArgument::OPTIONAL, 'Target folder', 'packagist');
47+
}
48+
49+
protected function execute(InputInterface $input, OutputInterface $output): int
50+
{
51+
mkdir($targetFolder = $input->getArgument('target'));
52+
53+
$cache = new FilesystemAdapter();
54+
55+
$namespaceIterator = new DirectoryIterator(__DIR__);
56+
57+
// Package namespaces
58+
foreach ($namespaceIterator as $namespaceInfo) {
59+
if ($namespaceInfo->isDot() || !$namespaceInfo->isDir() || $namespaceInfo->getFilename() === 'vendor' || strpos($namespaceInfo->getFilename() , '.') === 0) continue;
60+
61+
$namespace = $namespaceInfo->getFilename();
62+
$packageIterator = new DirectoryIterator($namespaceInfo->getPathname());
63+
64+
// Packages inside namespace
65+
foreach ($packageIterator as $packageInfo) {
66+
if ($packageIterator->isDot() || !$packageInfo->isDir()) continue;
67+
68+
$package = [
69+
'name' => $namespace . '/' . $packageInfo->getFilename(),
70+
'data' => $this->getPackageData($namespace . '/' . $packageInfo->getFilename(), $cache),
71+
];
72+
73+
if (is_null($package['data'])) {
74+
$output->writeln('Skipped "' . $package['name'] . '" because it was not found on Packagist');
75+
continue;
76+
}
77+
78+
$fileSystemIterator = new FilesystemIterator($packageInfo->getPathname());
79+
80+
$output->write('Converting "' . $package['name'] . '" ...' . str_repeat(' ', 20) . "\r");
81+
82+
foreach ($fileSystemIterator as $fileInfo) {
83+
$osv = $this->convertToOsv($fileInfo, $package);
84+
85+
if (is_null($osv)) {
86+
$output->writeln('Skipped "' . $package['name'] . '/' . $fileInfo->getFilename() . '" because package is not on Packagist');
87+
continue;
88+
}
89+
90+
if (count($osv['affected']['versions']) === 0) {
91+
$output->writeln('Skipped "' . $package['name'] . '/' . $fileInfo->getFilename() . '" because no affected versions are available on Packagist');
92+
continue;
93+
}
94+
95+
$path = $targetFolder . DIRECTORY_SEPARATOR . $osv['id'] . '.json';
96+
97+
file_put_contents($path, json_encode($osv, JSON_PRETTY_PRINT));
98+
}
99+
}
100+
}
101+
102+
$output->writeln('');
103+
104+
// Command::SUCCESS and Command::FAILURE constants were introduced in Symfony 5.1
105+
return 0;
106+
}
107+
108+
private function convertToOsv(SplFileInfo $fileInfo, array $package): ?array
109+
{
110+
$advisory = Yaml::parseFile($fileInfo->getPathname());
111+
112+
// Advisories with custom repositories are currently not supported
113+
if (isset($advisory['composer-repository'])) {
114+
return null;
115+
}
116+
117+
return [
118+
'id' => $advisory['cve'] ?? self::OSV_PREFIX . '-' . $fileInfo->getBasename('.yaml'),
119+
'modified' => self::getDateFromGitLog($fileInfo),
120+
'published' => self::getDateFromGitLog($fileInfo, true),
121+
'aliases' => [],
122+
'related' => [],
123+
'summary' => $advisory['title'] ?? '',
124+
'details' => '',
125+
'affected' => self::getAffected($advisory, $package),
126+
'references' => self::getReferences($advisory, $package['name']),
127+
];
128+
}
129+
130+
private function getPackageData(string $packageName, CacheInterface $cache): ?array
131+
{
132+
return $cache->get($packageName, function () use ($packageName) {
133+
$response = HttpClient::create()->request(
134+
'GET',
135+
'https://repo.packagist.org/p2/' . $packageName . '.json'
136+
);
137+
138+
try {
139+
return $response->toArray();
140+
} catch (HttpExceptionInterface $httpException) {
141+
return null;
142+
}
143+
});
144+
}
145+
146+
private static function getAffected(array $advisory, array $package): array
147+
{
148+
return [
149+
'package' => [
150+
'ecosystem' => self::OSV_ECOSYSTEM,
151+
'name' => $package['name'],
152+
'purl' => sprintf('pkg:packagist/%s', $package['name']),
153+
],
154+
'versions' => self::getVersions($advisory['branches'], $package),
155+
];
156+
}
157+
158+
private static function getDateFromGitLog(SplFileInfo $fileInfo, bool $created = false): string
159+
{
160+
$timestamp = shell_exec(sprintf(
161+
'git log --format="%%at" %s %s %s %s',
162+
$created ? '' : '--max-count 1',
163+
$created ? '--reverse' : '',
164+
escapeshellarg($fileInfo->getPathname()),
165+
$created ? '| head -1' : ''
166+
));
167+
168+
return date('Y-m-d\TH:i:s\Z', (int) trim($timestamp));
169+
}
170+
171+
private static function getVersions(array $branches, array $package): array
172+
{
173+
$branchConstraints = array_column($branches, 'versions');
174+
175+
$versions = array_column($package['data']['packages'][$package['name']], 'version');
176+
$versionsAffected = [];
177+
178+
foreach ($branchConstraints as $constraints) {
179+
foreach (array_reverse($versions) as $version) {
180+
if (Semver::satisfies($version, implode(' ', $constraints))) {
181+
array_push($versionsAffected, $version);
182+
}
183+
}
184+
}
185+
186+
return $versionsAffected;
187+
}
188+
189+
private static function getReferences(array $advisory, string $packageName): array
190+
{
191+
return [
192+
[
193+
'type' => 'ADVISORY',
194+
'url' => $advisory['link'],
195+
],
196+
[
197+
'type' => 'PACKAGE',
198+
'url' => self::OSV_PACKAGE_URL . $packageName,
199+
],
200+
];
201+
}
202+
}
203+
204+
$application = new Application();
205+
$application->add(new ExportOsv());
206+
$application->run();

0 commit comments

Comments
 (0)