From 9b8d9e49b935ebdb960363d39623da72d96c0bec 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 ++++++++++++++++++++ tests/Integration/PackagistClientTest.php | 98 +++++++++++++++++++++++ tests/Unit/PackagistClientTest.php | 91 +++++++++++++++++++++ 5 files changed, 298 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..29885cb 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 vulnerability 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); diff --git a/tests/Integration/PackagistClientTest.php b/tests/Integration/PackagistClientTest.php index 5e7f9fc..21040b6 100644 --- a/tests/Integration/PackagistClientTest.php +++ b/tests/Integration/PackagistClientTest.php @@ -142,6 +142,104 @@ public function it_can_get_the_statistics() $this->assertArrayHasKey('downloads', $result['totals']); } + /** @test */ + public function it_can_get_filtered_advisories_by_package_name() + { + $client = $this->client(); + + $result = $client->getAdvisories(['silverstripe/admin' => '1.5.0'], null, true); + + $this->assertArrayHasKey('silverstripe/admin', $result); + $advisory = [ + 'advisoryId' => 'PKSA-zmvy-dmwz-zrvp', + 'packageName' => 'silverstripe/admin', + 'remoteId' => 'silverstripe/admin/CVE-2021-36150.yaml', + 'title' => 'CVE-2021-36150 - Insert from files link text - Reflective (self) Cross Site Scripting', + 'link' => 'https://www.silverstripe.org/download/security-releases/CVE-2021-36150', + 'cve' => 'CVE-2021-36150', + 'affectedVersions' => '>=1.0.0,<1.8.1', + 'source' => 'FriendsOfPHP/security-advisories', + 'reportedAt' => '2021-10-05 05:18:20', + 'composerRepository' => 'https://packagist.org', + 'sources' => [ + [ + 'name' => 'GitHub', + 'remoteId' => 'GHSA-j66h-cc96-c32q', + ], + [ + 'name' => 'FriendsOfPHP/security-advisories', + 'remoteId' => 'silverstripe/admin/CVE-2021-36150.yaml', + ], + ], + ]; + $this->assertContains($advisory, $result['silverstripe/admin']); + } + + /** @test */ + public function it_can_get_unfiltered_advisories_by_package_name() + { + $client = $this->client(); + + $result = $client->getAdvisories(['silverstripe/admin'], null, false); + + $this->assertArrayHasKey('silverstripe/admin', $result); + $advisories = [ + [ + 'advisoryId' => 'PKSA-zmvy-dmwz-zrvp', + 'packageName' => 'silverstripe/admin', + 'remoteId' => 'silverstripe/admin/CVE-2021-36150.yaml', + 'title' => 'CVE-2021-36150 - Insert from files link text - Reflective (self) Cross Site Scripting', + 'link' => 'https://www.silverstripe.org/download/security-releases/CVE-2021-36150', + 'cve' => 'CVE-2021-36150', + 'affectedVersions' => '>=1.0.0,<1.8.1', + 'source' => 'FriendsOfPHP/security-advisories', + 'reportedAt' => '2021-10-05 05:18:20', + 'composerRepository' => 'https://packagist.org', + 'sources' => [ + [ + 'name' => 'GitHub', + 'remoteId' => 'GHSA-j66h-cc96-c32q', + ], + [ + 'name' => 'FriendsOfPHP/security-advisories', + 'remoteId' => 'silverstripe/admin/CVE-2021-36150.yaml', + ], + ], + ], + [ + 'advisoryId' => 'PKSA-wvzh-yq7r-9q1d', + 'packageName' => 'silverstripe/admin', + 'remoteId' => 'silverstripe/admin/SS-2018-004-1.yaml', + 'title' => 'SS-2018-004: XSS Vulnerability via WYSIWYG editor', + 'link' => 'https://www.silverstripe.org/download/security-releases/ss-2018-004/', + 'cve' => null, + 'affectedVersions' => '>=1.0.3,<1.0.4|>=1.1.0,<1.1.1', + 'source' => 'FriendsOfPHP/security-advisories', + 'reportedAt' => '2018-02-01 17:33:07', + 'composerRepository' => 'https://packagist.org', + 'sources' => [ + [ + 'name' => 'FriendsOfPHP/security-advisories', + 'remoteId' => 'silverstripe/admin/SS-2018-004-1.yaml', + ], + ], + ], + ]; + foreach ($advisories as $advisory) { + $this->assertContains($advisory, $result['silverstripe/admin']); + } + } + + /** @test */ + public function it_can_get_advisories_by_timestamp() + { + $client = $this->client(); + + $result = $client->getAdvisories([], 1656670429); + + $this->assertArrayHasKey('microweber/microweber', $result); + } + private function client(): PackagistClient { $http = new Client(); diff --git a/tests/Unit/PackagistClientTest.php b/tests/Unit/PackagistClientTest.php index c9dd33b..5daf7ed 100644 --- a/tests/Unit/PackagistClientTest.php +++ b/tests/Unit/PackagistClientTest.php @@ -6,6 +6,7 @@ use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use ReflectionMethod; use Spatie\Packagist\Exceptions\InvalidArgumentException; use Spatie\Packagist\PackagistClient; use Spatie\Packagist\PackagistUrlGenerator; @@ -147,6 +148,96 @@ public function it_can_get_the_statistics() $this->assertEquals($result, ['result' => 'ok']); } + /** @test */ + public function it_filters_the_advisories_by_package_version() + { + $mock = $this->getMockBuilder(Client::class)->disableOriginalConstructor()->getMock(); + $client = new PackagistClient($mock, new PackagistUrlGenerator()); + $filterAdvisoriesReflection = new ReflectionMethod($client, 'filterAdvisories'); + $filterAdvisoriesReflection->setAccessible(true); + + $packages = [ + 'no-version/package', + 'missing/package' => '1.0.0', + 'matches1/package' => '1.2.3', + 'matches2/package' => '7.0.0', + 'no-match/package' => '3.4.5', + ]; + $advisories = [ + 'no-version/package' => [ + [ + 'title' => 'advisory1', + 'affectedVersions' => '>=1.0.0,<1.8.1', + ], + ], + 'matches1/package' => [ + [ + 'title' => 'advisory2', + 'affectedVersions' => '>=1.0.0,<1.8.1', + ], + [ + 'title' => 'advisory3', + 'affectedVersions' => '>=1.2.3,<1.2.4', + ], + ], + 'matches2/package' => [ + [ + 'title' => 'advisory4', + 'affectedVersions' => '>=1.0.0,<1.8.1', + ], + [ + 'title' => 'advisory5', + 'affectedVersions' => '>=7.0.0,<7.8.1', + ], + ], + 'no-match/package' => [ + [ + 'title' => 'advisory6', + 'affectedVersions' => '>=1.0.0,<1.8.1', + ], + ], + 'not-requested/package' => [ + [ + 'title' => 'advisory7', + 'affectedVersions' => '>=1.0.0,<1.8.1', + ], + ], + ]; + + $expected = [ + 'matches1/package' => [ + [ + 'title' => 'advisory2', + 'affectedVersions' => '>=1.0.0,<1.8.1', + ], + [ + 'title' => 'advisory3', + 'affectedVersions' => '>=1.2.3,<1.2.4', + ], + ], + 'matches2/package' => [ + [ + 'title' => 'advisory5', + 'affectedVersions' => '>=7.0.0,<7.8.1', + ], + ], + ]; + + $filtered = $filterAdvisoriesReflection->invoke($client, $advisories, $packages); + $this->assertEquals($expected, $filtered); + } + + /** @test */ + public function it_throws_exception_on_bad_advisory_inputs() + { + $mock = $this->getMockBuilder(Client::class)->disableOriginalConstructor()->getMock(); + $client = new PackagistClient($mock, new PackagistUrlGenerator()); + + $this->expectException(InvalidArgumentException::class); + + $client->getAdvisories([], null); + } + /** * @param string $url * @param array $query