Skip to content

Commit 1b49ea4

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

File tree

4 files changed

+234
-18
lines changed

4 files changed

+234
-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+
- export-osv
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

export-osv.php

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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 DirectoryIterator;
14+
use FilesystemIterator;
15+
use SplFileInfo;
16+
use Symfony\Component\Console\Application;
17+
use Symfony\Component\Console\Command\Command;
18+
use Symfony\Component\Console\Input\InputArgument;
19+
use Symfony\Component\Console\Input\InputInterface;
20+
use Symfony\Component\Console\Output\OutputInterface;
21+
use Symfony\Component\Yaml\Yaml;
22+
23+
if (!is_file($autoloader = __DIR__ . '/vendor/autoload.php')) {
24+
echo 'Dependencies are not installed, please run "composer install" first!' . PHP_EOL;
25+
exit(1);
26+
}
27+
28+
require $autoloader;
29+
30+
final class ExportOsv extends Command
31+
{
32+
private const OSV_ECOSYSTEM = 'Packagist';
33+
private const OSV_PACKAGE_URL = 'https://packagist.org/packages/';
34+
private const OSV_PREFIX = 'PHPSEC';
35+
36+
protected function configure(): void
37+
{
38+
$this
39+
->setName('export')
40+
->setDescription('Export advisories in OSV format')
41+
->addArgument('target',InputArgument::OPTIONAL, 'Target folder', 'packagist');
42+
}
43+
44+
protected function execute(InputInterface $input, OutputInterface $output): int
45+
{
46+
mkdir($targetFolder = $input->getArgument('target'));
47+
48+
$namespaceIterator = new DirectoryIterator(__DIR__);
49+
50+
// Package namespaces
51+
foreach ($namespaceIterator as $namespaceInfo) {
52+
if ($namespaceInfo->isDot() || !$namespaceInfo->isDir() || $namespaceInfo->getFilename() === 'vendor' || strpos($namespaceInfo->getFilename() , '.') === 0) continue;
53+
54+
$namespace = $namespaceInfo->getFilename();
55+
$packageIterator = new DirectoryIterator($namespaceInfo->getPathname());
56+
57+
// Packages inside namespace
58+
foreach ($packageIterator as $packageInfo) {
59+
if ($packageIterator->isDot() || !$packageInfo->isDir()) continue;
60+
61+
$package = $packageInfo->getFilename();
62+
$fileSystemIterator = new FilesystemIterator($packageInfo->getPathname());
63+
64+
$output->write('Converting "' . $namespace . '/' . $package . '" ...' . str_repeat(' ', 20) . "\r");
65+
66+
foreach ($fileSystemIterator as $fileInfo) {
67+
$osv = self::convertToOsv($fileInfo, $namespace . '/' . $package);
68+
69+
if (is_null($osv)) {
70+
$output->writeln('Skipped "' . $namespace . '/' . $package . '/' . $fileInfo->getFilename());
71+
continue;
72+
}
73+
74+
$path = $targetFolder . DIRECTORY_SEPARATOR . $osv['id'] . '.json';
75+
76+
file_put_contents($path, json_encode($osv, JSON_PRETTY_PRINT));
77+
}
78+
}
79+
}
80+
81+
$output->writeln('');
82+
83+
// Command::SUCCESS and Command::FAILURE constants were introduced in Symfony 5.1
84+
return 0;
85+
}
86+
87+
private function convertToOsv(SplFileInfo $fileInfo, string $package): ?array
88+
{
89+
$advisory = Yaml::parseFile($fileInfo->getPathname());
90+
91+
// Advisories with custom repositories are currently not supported
92+
if (isset($advisory['composer-repository'])) {
93+
return null;
94+
}
95+
96+
return [
97+
'id' => $advisory['cve'] ?? self::OSV_PREFIX . '-' . $fileInfo->getBasename('.yaml'),
98+
'modified' => self::getDateFromGitLog($fileInfo),
99+
'published' => self::getDateFromGitLog($fileInfo, true),
100+
'aliases' => [],
101+
'related' => [],
102+
'summary' => $advisory['title'] ?? '',
103+
'details' => '',
104+
'affected' => self::getAffected($advisory, $package),
105+
'references' => self::getReferences($advisory, $package),
106+
];
107+
}
108+
109+
private static function getAffected(array $advisory, string $package): array
110+
{
111+
return [
112+
'package' => [
113+
'ecosystem' => self::OSV_ECOSYSTEM,
114+
'name' => $package,
115+
'purl' => sprintf('pkg:packagist/%s', $package),
116+
],
117+
'ranges' => [
118+
'type' => 'SEMVER',
119+
'events' => self::getEvents($advisory['branches']),
120+
],
121+
];
122+
}
123+
124+
private static function getDateFromGitLog(SplFileInfo $fileInfo, bool $created = false): string
125+
{
126+
$timestamp = shell_exec(sprintf(
127+
'git log --format="%%at" %s %s %s %s',
128+
$created ? '' : '--max-count 1',
129+
$created ? '--reverse' : '',
130+
escapeshellarg($fileInfo->getPathname()),
131+
$created ? '| head -1' : ''
132+
));
133+
134+
return date('Y-m-d\TH:i:s\Z', (int) trim($timestamp));
135+
}
136+
137+
private static function getEvents(array $branches): array
138+
{
139+
$events = [];
140+
141+
foreach (array_column($branches, 'versions') as $branch) {
142+
if (count($branch) === 2) {
143+
array_push($events, ['introduced' => $branch[0]]); // TODO Parse Semver and fetch version
144+
array_push($events, ['fixed' => $branch[1]]); // TODO Parse Semver and fetch version
145+
} else {
146+
array_push($events, ['introduced' => '0']);
147+
array_push($events, ['fixed' => $branch[0]]); // TODO Parse Semver and fetch version
148+
}
149+
}
150+
151+
return $events;
152+
}
153+
154+
private static function getReferences(array $advisory, string $package): array
155+
{
156+
return [
157+
[
158+
'type' => 'PACKAGE',
159+
'url' => self::OSV_PACKAGE_URL . $package,
160+
],
161+
[
162+
'type' => 'ADVISORY',
163+
'url' => $advisory['link'],
164+
],
165+
];
166+
}
167+
}
168+
169+
$application = new Application();
170+
$application->add(new ExportOsv());
171+
$application->run();

0 commit comments

Comments
 (0)