Skip to content

Commit

Permalink
Merge pull request #86 from creative-commoners/pulls/master/add-advis…
Browse files Browse the repository at this point in the history
…ory-endpoint

New: Add security advisories endpoint.
  • Loading branch information
robbieaverill authored Aug 8, 2022
2 parents 7378342 + b9f74e2 commit 0c5c7e2
Show file tree
Hide file tree
Showing 8 changed files with 417 additions and 4 deletions.
5 changes: 3 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@
"guzzlehttp/guzzle": "^6.0 || ^7.0",
"doctrine/inflector": "^1.0 || ^2.0",
"ext-json": "*",
"composer/metadata-minifier": "^1.0"
},
"composer/metadata-minifier": "^1.0",
"composer/semver": "^1.0|^2.0|^3.0"
},
"require-dev": {
"phpspec/phpspec": "^6.0 || ^7.0",
"squizlabs/php_codesniffer": "^3.0"
Expand Down
17 changes: 17 additions & 0 deletions examples/advisories.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

require __DIR__ . '/../vendor/autoload.php';

$client = new Packagist\Api\Client();

// Get any advisories for the monolog/monolog package
$advisories = $client->advisories(['monolog/monolog']);
var_export($advisories);

// Get any advisories for the monolog/monolog package which were modified after midnight 2022/07/2022.
$advisories = $client->advisories(['monolog/monolog' => '1.8.1'], 1659052800);
var_export($advisories);

// Get any advisories for the monolog/monolog package which will affect version 1.8.1 of that package
$advisories = $client->advisories(['monolog/monolog' => '1.8.1'], null, true);
var_export($advisories);
34 changes: 34 additions & 0 deletions spec/Packagist/Api/Result/Advisory/SourceSpec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace spec\Packagist\Api\Result\Advisory;

use Packagist\Api\Result\Advisory\Source;
use PhpSpec\ObjectBehavior;

class SourceSpec extends ObjectBehavior
{
public function let()
{
$this->fromArray([
'name' => 'FriendsOfPHP/security-advisories',
'remoteId' => 'monolog/monolog/2014-12-29-1.yaml',
]);
}

public function it_is_initializable()
{
$this->shouldHaveType(Source::class);
}

public function it_gets_name()
{
$this->getName()->shouldReturn('FriendsOfPHP/security-advisories');
}

public function it_gets_remote_id()
{
$this->getRemoteId()->shouldReturn('monolog/monolog/2014-12-29-1.yaml');
}
}
97 changes: 97 additions & 0 deletions spec/Packagist/Api/Result/AdvisorySpec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

declare(strict_types=1);

namespace spec\Packagist\Api\Result;

use Packagist\Api\Result\AbstractResult;
use Packagist\Api\Result\Advisory;
use Packagist\Api\Result\Advisory\Source;
use PhpSpec\ObjectBehavior;

class AdvisorySpec extends ObjectBehavior
{
private $source;

private function data()
{
return [
'advisoryId' => 'PKSA-dmw8-jd8k-q3c6',
'packageName' => 'monolog/monolog',
'remoteId' => 'monolog/monolog/2014-12-29-1.yaml',
'title' => 'Header injection in NativeMailerHandler',
'link' => 'https://github.com/Seldaek/monolog/pull/448#issuecomment-68208704',
'cve' => 'test-value',
'affectedVersions' => '>=1.8.0,<1.12.0',
'sources' => [$this->source],
'reportedAt' => '2014-12-29 00:00:00',
'composerRepository' => 'https://packagist.org',
];
}

public function let(Source $source)
{
$this->source = $source;
$this->fromArray($this->data());
}

public function it_is_initializable()
{
$this->shouldHaveType(Advisory::class);
}

public function it_is_a_packagist_result()
{
$this->shouldHaveType(AbstractResult::class);
}

public function it_gets_advisory_id()
{
$this->getAdvisoryId()->shouldReturn($this->data()['advisoryId']);
}

public function it_gets_package_name()
{
$this->getPackageName()->shouldReturn($this->data()['packageName']);
}

public function it_gets_remote_id()
{
$this->getRemoteId()->shouldReturn($this->data()['remoteId']);
}

public function it_gets_title()
{
$this->getTitle()->shouldReturn($this->data()['title']);
}

public function it_gets_link()
{
$this->getLink()->shouldReturn($this->data()['link']);
}

public function it_gets_cve()
{
$this->getCve()->shouldReturn($this->data()['cve']);
}

public function it_gets_affected_versions()
{
$this->getAffectedVersions()->shouldReturn($this->data()['affectedVersions']);
}

public function it_gets_sources()
{
$this->getSources()->shouldReturn($this->data()['sources']);
}

public function it_gets_reported_at()
{
$this->getReportedAt()->shouldReturn($this->data()['reportedAt']);
}

public function it_gets_composer_repository()
{
$this->getComposerRepository()->shouldReturn($this->data()['composerRepository']);
}
}
123 changes: 123 additions & 0 deletions src/Packagist/Api/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

namespace Packagist\Api;

use Composer\Semver\Semver;
use GuzzleHttp\Client as HttpClient;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use Packagist\Api\Result\Advisory;
use Packagist\Api\Result\Factory;
use Packagist\Api\Result\Package;

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

/**
* Get a list of known security vulnerability advisories
*
* $packages can be a simple array of package names, or an array with package names
* as keys and version strings as values.
*
* If $filterByVersion is true, any packages which are not accompanied by a version
* number will be ignored.
*
* @param array $packages
* @param integer|null $updatedSince A unix timestamp.
* Only advisories updated after this date/time will be included
* @param boolean $filterByVersion If true, only advisories which affect the version of packages in the
* $packages array will be included
* @return Advisory[]
*/
public function advisories(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
/** @var Advisory[] $advisories */
$advisories = $this->respondPost($this->url('/api/security-advisories/'), $options);

// Filter advisories if necessary
if (count($advisories) > 0 && $filterByVersion) {
return $this->filterAdvisories($advisories, $packages);
}

return $advisories;
}

/**
* Filter the advisories array to only include any advisories that affect
* the versions of packages in the $packages array
*
* @param Advisory[] $advisories
* @param array $packages
* @return Advisory[] Filtered advisories array
*/
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->getAffectedVersions())) {
$filteredAdvisories[$package][] = $advisory;
}
}
}
}
return $filteredAdvisories;
}

/**
* Assemble the packagist URL with the route
*
Expand All @@ -212,6 +304,21 @@ protected function respond(string $url)
return $this->create($response);
}

/**
* Execute the POST request and parse the response
*
* @param string $url
* @param array $option
* @return array|Package
*/
protected function respondPost(string $url, array $options)
{
$response = $this->postRequest($url, $options);
$response = $this->parse($response);

return $this->create($response);
}

/**
* Execute two URLs request, parse and merge the responses by adding the versions from the second URL
* into the versions from the first URL.
Expand Down Expand Up @@ -241,6 +348,22 @@ protected function multiRespond(string $url1, string $url2)
return $this->create($response1);
}

/**
* Execute the POST request
*
* @param string $url
* @param array $options
* @return string
* @throws GuzzleException
*/
protected function postRequest(string $url, array $options): string
{
return $this->httpClient
->request('POST', $url, $options)
->getBody()
->getContents();
}

/**
* Execute the request URL
*
Expand Down
Loading

0 comments on commit 0c5c7e2

Please sign in to comment.