Skip to content

Commit 0c5c7e2

Browse files
Merge pull request #86 from creative-commoners/pulls/master/add-advisory-endpoint
New: Add security advisories endpoint.
2 parents 7378342 + b9f74e2 commit 0c5c7e2

File tree

8 files changed

+417
-4
lines changed

8 files changed

+417
-4
lines changed

composer.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@
1616
"guzzlehttp/guzzle": "^6.0 || ^7.0",
1717
"doctrine/inflector": "^1.0 || ^2.0",
1818
"ext-json": "*",
19-
"composer/metadata-minifier": "^1.0"
20-
},
19+
"composer/metadata-minifier": "^1.0",
20+
"composer/semver": "^1.0|^2.0|^3.0"
21+
},
2122
"require-dev": {
2223
"phpspec/phpspec": "^6.0 || ^7.0",
2324
"squizlabs/php_codesniffer": "^3.0"

examples/advisories.php

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
require __DIR__ . '/../vendor/autoload.php';
4+
5+
$client = new Packagist\Api\Client();
6+
7+
// Get any advisories for the monolog/monolog package
8+
$advisories = $client->advisories(['monolog/monolog']);
9+
var_export($advisories);
10+
11+
// Get any advisories for the monolog/monolog package which were modified after midnight 2022/07/2022.
12+
$advisories = $client->advisories(['monolog/monolog' => '1.8.1'], 1659052800);
13+
var_export($advisories);
14+
15+
// Get any advisories for the monolog/monolog package which will affect version 1.8.1 of that package
16+
$advisories = $client->advisories(['monolog/monolog' => '1.8.1'], null, true);
17+
var_export($advisories);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace spec\Packagist\Api\Result\Advisory;
6+
7+
use Packagist\Api\Result\Advisory\Source;
8+
use PhpSpec\ObjectBehavior;
9+
10+
class SourceSpec extends ObjectBehavior
11+
{
12+
public function let()
13+
{
14+
$this->fromArray([
15+
'name' => 'FriendsOfPHP/security-advisories',
16+
'remoteId' => 'monolog/monolog/2014-12-29-1.yaml',
17+
]);
18+
}
19+
20+
public function it_is_initializable()
21+
{
22+
$this->shouldHaveType(Source::class);
23+
}
24+
25+
public function it_gets_name()
26+
{
27+
$this->getName()->shouldReturn('FriendsOfPHP/security-advisories');
28+
}
29+
30+
public function it_gets_remote_id()
31+
{
32+
$this->getRemoteId()->shouldReturn('monolog/monolog/2014-12-29-1.yaml');
33+
}
34+
}
+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace spec\Packagist\Api\Result;
6+
7+
use Packagist\Api\Result\AbstractResult;
8+
use Packagist\Api\Result\Advisory;
9+
use Packagist\Api\Result\Advisory\Source;
10+
use PhpSpec\ObjectBehavior;
11+
12+
class AdvisorySpec extends ObjectBehavior
13+
{
14+
private $source;
15+
16+
private function data()
17+
{
18+
return [
19+
'advisoryId' => 'PKSA-dmw8-jd8k-q3c6',
20+
'packageName' => 'monolog/monolog',
21+
'remoteId' => 'monolog/monolog/2014-12-29-1.yaml',
22+
'title' => 'Header injection in NativeMailerHandler',
23+
'link' => 'https://github.com/Seldaek/monolog/pull/448#issuecomment-68208704',
24+
'cve' => 'test-value',
25+
'affectedVersions' => '>=1.8.0,<1.12.0',
26+
'sources' => [$this->source],
27+
'reportedAt' => '2014-12-29 00:00:00',
28+
'composerRepository' => 'https://packagist.org',
29+
];
30+
}
31+
32+
public function let(Source $source)
33+
{
34+
$this->source = $source;
35+
$this->fromArray($this->data());
36+
}
37+
38+
public function it_is_initializable()
39+
{
40+
$this->shouldHaveType(Advisory::class);
41+
}
42+
43+
public function it_is_a_packagist_result()
44+
{
45+
$this->shouldHaveType(AbstractResult::class);
46+
}
47+
48+
public function it_gets_advisory_id()
49+
{
50+
$this->getAdvisoryId()->shouldReturn($this->data()['advisoryId']);
51+
}
52+
53+
public function it_gets_package_name()
54+
{
55+
$this->getPackageName()->shouldReturn($this->data()['packageName']);
56+
}
57+
58+
public function it_gets_remote_id()
59+
{
60+
$this->getRemoteId()->shouldReturn($this->data()['remoteId']);
61+
}
62+
63+
public function it_gets_title()
64+
{
65+
$this->getTitle()->shouldReturn($this->data()['title']);
66+
}
67+
68+
public function it_gets_link()
69+
{
70+
$this->getLink()->shouldReturn($this->data()['link']);
71+
}
72+
73+
public function it_gets_cve()
74+
{
75+
$this->getCve()->shouldReturn($this->data()['cve']);
76+
}
77+
78+
public function it_gets_affected_versions()
79+
{
80+
$this->getAffectedVersions()->shouldReturn($this->data()['affectedVersions']);
81+
}
82+
83+
public function it_gets_sources()
84+
{
85+
$this->getSources()->shouldReturn($this->data()['sources']);
86+
}
87+
88+
public function it_gets_reported_at()
89+
{
90+
$this->getReportedAt()->shouldReturn($this->data()['reportedAt']);
91+
}
92+
93+
public function it_gets_composer_repository()
94+
{
95+
$this->getComposerRepository()->shouldReturn($this->data()['composerRepository']);
96+
}
97+
}

src/Packagist/Api/Client.php

+123
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
namespace Packagist\Api;
66

7+
use Composer\Semver\Semver;
78
use GuzzleHttp\Client as HttpClient;
89
use GuzzleHttp\ClientInterface;
910
use GuzzleHttp\Exception\GuzzleException;
11+
use Packagist\Api\Result\Advisory;
1012
use Packagist\Api\Result\Factory;
1113
use Packagist\Api\Result\Package;
1214

@@ -187,6 +189,96 @@ public function popular(int $total): array
187189
return array_slice($results, 0, $total);
188190
}
189191

192+
/**
193+
* Get a list of known security vulnerability advisories
194+
*
195+
* $packages can be a simple array of package names, or an array with package names
196+
* as keys and version strings as values.
197+
*
198+
* If $filterByVersion is true, any packages which are not accompanied by a version
199+
* number will be ignored.
200+
*
201+
* @param array $packages
202+
* @param integer|null $updatedSince A unix timestamp.
203+
* Only advisories updated after this date/time will be included
204+
* @param boolean $filterByVersion If true, only advisories which affect the version of packages in the
205+
* $packages array will be included
206+
* @return Advisory[]
207+
*/
208+
public function advisories(array $packages = [], ?int $updatedSince = null, bool $filterByVersion = false): array
209+
{
210+
if (count($packages) === 0 && $updatedSince === null) {
211+
throw new \InvalidArgumentException(
212+
'At least one package or an $updatedSince timestamp must be passed in.'
213+
);
214+
}
215+
216+
if (count($packages) === 0 && $filterByVersion) {
217+
return [];
218+
}
219+
220+
// Add updatedSince to query if passed in
221+
$query = [];
222+
if ($updatedSince !== null) {
223+
$query['updatedSince'] = $updatedSince;
224+
}
225+
$options = [
226+
'query' => array_filter($query),
227+
];
228+
229+
// Add packages if appropriate
230+
if (count($packages) > 0) {
231+
$content = ['packages' => []];
232+
foreach ($packages as $package => $version) {
233+
if (is_numeric($package)) {
234+
$package = $version;
235+
}
236+
$content['packages'][] = $package;
237+
}
238+
$options['headers']['Content-type'] = 'application/x-www-form-urlencoded';
239+
$options['body'] = http_build_query($content);
240+
}
241+
242+
// Get advisories from API
243+
/** @var Advisory[] $advisories */
244+
$advisories = $this->respondPost($this->url('/api/security-advisories/'), $options);
245+
246+
// Filter advisories if necessary
247+
if (count($advisories) > 0 && $filterByVersion) {
248+
return $this->filterAdvisories($advisories, $packages);
249+
}
250+
251+
return $advisories;
252+
}
253+
254+
/**
255+
* Filter the advisories array to only include any advisories that affect
256+
* the versions of packages in the $packages array
257+
*
258+
* @param Advisory[] $advisories
259+
* @param array $packages
260+
* @return Advisory[] Filtered advisories array
261+
*/
262+
private function filterAdvisories(array $advisories, array $packages): array
263+
{
264+
$filteredAdvisories = [];
265+
foreach ($packages as $package => $version) {
266+
// Skip any packages with no declared versions
267+
if (is_numeric($package)) {
268+
continue;
269+
}
270+
// Filter advisories by version
271+
if (array_key_exists($package, $advisories)) {
272+
foreach ($advisories[$package] as $advisory) {
273+
if (Semver::satisfies($version, $advisory->getAffectedVersions())) {
274+
$filteredAdvisories[$package][] = $advisory;
275+
}
276+
}
277+
}
278+
}
279+
return $filteredAdvisories;
280+
}
281+
190282
/**
191283
* Assemble the packagist URL with the route
192284
*
@@ -212,6 +304,21 @@ protected function respond(string $url)
212304
return $this->create($response);
213305
}
214306

307+
/**
308+
* Execute the POST request and parse the response
309+
*
310+
* @param string $url
311+
* @param array $option
312+
* @return array|Package
313+
*/
314+
protected function respondPost(string $url, array $options)
315+
{
316+
$response = $this->postRequest($url, $options);
317+
$response = $this->parse($response);
318+
319+
return $this->create($response);
320+
}
321+
215322
/**
216323
* Execute two URLs request, parse and merge the responses by adding the versions from the second URL
217324
* into the versions from the first URL.
@@ -241,6 +348,22 @@ protected function multiRespond(string $url1, string $url2)
241348
return $this->create($response1);
242349
}
243350

351+
/**
352+
* Execute the POST request
353+
*
354+
* @param string $url
355+
* @param array $options
356+
* @return string
357+
* @throws GuzzleException
358+
*/
359+
protected function postRequest(string $url, array $options): string
360+
{
361+
return $this->httpClient
362+
->request('POST', $url, $options)
363+
->getBody()
364+
->getContents();
365+
}
366+
244367
/**
245368
* Execute the request URL
246369
*

0 commit comments

Comments
 (0)