Skip to content

Commit

Permalink
Add security advisory endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
GuySartorelli committed Jul 8, 2022
1 parent 36bb97c commit 9b8d9e4
Show file tree
Hide file tree
Showing 5 changed files with 298 additions and 3 deletions.
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -49,4 +50,4 @@
"test": "vendor/bin/phpunit",
"format": "vendor/bin/php-cs-fixer fix --allow-risky=yes"
}
}
}
89 changes: 89 additions & 0 deletions src/PackagistClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Spatie\Packagist;

use Composer\Semver\Semver;
use GuzzleHttp\Client;
use Spatie\Packagist\Exceptions\InvalidArgumentException;

Expand Down Expand Up @@ -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);
Expand Down
98 changes: 98 additions & 0 deletions tests/Integration/PackagistClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
91 changes: 91 additions & 0 deletions tests/Unit/PackagistClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 9b8d9e4

Please sign in to comment.