From 02ea3e0829a15005767c52334d10512282c20e39 Mon Sep 17 00:00:00 2001 From: Guy Sartorelli Date: Fri, 24 Jun 2022 18:12:23 +1200 Subject: [PATCH] Add security advisory endpoint --- README.md | 18 ++++++++- composer.json | 5 ++- src/PackagistClient.php | 89 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 04ef16d..f9d5df4 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ $packagist->searchPackagesByType('composer-plugin'); $packagist->searchPackages('packagist', ['type' => 'library']); ``` -### Pagination +#### Pagination Searching for packages returns a paginated result. You can change the pagination settings by adding more parameters. ```php @@ -89,6 +89,22 @@ $packagist->getPackage('spatie', 'packagist-api'); $packagist->getStatistics(); ``` +### Get security vulnerability advisories +```php +// Get advisories for specific packages +$packages = ['spatie/packagist-api']; +$advisories = $packagist->getAdvisories($packages); + +// Get advisories for specific packages that were updated after some timestamp +// The $packages array can also be ommitted here to get ALL advisories updated after that timestamp +$packages = ['spatie/packagist-api']; +$advisories = $packagist->getAdvisories($packages, strtotime('2 weeks ago')); + +// Get advisories only for specific versions of specific packages +$packages = ['spatie/packagist-api' => '2.0.2']; +$advisories = $packagist->getAdvisories($packages, null, true); +``` + ## Changelog Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. diff --git a/composer.json b/composer.json index bdbadaf..a081a32 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,8 @@ "require": { "php": "^7.3|^8.0", "ext-json": "*", - "guzzlehttp/guzzle": "^7.0" + "guzzlehttp/guzzle": "^7.0", + "composer/semver": "^1.0|^2.0|^3.0" }, "require-dev": { "phpunit/phpunit": "^9.4" @@ -49,4 +50,4 @@ "test": "vendor/bin/phpunit", "format": "vendor/bin/php-cs-fixer fix --allow-risky=yes" } -} \ No newline at end of file +} diff --git a/src/PackagistClient.php b/src/PackagistClient.php index 79da384..6496afe 100644 --- a/src/PackagistClient.php +++ b/src/PackagistClient.php @@ -2,6 +2,7 @@ namespace Spatie\Packagist; +use Composer\Semver\Semver; use GuzzleHttp\Client; use Spatie\Packagist\Exceptions\InvalidArgumentException; @@ -84,6 +85,94 @@ public function getStatistics(): ?array return $this->request('statistics.json'); } + /** + * Get security viulnerability advisories for specific packages and/or which have been updated since some timestamp. + * + * If $filterByVersion is true, the $packages array must have package names as keys and versions as values. + * If it is false, the $packages array can contain package names as values. + * + * @throws InvalidArgumentException if no packages and no updatedSince timestamp are passed in + */ + public function getAdvisories(array $packages = [], ?int $updatedSince = null, bool $filterByVersion = false): array + { + if (count($packages) === 0 && $updatedSince === null) { + throw new InvalidArgumentException( + 'At least one package or an $updatedSince timestamp must be passed in.' + ); + } + + if (count($packages) === 0 && $filterByVersion) { + return []; + } + + // Add updatedSince to query if passed in + $query = []; + if ($updatedSince !== null) { + $query['updatedSince'] = $updatedSince; + } + $options = [ + 'query' => array_filter($query), + ]; + + // Add packages if appropriate + if (count($packages) > 0) { + $content = ['packages' => []]; + foreach ($packages as $package => $version) { + if (is_numeric($package)) { + $package = $version; + } + $content['packages'][] = $package; + } + $options['headers']['Content-type'] = 'application/x-www-form-urlencoded'; + $options['body'] = http_build_query($content); + } + + // Get advisories from API + $response = $this->postRequest('api/security-advisories/', $options); + if ($response === null) { + return []; + } + + $advisories = $response['advisories']; + + if (count($advisories) > 0 && $filterByVersion) { + return $this->filterAdvisories($advisories, $packages); + } + + return $advisories; + } + + private function filterAdvisories(array $advisories, array $packages): array + { + $filteredAdvisories = []; + foreach ($packages as $package => $version) { + // Skip any packages with no declared versions + if (is_numeric($package)) { + continue; + } + // Filter advisories by version + if (array_key_exists($package, $advisories)) { + foreach ($advisories[$package] as $advisory) { + if (Semver::satisfies($version, $advisory['affectedVersions'])) { + $filteredAdvisories[$package][] = $advisory; + } + } + } + } + return $filteredAdvisories; + } + + public function postRequest(string $resource, array $options = [], string $mode = PackagistUrlGenerator::API_MODE): ?array + { + $url = $this->url->make($resource, $mode); + $response = $this->client + ->post($url, $options) + ->getBody() + ->getContents(); + + return json_decode($response, true); + } + public function request(string $resource, array $query = [], string $mode = PackagistUrlGenerator::API_MODE): ?array { $url = $this->url->make($resource, $mode);