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