diff --git a/composer.json b/composer.json index a04a7de..bb87705 100644 --- a/composer.json +++ b/composer.json @@ -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" diff --git a/examples/advisories.php b/examples/advisories.php new file mode 100644 index 0000000..5bfba16 --- /dev/null +++ b/examples/advisories.php @@ -0,0 +1,17 @@ +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); diff --git a/spec/Packagist/Api/Result/Advisory/SourceSpec.php b/spec/Packagist/Api/Result/Advisory/SourceSpec.php new file mode 100644 index 0000000..961d22a --- /dev/null +++ b/spec/Packagist/Api/Result/Advisory/SourceSpec.php @@ -0,0 +1,34 @@ +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'); + } +} diff --git a/spec/Packagist/Api/Result/AdvisorySpec.php b/spec/Packagist/Api/Result/AdvisorySpec.php new file mode 100644 index 0000000..a7aa1ca --- /dev/null +++ b/spec/Packagist/Api/Result/AdvisorySpec.php @@ -0,0 +1,97 @@ + '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']); + } +} diff --git a/src/Packagist/Api/Client.php b/src/Packagist/Api/Client.php index 83e990d..79b046b 100644 --- a/src/Packagist/Api/Client.php +++ b/src/Packagist/Api/Client.php @@ -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; @@ -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 * @@ -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. @@ -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 * diff --git a/src/Packagist/Api/Result/Advisory.php b/src/Packagist/Api/Result/Advisory.php new file mode 100644 index 0000000..214c2cf --- /dev/null +++ b/src/Packagist/Api/Result/Advisory.php @@ -0,0 +1,85 @@ +advisoryId; + } + + public function getPackageName(): string + { + return $this->packageName; + } + + public function getRemoteId(): string + { + return $this->remoteId; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getLink(): string + { + return $this->link; + } + + public function getCve(): string + { + return $this->cve; + } + + public function getAffectedVersions(): string + { + return $this->affectedVersions; + } + + /** + * @return Source[] + */ + public function getSources(): array + { + return $this->sources; + } + + public function getReportedAt(): string + { + return $this->reportedAt; + } + + public function getComposerRepository(): string + { + return $this->composerRepository; + } +} diff --git a/src/Packagist/Api/Result/Advisory/Source.php b/src/Packagist/Api/Result/Advisory/Source.php new file mode 100644 index 0000000..da54848 --- /dev/null +++ b/src/Packagist/Api/Result/Advisory/Source.php @@ -0,0 +1,22 @@ +name; + } + + public function getRemoteId(): string + { + return $this->remoteId; + } +} diff --git a/src/Packagist/Api/Result/Factory.php b/src/Packagist/Api/Result/Factory.php index bbcae7c..70787ff 100644 --- a/src/Packagist/Api/Result/Factory.php +++ b/src/Packagist/Api/Result/Factory.php @@ -10,8 +10,9 @@ use Packagist\Api\Result\Package\Dist; use Packagist\Api\Result\Package\Downloads; use Packagist\Api\Result\Package\Maintainer; -use Packagist\Api\Result\Package\Source; +use Packagist\Api\Result\Package\Source as PackageSource; use Packagist\Api\Result\Package\Version; +use Packagist\Api\Result\Advisory\Source as AdvisorySource; /** * Map raw data from website api to has a know type @@ -52,6 +53,9 @@ public function create(array $data) if (isset($data['packageNames'])) { return $data['packageNames']; } + if (isset($data['advisories'])) { + return $this->createAdvisoryResults($data['advisories']); + } throw new InvalidArgumentException('Invalid input data.'); } @@ -145,7 +149,7 @@ public function createPackageResults(array $package): Package } if ($version['source']) { - $version['source'] = $this->createResult(Source::class, $version['source']); + $version['source'] = $this->createResult(PackageSource::class, $version['source']); } if (isset($version['dist']) && $version['dist']) { @@ -161,6 +165,36 @@ public function createPackageResults(array $package): Package return $created; } + /** + * @param array $advisories + * @return Advisory[] + */ + public function createAdvisoryResults(array $advisories): array + { + $created = []; + foreach ($advisories as $package => $advisories2) { + foreach ($advisories2 as $advisory) { + $advisory['advisoryId'] ??= ''; + $advisory['packageName'] ??= ''; + $advisory['remoteId'] ??= ''; + $advisory['title'] ??= ''; + $advisory['link'] ??= ''; + $advisory['cve'] ??= ''; + $advisory['affectedVersions'] ??= ''; + $advisory['sources'] ??= []; + $advisory['reportedAt'] ??= ''; + $advisory['composerRepository'] ??= ''; + foreach ($advisory['sources'] as $i => $source) { + $source['name'] ??= ''; + $source['remoteId'] ??= ''; + $advisory['sources'][$i] = $this->createResult(AdvisorySource::class, $source); + } + $created[$package][] = $this->createResult(Advisory::class, $advisory); + } + } + return $created; + } + /** * Dynamically create an AbstractResult of type $class and hydrate *