Skip to content

Commit 33e65a7

Browse files
committed
New: Add security advisories endpoint.
1 parent 7378342 commit 33e65a7

File tree

7 files changed

+392
-4
lines changed

7 files changed

+392
-4
lines changed

composer.json

Lines changed: 3 additions & 2 deletions
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"
Lines changed: 34 additions & 0 deletions
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+
}
Lines changed: 97 additions & 0 deletions
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

Lines changed: 115 additions & 0 deletions
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,88 @@ 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+
* @param array $packages
196+
* @param integer|null $updatedSince
197+
* @param boolean $filterByVersion
198+
* @return Advisory[]
199+
*/
200+
public function advisories(array $packages = [], ?int $updatedSince = null, bool $filterByVersion = false): array
201+
{
202+
if (count($packages) === 0 && $updatedSince === null) {
203+
throw new \InvalidArgumentException(
204+
'At least one package or an $updatedSince timestamp must be passed in.'
205+
);
206+
}
207+
208+
if (count($packages) === 0 && $filterByVersion) {
209+
return [];
210+
}
211+
212+
// Add updatedSince to query if passed in
213+
$query = [];
214+
if ($updatedSince !== null) {
215+
$query['updatedSince'] = $updatedSince;
216+
}
217+
$options = [
218+
'query' => array_filter($query),
219+
];
220+
221+
// Add packages if appropriate
222+
if (count($packages) > 0) {
223+
$content = ['packages' => []];
224+
foreach ($packages as $package => $version) {
225+
if (is_numeric($package)) {
226+
$package = $version;
227+
}
228+
$content['packages'][] = $package;
229+
}
230+
$options['headers']['Content-type'] = 'application/x-www-form-urlencoded';
231+
$options['body'] = http_build_query($content);
232+
}
233+
234+
// Get advisories from API
235+
/** @var Advisory[] $advisories */
236+
$advisories = $this->respondPost($this->url('/api/security-advisories/'), $options);
237+
238+
// Filter advisories if necessary
239+
if (count($advisories) > 0 && $filterByVersion) {
240+
return $this->filterAdvisories($advisories, $packages);
241+
}
242+
243+
return $advisories;
244+
}
245+
246+
/**
247+
* Filter the advisories array to only include any advisories that affect
248+
* the versions of packages in the $packages array
249+
*
250+
* @param Advisory[] $advisories
251+
* @param array $packages
252+
* @return Advisory[] Filtered advisories array
253+
*/
254+
private function filterAdvisories(array $advisories, array $packages): array
255+
{
256+
$filteredAdvisories = [];
257+
foreach ($packages as $package => $version) {
258+
// Skip any packages with no declared versions
259+
if (is_numeric($package)) {
260+
continue;
261+
}
262+
// Filter advisories by version
263+
if (array_key_exists($package, $advisories)) {
264+
foreach ($advisories[$package] as $advisory) {
265+
if (Semver::satisfies($version, $advisory->getAffectedVersions())) {
266+
$filteredAdvisories[$package][] = $advisory;
267+
}
268+
}
269+
}
270+
}
271+
return $filteredAdvisories;
272+
}
273+
190274
/**
191275
* Assemble the packagist URL with the route
192276
*
@@ -212,6 +296,21 @@ protected function respond(string $url)
212296
return $this->create($response);
213297
}
214298

299+
/**
300+
* Execute the POST request and parse the response
301+
*
302+
* @param string $url
303+
* @param array $option
304+
* @return array|Package
305+
*/
306+
protected function respondPost(string $url, array $options)
307+
{
308+
$response = $this->postRequest($url, $options);
309+
$response = $this->parse($response);
310+
311+
return $this->create($response);
312+
}
313+
215314
/**
216315
* Execute two URLs request, parse and merge the responses by adding the versions from the second URL
217316
* into the versions from the first URL.
@@ -241,6 +340,22 @@ protected function multiRespond(string $url1, string $url2)
241340
return $this->create($response1);
242341
}
243342

343+
/**
344+
* Execute the POST request
345+
*
346+
* @param string $url
347+
* @param array $options
348+
* @return string
349+
* @throws GuzzleException
350+
*/
351+
protected function postRequest(string $url, array $options): string
352+
{
353+
return $this->httpClient
354+
->request('POST', $url, $options)
355+
->getBody()
356+
->getContents();
357+
}
358+
244359
/**
245360
* Execute the request URL
246361
*
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
namespace Packagist\Api\Result;
4+
5+
use Packagist\Api\Result\AbstractResult;
6+
use Packagist\Api\Result\Advisory\Source;
7+
8+
class Advisory extends AbstractResult
9+
{
10+
protected string $advisoryId;
11+
12+
protected string $packageName;
13+
14+
protected string $remoteId;
15+
16+
protected string $title;
17+
18+
protected string $link;
19+
20+
protected string $cve;
21+
22+
protected string $affectedVersions;
23+
24+
/**
25+
* @var Source[]
26+
*/
27+
protected array $sources;
28+
29+
protected string $reportedAt;
30+
31+
protected string $composerRepository;
32+
33+
public function getAdvisoryId(): string
34+
{
35+
return $this->advisoryId;
36+
}
37+
38+
public function getPackageName(): string
39+
{
40+
return $this->packageName;
41+
}
42+
43+
public function getRemoteId(): string
44+
{
45+
return $this->remoteId;
46+
}
47+
48+
public function getTitle(): string
49+
{
50+
return $this->title;
51+
}
52+
53+
public function getLink(): string
54+
{
55+
return $this->link;
56+
}
57+
58+
public function getCve(): string
59+
{
60+
return $this->cve;
61+
}
62+
63+
public function getAffectedVersions(): string
64+
{
65+
return $this->affectedVersions;
66+
}
67+
68+
/**
69+
* @return Source[]
70+
*/
71+
public function getSources(): array
72+
{
73+
return $this->sources;
74+
}
75+
76+
public function getReportedAt(): string
77+
{
78+
return $this->reportedAt;
79+
}
80+
81+
public function getComposerRepository(): string
82+
{
83+
return $this->composerRepository;
84+
}
85+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
namespace Packagist\Api\Result\Advisory;
4+
5+
use Packagist\Api\Result\AbstractResult;
6+
7+
class Source extends AbstractResult
8+
{
9+
protected string $name;
10+
11+
protected string $remoteId;
12+
13+
public function getName(): string
14+
{
15+
return $this->name;
16+
}
17+
18+
public function getRemoteId(): string
19+
{
20+
return $this->remoteId;
21+
}
22+
}

0 commit comments

Comments
 (0)