|  | 
|  | 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