Skip to content

Commit

Permalink
Export advisories in OSV format
Browse files Browse the repository at this point in the history
Fixes #576
  • Loading branch information
jaylinski committed Nov 18, 2021
1 parent 486a92e commit 5d2ade5
Show file tree
Hide file tree
Showing 5 changed files with 271 additions and 18 deletions.
42 changes: 42 additions & 0 deletions .github/workflows/export-osv.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Export to OSV format

on:
push:
branches:
- export-osv

jobs:
publish-web:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
with:
# Required in order to extract dates from commit history
fetch-depth: 0

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.0"
coverage: none
tools: composer

- name: Install dependencies
run: composer install --prefer-dist --no-progress

- name: Export to OSV format
run: |
git config user.name github-actions
git config user.email [email protected]
php export-osv.php export
git add packagist
git stash
git checkout osv
echo `date` > published
git add published
git rm -r --ignore-unmatch packagist
git commit -m "Update OSV data export"
git stash pop
git commit --amend --no-edit --allow-empty
git push
36 changes: 18 additions & 18 deletions .github/workflows/php.yaml
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
name: Validation

on:
push:
pull_request:
push:
pull_request:

jobs:
run:
runs-on: ubuntu-latest
run:
runs-on: ubuntu-latest

name: Validation
steps:
- name: Checkout
uses: actions/checkout@v2
name: Validation
steps:
- name: Checkout
uses: actions/checkout@v2

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.0"
coverage: none
tools: composer
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.0"
coverage: none
tools: composer

- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Install dependencies
run: composer install --prefer-dist --no-progress

- name: Run tests
run: php -d memory_limit=-1 validator.php
- name: Run tests
run: php -d memory_limit=-1 validator.php
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ not** serve as the primary source of information for security issues, it is
not authoritative for any referenced software, but it allows to centralize
information for convenience and easy consumption.

We also export advisory data to the [OSV](https://github.com/ossf/osv-schema) format,
see the [`osv`](https://github.com/FriendsOfPHP/security-advisories/tree/osv) branch.

License
-------

Expand Down
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
"description": "Database of known security vulnerabilities in various PHP projects and libraries",
"require-dev": {
"composer/composer": "~1.0",
"symfony/cache": "^5.3",
"symfony/console": "^4.0",
"symfony/http-client": "^5.3",
"symfony/yaml": "^4.0"
},
"license": "Unlicense",
Expand Down
206 changes: 206 additions & 0 deletions export-osv.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
<?php

/**
* Script for exporting advisories to OSV format.
*
* Usage: `php export-osv.php export target_folder`
*
* @see https://ossf.github.io/osv-schema/
*/

namespace FriendsOfPhp\SecurityAdvisories;

use Composer\Semver\Semver;
use DirectoryIterator;
use FilesystemIterator;
use SplFileInfo;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\Yaml\Yaml;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;

if (!is_file($autoloader = __DIR__ . '/vendor/autoload.php')) {
echo 'Dependencies are not installed, please run "composer install" first!' . PHP_EOL;
exit(1);
}

require $autoloader;

final class ExportOsv extends Command
{
private const OSV_ECOSYSTEM = 'Packagist';
private const OSV_PACKAGE_URL = 'https://packagist.org/packages/';
private const OSV_PREFIX = 'PHPSEC';

protected function configure(): void
{
$this
->setName('export')
->setDescription('Export advisories in OSV format')
->addArgument('target',InputArgument::OPTIONAL, 'Target folder', 'packagist');
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
mkdir($targetFolder = $input->getArgument('target'));

$cache = new FilesystemAdapter();

$namespaceIterator = new DirectoryIterator(__DIR__);

// Package namespaces
foreach ($namespaceIterator as $namespaceInfo) {
if ($namespaceInfo->isDot() || !$namespaceInfo->isDir() || $namespaceInfo->getFilename() === 'vendor' || strpos($namespaceInfo->getFilename() , '.') === 0) continue;

$namespace = $namespaceInfo->getFilename();
$packageIterator = new DirectoryIterator($namespaceInfo->getPathname());

// Packages inside namespace
foreach ($packageIterator as $packageInfo) {
if ($packageIterator->isDot() || !$packageInfo->isDir()) continue;

$package = [
'name' => $namespace . '/' . $packageInfo->getFilename(),
'data' => $this->getPackageData($namespace . '/' . $packageInfo->getFilename(), $cache),
];

if (is_null($package['data'])) {
$output->writeln('Skipped "' . $package['name'] . ' because it was not found on Packagist');
continue;
}

$fileSystemIterator = new FilesystemIterator($packageInfo->getPathname());

$output->write('Converting "' . $package['name'] . '" ...' . str_repeat(' ', 20) . "\r");

foreach ($fileSystemIterator as $fileInfo) {
$osv = $this->convertToOsv($fileInfo, $package);

if (is_null($osv)) {
$output->writeln('Skipped "' . $package['name'] . '/' . $fileInfo->getFilename() . '" because package is not on Packagist');
continue;
}

if (count($osv['affected']['versions']) === 0) {
$output->writeln('Skipped "' . $package['name'] . '/' . $fileInfo->getFilename() . '" because no affected versions are available on Packagist');
continue;
}

$path = $targetFolder . DIRECTORY_SEPARATOR . $osv['id'] . '.json';

file_put_contents($path, json_encode($osv, JSON_PRETTY_PRINT));
}
}
}

$output->writeln('');

// Command::SUCCESS and Command::FAILURE constants were introduced in Symfony 5.1
return 0;
}

private function convertToOsv(SplFileInfo $fileInfo, array $package): ?array
{
$advisory = Yaml::parseFile($fileInfo->getPathname());

// Advisories with custom repositories are currently not supported
if (isset($advisory['composer-repository'])) {
return null;
}

return [
'id' => $advisory['cve'] ?? self::OSV_PREFIX . '-' . $fileInfo->getBasename('.yaml'),
'modified' => self::getDateFromGitLog($fileInfo),
'published' => self::getDateFromGitLog($fileInfo, true),
'aliases' => [],
'related' => [],
'summary' => $advisory['title'] ?? '',
'details' => '',
'affected' => self::getAffected($advisory, $package),
'references' => self::getReferences($advisory, $package['name']),
];
}

private function getPackageData(string $packageName, CacheInterface $cache): ?array
{
return $cache->get($packageName, function () use ($packageName) {
$response = HttpClient::create()->request(
'GET',
'https://repo.packagist.org/p2/' . $packageName . '.json'
);

try {
return $response->toArray();
} catch (HttpExceptionInterface $httpException) {
return null;
}
});
}

private static function getAffected(array $advisory, array $package): array
{
return [
'package' => [
'ecosystem' => self::OSV_ECOSYSTEM,
'name' => $package['name'],
'purl' => sprintf('pkg:packagist/%s', $package['name']),
],
'versions' => self::getVersions($advisory['branches'], $package),
];
}

private static function getDateFromGitLog(SplFileInfo $fileInfo, bool $created = false): string
{
$timestamp = shell_exec(sprintf(
'git log --format="%%at" %s %s %s %s',
$created ? '' : '--max-count 1',
$created ? '--reverse' : '',
escapeshellarg($fileInfo->getPathname()),
$created ? '| head -1' : ''
));

return date('Y-m-d\TH:i:s\Z', (int) trim($timestamp));
}

private static function getVersions(array $branches, array $package): array
{
$branchConstraints = array_column($branches, 'versions');

$versions = array_column($package['data']['packages'][$package['name']], 'version');
$versionsAffected = [];

foreach ($branchConstraints as $constraints) {
foreach (array_reverse($versions) as $version) {
if (Semver::satisfies($version, implode(' ', $constraints))) {
array_push($versionsAffected, $version);
}
}
}

return $versionsAffected;
}

private static function getReferences(array $advisory, string $packageName): array
{
return [
[
'type' => 'ADVISORY',
'url' => $advisory['link'],
],
[
'type' => 'PACKAGE',
'url' => self::OSV_PACKAGE_URL . $packageName,
],
];
}
}

$application = new Application();
$application->add(new ExportOsv());
$application->run();

0 comments on commit 5d2ade5

Please sign in to comment.