Skip to content

New: Add security advisories endpoint. #86

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this docblock would benefit from some extra information describing what the $updatedSince and $filterByVersion arguments are for. It's not immediately obvious to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated the phpdoc to be more descriptive

*
* $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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My initial instinct when testing this was to call $client->advisories('monolog/monolog'). I think it's fine to enforce an array of package names here (maybe add this as a description on this argument). I then thought it might be nice to be able to access advisories from the Package class, i.e. $package->advisories(), but the Package class doesn't have client awareness so it would require making the AbstractResult classes aware of the client. I don't think it's worth it, but it would be nice to have that accessor method.

(Not asking for changes here, just voicing my thoughts while testing)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I thought about allowing Package values in the array, but all of the other methods that accept a package as an argument only accept the string names, so I figured it made sense to be consistent and do the same here.

Maybe a future enhancement could be to change all of those to accept Package|string.

* @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