From 8c9f82dc1eba5a79ae2be3324b9390357c0f46cf Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Fri, 24 Jun 2022 16:21:01 +0200 Subject: [PATCH] Move security advisory loading to repositories, allows others to provider them and reduces load on packagist.org for summary advisory reports --- phpstan/baseline.neon | 122 +----- src/Composer/Advisory/Auditor.php | 174 ++++++++ .../Advisory/PartialSecurityAdvisory.php | 48 +++ src/Composer/Advisory/SecurityAdvisory.php | 54 +++ src/Composer/Command/AuditCommand.php | 14 +- src/Composer/Command/BaseCommand.php | 2 +- src/Composer/Command/CreateProjectCommand.php | 2 +- src/Composer/Command/InstallCommand.php | 2 +- src/Composer/Command/RemoveCommand.php | 2 +- src/Composer/Command/RequireCommand.php | 2 +- src/Composer/Command/UpdateCommand.php | 2 +- src/Composer/Installer.php | 10 +- .../Repository/AdvisoryProviderInterface.php | 34 ++ .../Repository/ComposerRepository.php | 177 ++++++-- src/Composer/Repository/RepositorySet.php | 56 +++ src/Composer/Util/Auditor.php | 250 ----------- tests/Composer/Test/Advisory/AuditorTest.php | 289 +++++++++++++ tests/Composer/Test/Util/AuditorTest.php | 395 ------------------ 18 files changed, 836 insertions(+), 799 deletions(-) create mode 100644 src/Composer/Advisory/Auditor.php create mode 100644 src/Composer/Advisory/PartialSecurityAdvisory.php create mode 100644 src/Composer/Advisory/SecurityAdvisory.php create mode 100644 src/Composer/Repository/AdvisoryProviderInterface.php delete mode 100644 src/Composer/Util/Auditor.php create mode 100644 tests/Composer/Test/Advisory/AuditorTest.php delete mode 100644 tests/Composer/Test/Util/AuditorTest.php diff --git a/phpstan/baseline.neon b/phpstan/baseline.neon index 5538f5f70..335235af8 100644 --- a/phpstan/baseline.neon +++ b/phpstan/baseline.neon @@ -1,13 +1,13 @@ parameters: ignoreErrors: - - message: "#^Binary operation \"\\.\" between non\\-empty\\-string and array\\|string\\|null results in an error\\.$#" + message: "#^Parameter \\#2 \\$advisories of method Composer\\\\Advisory\\\\Auditor\\:\\:outputAdvisories\\(\\) expects array\\\\>, array\\\\> given\\.$#" count: 1 - path: ../src/Composer/Autoload/AutoloadGenerator.php + path: ../src/Composer/Advisory/Auditor.php - - message: "#^Cannot call method writeError\\(\\) on Composer\\\\IO\\\\IOInterface\\|null\\.$#" - count: 2 + message: "#^Binary operation \"\\.\" between non\\-empty\\-string and array\\|string\\|null results in an error\\.$#" + count: 1 path: ../src/Composer/Autoload/AutoloadGenerator.php - @@ -20,11 +20,6 @@ parameters: count: 1 path: ../src/Composer/Autoload/AutoloadGenerator.php - - - message: "#^Only booleans are allowed in &&, Composer\\\\IO\\\\IOInterface\\|null given on the left side\\.$#" - count: 1 - path: ../src/Composer/Autoload/AutoloadGenerator.php - - message: "#^Only booleans are allowed in &&, array\\\\|int\\|string\\>\\|bool\\|string\\>\\|bool\\|string\\|null given on the left side\\.$#" count: 1 @@ -55,21 +50,11 @@ parameters: count: 3 path: ../src/Composer/Autoload/AutoloadGenerator.php - - - message: "#^Only booleans are allowed in a ternary operator condition, array\\ given\\.$#" - count: 1 - path: ../src/Composer/Autoload/AutoloadGenerator.php - - message: "#^Only booleans are allowed in a ternary operator condition, mixed given\\.$#" count: 1 path: ../src/Composer/Autoload/AutoloadGenerator.php - - - message: "#^Only booleans are allowed in an if condition, array\\\\|null given\\.$#" - count: 1 - path: ../src/Composer/Autoload/AutoloadGenerator.php - - message: "#^Only booleans are allowed in an if condition, mixed given\\.$#" count: 1 @@ -115,11 +100,6 @@ parameters: count: 1 path: ../src/Composer/Autoload/AutoloadGenerator.php - - - message: "#^Parameter \\#2 \\$excluded of static method Composer\\\\Autoload\\\\ClassMapGenerator\\:\\:createMap\\(\\) expects string\\|null, array\\|string\\|null given\\.$#" - count: 1 - path: ../src/Composer/Autoload/AutoloadGenerator.php - - message: "#^Parameter \\#2 \\$subject of static method Composer\\\\Pcre\\\\Preg\\:\\:isMatch\\(\\) expects string, string\\|false given\\.$#" count: 1 @@ -165,66 +145,6 @@ parameters: count: 1 path: ../src/Composer/Autoload/ClassLoader.php - - - message: "#^Call to function in_array\\(\\) requires parameter \\#3 to be set\\.$#" - count: 1 - path: ../src/Composer/Autoload/ClassMapGenerator.php - - - - message: "#^Cannot call method getPathname\\(\\) on SplFileInfo\\|string\\.$#" - count: 1 - path: ../src/Composer/Autoload/ClassMapGenerator.php - - - - message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" - count: 2 - path: ../src/Composer/Autoload/ClassMapGenerator.php - - - - message: "#^Method Composer\\\\Autoload\\\\ClassMapGenerator\\:\\:findClasses\\(\\) should return array\\ but returns array\\\\.$#" - count: 1 - path: ../src/Composer/Autoload/ClassMapGenerator.php - - - - message: "#^Only booleans are allowed in &&, Composer\\\\IO\\\\IOInterface\\|null given on the left side\\.$#" - count: 1 - path: ../src/Composer/Autoload/ClassMapGenerator.php - - - - message: "#^Only booleans are allowed in &&, string\\|null given on the left side\\.$#" - count: 2 - path: ../src/Composer/Autoload/ClassMapGenerator.php - - - - message: "#^Only booleans are allowed in a negated boolean, string given\\.$#" - count: 1 - path: ../src/Composer/Autoload/ClassMapGenerator.php - - - - message: "#^Only booleans are allowed in an if condition, Composer\\\\IO\\\\IOInterface\\|null given\\.$#" - count: 1 - path: ../src/Composer/Autoload/ClassMapGenerator.php - - - - message: "#^Only booleans are allowed in an if condition, array\\ given\\.$#" - count: 1 - path: ../src/Composer/Autoload/ClassMapGenerator.php - - - - message: "#^Parameter \\#1 \\$str of function strtr expects string, string\\|false given\\.$#" - count: 1 - path: ../src/Composer/Autoload/ClassMapGenerator.php - - - - message: "#^Parameter \\#3 \\$baseNamespace of static method Composer\\\\Autoload\\\\ClassMapGenerator\\:\\:filterByNamespace\\(\\) expects string, string\\|null given\\.$#" - count: 1 - path: ../src/Composer/Autoload/ClassMapGenerator.php - - - - message: "#^Parameter \\#5 \\$basePath of static method Composer\\\\Autoload\\\\ClassMapGenerator\\:\\:filterByNamespace\\(\\) expects string, array\\\\|string\\|Traversable\\ given\\.$#" - count: 1 - path: ../src/Composer/Autoload/ClassMapGenerator.php - - message: "#^Casting to bool something that's already bool\\.$#" count: 2 @@ -717,7 +637,7 @@ parameters: - message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" - count: 3 + count: 2 path: ../src/Composer/Command/RequireCommand.php - @@ -735,11 +655,6 @@ parameters: count: 1 path: ../src/Composer/Command/RequireCommand.php - - - message: "#^Only booleans are allowed in a negated boolean, mixed given\\.$#" - count: 1 - path: ../src/Composer/Command/RequireCommand.php - - message: "#^Only booleans are allowed in an if condition, string\\|null given\\.$#" count: 2 @@ -1842,7 +1757,7 @@ parameters: - message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" - count: 2 + count: 1 path: ../src/Composer/DependencyResolver/SolverProblemsException.php - @@ -3725,11 +3640,6 @@ parameters: count: 2 path: ../src/Composer/Repository/ComposerRepository.php - - - message: "#^Only booleans are allowed in a negated boolean, string\\|null given\\.$#" - count: 2 - path: ../src/Composer/Repository/ComposerRepository.php - - message: "#^Only booleans are allowed in a ternary operator condition, string\\|null given\\.$#" count: 1 @@ -4518,11 +4428,6 @@ parameters: count: 1 path: ../src/Composer/SelfUpdate/Versions.php - - - message: "#^Short ternary operator is not allowed\\. Use null coalesce operator if applicable or consider using long ternary\\.$#" - count: 1 - path: src/Composer/Util/Auditor.php - - message: "#^Call to function in_array\\(\\) requires parameter \\#3 to be set\\.$#" count: 1 @@ -5598,21 +5503,6 @@ parameters: count: 2 path: ../tests/Composer/Test/Autoload/AutoloadGeneratorTest.php - - - message: "#^Dynamic call to static method Composer\\\\Test\\\\TestCase\\:\\:ensureDirectoryExistsAndClear\\(\\)\\.$#" - count: 1 - path: ../tests/Composer/Test/Autoload/ClassMapGeneratorTest.php - - - - message: "#^Parameter \\#1 \\$expected of method Composer\\\\Test\\\\Autoload\\\\ClassMapGeneratorTest\\:\\:assertEqualsNormalized\\(\\) expects array\\, array\\ given\\.$#" - count: 3 - path: ../tests/Composer/Test/Autoload/ClassMapGeneratorTest.php - - - - message: "#^Parameter \\#2 \\$actual of method Composer\\\\Test\\\\Autoload\\\\ClassMapGeneratorTest\\:\\:assertEqualsNormalized\\(\\) expects array\\, array\\ given\\.$#" - count: 3 - path: ../tests/Composer/Test/Autoload/ClassMapGeneratorTest.php - - message: "#^Short ternary operator is not allowed\\. Use null coalesce operator if applicable or consider using long ternary\\.$#" count: 1 diff --git a/src/Composer/Advisory/Auditor.php b/src/Composer/Advisory/Auditor.php new file mode 100644 index 000000000..124372751 --- /dev/null +++ b/src/Composer/Advisory/Auditor.php @@ -0,0 +1,174 @@ +getMatchingSecurityAdvisories($packages, $format === self::FORMAT_SUMMARY); + $errorOrWarn = $warningOnly ? 'warning' : 'error'; + if (count($advisories) > 0) { + [$affectedPackages, $totalAdvisories] = $this->countAdvisories($advisories); + $plurality = $totalAdvisories === 1 ? 'y' : 'ies'; + $pkgPlurality = $affectedPackages === 1 ? '' : 's'; + $punctuation = $format === 'summary' ? '.' : ':'; + $io->writeError("<$errorOrWarn>Found $totalAdvisories security vulnerability advisor{$plurality} affecting $affectedPackages package{$pkgPlurality}{$punctuation}"); + $this->outputAdvisories($io, $advisories, $format); + + return $affectedPackages; + } + + $io->writeError('No security vulnerability advisories found'); + + return 0; + } + + /** + * @param array> $advisories + * @return array{int, int} Count of affected packages and total count of advisories + */ + private function countAdvisories(array $advisories): array + { + $count = 0; + foreach ($advisories as $packageAdvisories) { + $count += count($packageAdvisories); + } + return [count($advisories), $count]; + } + + /** + * @param IOInterface $io + * @param array> $advisories + * @param self::FORMAT_* $format The format that will be used to output audit results. + * @return void + */ + private function outputAdvisories(IOInterface $io, array $advisories, string $format): void + { + switch ($format) { + case self::FORMAT_TABLE: + if (!($io instanceof ConsoleIO)) { + throw new InvalidArgumentException('Cannot use table format with ' . get_class($io)); + } + $this->outputAvisoriesTable($io, $advisories); + return; + case self::FORMAT_PLAIN: + $this->outputAdvisoriesPlain($io, $advisories); + return; + case self::FORMAT_SUMMARY: + // We've already output the number of advisories in audit() + $io->writeError('Run composer audit for a full list of advisories.'); + return; + default: + throw new InvalidArgumentException('Invalid format "'.$format.'".'); + } + } + + /** + * @param ConsoleIO $io + * @param array> $advisories + * @return void + */ + private function outputAvisoriesTable(ConsoleIO $io, array $advisories): void + { + foreach ($advisories as $packageAdvisories) { + foreach ($packageAdvisories as $advisory) { + $io->getTable() + ->setHorizontal() + ->setHeaders([ + 'Package', + 'CVE', + 'Title', + 'URL', + 'Affected versions', + 'Reported at', + ]) + ->addRow([ + $advisory->packageName, + $this->getCVE($advisory), + $advisory->title, + $this->getURL($advisory), + $advisory->affectedVersions->getPrettyString(), + $advisory->reportedAt->format(DATE_ATOM), + ]) + ->setColumnWidth(1, 80) + ->setColumnMaxWidth(1, 80) + ->render(); + } + } + } + + /** + * @param IOInterface $io + * @param array> $advisories + * @return void + */ + private function outputAdvisoriesPlain(IOInterface $io, array $advisories): void + { + $error = []; + $firstAdvisory = true; + foreach ($advisories as $packageAdvisories) { + foreach ($packageAdvisories as $advisory) { + if (!$firstAdvisory) { + $error[] = '--------'; + } + $error[] = "Package: ".$advisory->packageName; + $error[] = "CVE: ".$this->getCVE($advisory); + $error[] = "Title: ".OutputFormatter::escape($advisory->title); + $error[] = "URL: ".$this->getURL($advisory); + $error[] = "Affected versions: ".OutputFormatter::escape($advisory->affectedVersions->getPrettyString()); + $error[] = "Reported at: ".$advisory->reportedAt->format(DATE_ATOM); + $firstAdvisory = false; + } + } + $io->writeError($error); + } + + private function getCVE(SecurityAdvisory $advisory): string + { + if ($advisory->cve === null) { + return 'NO CVE'; + } + + return ''.$advisory->cve.''; + } + + private function getURL(SecurityAdvisory $advisory): string + { + if ($advisory->link === null) { + return ''; + } + + return 'link).'>'.OutputFormatter::escape($advisory->link).''; + } +} diff --git a/src/Composer/Advisory/PartialSecurityAdvisory.php b/src/Composer/Advisory/PartialSecurityAdvisory.php new file mode 100644 index 000000000..7a4cc7e12 --- /dev/null +++ b/src/Composer/Advisory/PartialSecurityAdvisory.php @@ -0,0 +1,48 @@ + $data + * @return SecurityAdvisory|PartialSecurityAdvisory + */ + public static function create(string $packageName, array $data, VersionParser $parser): self + { + $constraint = $parser->parseConstraints($data['affectedVersions']); + if (isset($data['title'], $data['sources'], $data['reportedAt'])) { + return new SecurityAdvisory($packageName, $data['advisoryId'], $constraint, $data['title'], $data['sources'], new \DateTimeImmutable($data['reportedAt'], new \DateTimeZone('UTC')), $data['cve'] ?? null, $data['link'] ?? null); + } + + return new self($packageName, $data['advisoryId'], $constraint); + } + + public function __construct(string $packageName, string $advisoryId, ConstraintInterface $affectedVersions) + { + $this->advisoryId = $advisoryId; + $this->packageName = $packageName; + $this->affectedVersions = $affectedVersions; + } +} diff --git a/src/Composer/Advisory/SecurityAdvisory.php b/src/Composer/Advisory/SecurityAdvisory.php new file mode 100644 index 000000000..e8b53e925 --- /dev/null +++ b/src/Composer/Advisory/SecurityAdvisory.php @@ -0,0 +1,54 @@ + + * @readonly + */ + public $sources; + + /** + * @param non-empty-array $sources + * @readonly + */ + public function __construct(string $packageName, string $advisoryId, ConstraintInterface $affectedVersions, string $title, array $sources, \DateTimeImmutable $reportedAt, ?string $cve = null, ?string $link = null) + { + parent::__construct($packageName, $advisoryId, $affectedVersions); + + $this->title = $title; + $this->sources = $sources; + $this->reportedAt = $reportedAt; + $this->cve = $cve; + $this->link = $link; + } +} diff --git a/src/Composer/Command/AuditCommand.php b/src/Composer/Command/AuditCommand.php index ff511625e..a5d0f5d6f 100644 --- a/src/Composer/Command/AuditCommand.php +++ b/src/Composer/Command/AuditCommand.php @@ -1,14 +1,15 @@ -requireComposer(); $packages = $this->getPackages($composer, $input); - $httpDownloader = $composer->getLoop()->getHttpDownloader(); if (count($packages) === 0) { $this->getIO()->writeError('No packages - skipping audit.'); return 0; } - $auditor = new Auditor($httpDownloader); + $auditor = new Auditor(); + $repoSet = new RepositorySet(); + foreach ($composer->getRepositoryManager()->getRepositories() as $repo) { + $repoSet->addRepository($repo); + } - return min(255, $auditor->audit($this->getIO(), $packages, $this->getAuditFormat($input, 'format'), false)); + return min(255, $auditor->audit($this->getIO(), $repoSet, $packages, $this->getAuditFormat($input, 'format'), false)); } /** diff --git a/src/Composer/Command/BaseCommand.php b/src/Composer/Command/BaseCommand.php index afd2ae33b..354b95be8 100644 --- a/src/Composer/Command/BaseCommand.php +++ b/src/Composer/Command/BaseCommand.php @@ -25,7 +25,7 @@ use Composer\IO\NullIO; use Composer\Plugin\PreCommandRunEvent; use Composer\Package\Version\VersionParser; use Composer\Plugin\PluginEvents; -use Composer\Util\Auditor; +use Composer\Advisory\Auditor; use Composer\Util\Platform; use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Completion\CompletionSuggestions; diff --git a/src/Composer/Command/CreateProjectCommand.php b/src/Composer/Command/CreateProjectCommand.php index 9156f04a8..f129b1523 100644 --- a/src/Composer/Command/CreateProjectCommand.php +++ b/src/Composer/Command/CreateProjectCommand.php @@ -44,7 +44,7 @@ use Composer\Util\Filesystem; use Composer\Util\Platform; use Composer\Util\ProcessExecutor; use Composer\Package\Version\VersionParser; -use Composer\Util\Auditor; +use Composer\Advisory\Auditor; /** * Install a package as new project into new directory. diff --git a/src/Composer/Command/InstallCommand.php b/src/Composer/Command/InstallCommand.php index 015c7362d..29b2d02f3 100644 --- a/src/Composer/Command/InstallCommand.php +++ b/src/Composer/Command/InstallCommand.php @@ -15,7 +15,7 @@ namespace Composer\Command; use Composer\Installer; use Composer\Plugin\CommandEvent; use Composer\Plugin\PluginEvents; -use Composer\Util\Auditor; +use Composer\Advisory\Auditor; use Composer\Util\HttpDownloader; use Symfony\Component\Console\Input\InputInterface; use Composer\Console\Input\InputOption; diff --git a/src/Composer/Command/RemoveCommand.php b/src/Composer/Command/RemoveCommand.php index bfa3fe825..ae26269bb 100644 --- a/src/Composer/Command/RemoveCommand.php +++ b/src/Composer/Command/RemoveCommand.php @@ -25,7 +25,7 @@ use Composer\Console\Input\InputOption; use Composer\Console\Input\InputArgument; use Symfony\Component\Console\Output\OutputInterface; use Composer\Package\BasePackage; -use Composer\Util\Auditor; +use Composer\Advisory\Auditor; /** * @author Pierre du Plessis diff --git a/src/Composer/Command/RequireCommand.php b/src/Composer/Command/RequireCommand.php index d2898fbba..37e01b854 100644 --- a/src/Composer/Command/RequireCommand.php +++ b/src/Composer/Command/RequireCommand.php @@ -31,7 +31,7 @@ use Composer\Plugin\PluginEvents; use Composer\Repository\CompositeRepository; use Composer\Repository\PlatformRepository; use Composer\IO\IOInterface; -use Composer\Util\Auditor; +use Composer\Advisory\Auditor; use Composer\Util\Silencer; /** diff --git a/src/Composer/Command/UpdateCommand.php b/src/Composer/Command/UpdateCommand.php index 7df4cc397..c4659693e 100644 --- a/src/Composer/Command/UpdateCommand.php +++ b/src/Composer/Command/UpdateCommand.php @@ -25,7 +25,7 @@ use Composer\Semver\Constraint\ConstraintInterface; use Composer\Util\HttpDownloader; use Composer\Semver\Constraint\MultiConstraint; use Composer\Package\Link; -use Composer\Util\Auditor; +use Composer\Advisory\Auditor; use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputInterface; use Composer\Console\Input\InputOption; diff --git a/src/Composer/Installer.php b/src/Composer/Installer.php index 3328931d3..737b58018 100644 --- a/src/Composer/Installer.php +++ b/src/Composer/Installer.php @@ -62,7 +62,7 @@ use Composer\Repository\RepositoryManager; use Composer\Repository\LockArrayRepository; use Composer\Script\ScriptEvents; use Composer\Semver\Constraint\ConstraintInterface; -use Composer\Util\Auditor; +use Composer\Advisory\Auditor; use Composer\Util\Platform; /** @@ -397,8 +397,12 @@ class Installer } if (count($packages) > 0) { try { - $auditor = new Auditor(Factory::createHttpDownloader($this->io, $this->config)); - $auditor->audit($this->io, $packages, $this->auditFormat); + $auditor = new Auditor(); + $repoSet = new RepositorySet(); + foreach ($this->repositoryManager->getRepositories() as $repo) { + $repoSet->addRepository($repo); + } + $auditor->audit($this->io, $repoSet, $packages, $this->auditFormat); } catch (TransportException $e) { $this->io->error('Failed to audit '.$target.' packages.'); if ($this->io->isVerbose()) { diff --git a/src/Composer/Repository/AdvisoryProviderInterface.php b/src/Composer/Repository/AdvisoryProviderInterface.php new file mode 100644 index 000000000..950f28302 --- /dev/null +++ b/src/Composer/Repository/AdvisoryProviderInterface.php @@ -0,0 +1,34 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Repository; + +use Composer\Semver\Constraint\ConstraintInterface; +use Composer\Advisory\PartialSecurityAdvisory; +use Composer\Advisory\SecurityAdvisory; + +/** + * Repositories that allow fetching security advisory data + * + * @author Jordi Boggiano + * @internal + */ +interface AdvisoryProviderInterface +{ + public function hasSecurityAdvisories(): bool; + + /** + * @param array $packageConstraintMap Map of package name to constraint (can be MatchAllConstraint to fetch all advisories) + * @return ($allowPartialAdvisories is true ? array{namesFound: string[], advisories: array>} : array{namesFound: string[], advisories: array>}) + */ + public function getSecurityAdvisories(array $packageConstraintMap, bool $allowPartialAdvisories = false): array; +} diff --git a/src/Composer/Repository/ComposerRepository.php b/src/Composer/Repository/ComposerRepository.php index 1c07f60ba..fabf2d2b8 100644 --- a/src/Composer/Repository/ComposerRepository.php +++ b/src/Composer/Repository/ComposerRepository.php @@ -12,6 +12,8 @@ namespace Composer\Repository; +use Composer\Advisory\PartialSecurityAdvisory; +use Composer\Advisory\SecurityAdvisory; use Composer\Package\BasePackage; use Composer\Package\Loader\ArrayLoader; use Composer\Package\PackageInterface; @@ -40,11 +42,12 @@ use Composer\Util\Http\Response; use Composer\MetadataMinifier\MetadataMinifier; use Composer\Util\Url; use React\Promise\PromiseInterface; +use function React\Promise\resolve; /** * @author Jordi Boggiano */ -class ComposerRepository extends ArrayRepository implements ConfigurableRepositoryInterface +class ComposerRepository extends ArrayRepository implements ConfigurableRepositoryInterface, AdvisoryProviderInterface { /** * @var mixed[] @@ -107,6 +110,8 @@ class ComposerRepository extends ArrayRepository implements ConfigurableReposito private $partialPackagesByName = null; /** @var bool */ private $displayedWarningAboutNonMatchingPackageIndex = false; + /** @var array{metadata: bool, query-all: bool, api-url: string|null}|null */ + private $securityAdvisoryConfig = null; /** * @var array list of package names which are fresh and can be loaded from the cache directly in case loadPackage is called several times @@ -472,7 +477,7 @@ class ComposerRepository extends ArrayRepository implements ConfigurableReposito // this call initializes loadRootServerFile which is needed for the rest below to work $hasProviders = $this->hasProviders(); - if (!$hasProviders && !$this->hasPartialPackages() && !$this->lazyProvidersUrl) { + if (!$hasProviders && !$this->hasPartialPackages() && null === $this->lazyProvidersUrl) { return parent::loadPackages($packageNameMap, $acceptableStabilities, $stabilityFlags, $alreadyLoaded); } @@ -604,6 +609,102 @@ class ComposerRepository extends ArrayRepository implements ConfigurableReposito return parent::search($query, $mode); } + public function hasSecurityAdvisories(): bool + { + $this->loadRootServerFile(600); + return $this->securityAdvisoryConfig !== null && ($this->securityAdvisoryConfig['metadata'] || $this->securityAdvisoryConfig['api-url'] !== null); + } + + /** + * @inheritDoc + */ + public function getSecurityAdvisories(array $packageConstraintMap, bool $allowPartialAdvisories = false): array + { + $this->loadRootServerFile(600); + if (null === $this->securityAdvisoryConfig) { + return ['namesFound' => [], 'advisories' => []]; + } + + $advisories = []; + $namesFound = []; + + $apiUrl = $this->securityAdvisoryConfig['api-url']; + + $parser = new VersionParser(); + /** + * @param array $data + * @param string $name + * @return ($allowPartialAdvisories is false ? SecurityAdvisory|null : PartialSecurityAdvisory|SecurityAdvisory|null) + */ + $create = function (array $data, string $name) use ($parser, $allowPartialAdvisories, &$packageConstraintMap): ?PartialSecurityAdvisory { + $advisory = PartialSecurityAdvisory::create($name, $data, $parser); + if (!$allowPartialAdvisories && !$advisory instanceof SecurityAdvisory) { + throw new \RuntimeException('Advisory for '.$name.' could not be loaded as a full advisory from '.$this->getRepoName() . PHP_EOL . var_export($data, true)); + } + if (!$advisory->affectedVersions->matches($packageConstraintMap[$name])) { + return null; + } + + return $advisory; + }; + + if ($this->securityAdvisoryConfig['metadata'] && ($allowPartialAdvisories || $apiUrl === null)) { + $promises = []; + foreach ($packageConstraintMap as $name => $constraint) { + $name = strtolower($name); + + // skip platform packages, root package and composer-plugin-api + if (PlatformRepository::isPlatformPackage($name) || '__root__' === $name) { + continue; + } + + $promises[] = $this->startCachedAsyncDownload($name, $name) + ->then(function (array $spec) use (&$advisories, &$namesFound, &$packageConstraintMap, $name, $create): void { + list($response, ) = $spec; + + if (!isset($response['security-advisories']) || !is_array($response['security-advisories'])) { + return; + } + + $namesFound[$name] = true; + if (count($response['security-advisories']) > 0) { + $advisories[$name] = array_filter(array_map( + function ($data) use ($name, $create) { return $create($data, $name); }, + $response['security-advisories'] + )); + } + unset($packageConstraintMap[$name]); + }); + } + + $this->loop->wait($promises); + } + + if ($apiUrl !== null && count($packageConstraintMap) > 0) { + $options = [ + 'http' => [ + 'method' => 'POST', + 'header' => ['Content-type: application/x-www-form-urlencoded'], + 'timeout' => 10, + 'content' => http_build_query(['packages' => array_keys($packageConstraintMap)]), + ], + ]; + $response = $this->httpDownloader->get($apiUrl, $options); + /** @var string $name */ + foreach ($response->decodeJson()['advisories'] as $name => $list) { + if (count($list) > 0) { + $advisories[$name] = array_filter(array_map( + function ($data) use ($name, $create) { return $create($data, $name); }, + $list + )); + } + $namesFound[$name] = true; + } + } + + return ['namesFound' => array_keys($namesFound), 'advisories' => array_filter($advisories)]; + } + public function getProviders(string $packageName) { $this->loadRootServerFile(); @@ -863,7 +964,8 @@ class ComposerRepository extends ArrayRepository implements ConfigurableReposito } /** - * @param array $packageNames array of package name => ConstraintInterface|null - if a constraint is provided, only packages matching it will be loaded + * @param array $packageNames array of package name => ConstraintInterface|null - if a constraint is provided, only + * packages matching it will be loaded * @param array|null $acceptableStabilities * @phpstan-param array|null $acceptableStabilities * @param array|null $stabilityFlags an array of package name => BasePackage::STABILITY_* value @@ -880,7 +982,7 @@ class ComposerRepository extends ArrayRepository implements ConfigurableReposito $namesFound = array(); $promises = array(); - if (!$this->lazyProvidersUrl) { + if (null === $this->lazyProvidersUrl) { throw new \LogicException('loadAsyncPackages only supports v2 protocol composer repos with a metadata-url'); } @@ -904,25 +1006,10 @@ class ComposerRepository extends ArrayRepository implements ConfigurableReposito continue; } - $url = str_replace('%package%', $name, $this->lazyProvidersUrl); - $cacheKey = 'provider-'.strtr($name, '/', '~').'.json'; - - $lastModified = null; - if ($contents = $this->cache->read($cacheKey)) { - $contents = json_decode($contents, true); - $lastModified = $contents['last-modified'] ?? null; - } - - $promises[] = $this->asyncFetchFile($url, $cacheKey, $lastModified) - ->then(function ($response) use (&$packages, &$namesFound, $url, $cacheKey, $contents, $realName, $constraint, $acceptableStabilities, $stabilityFlags, $alreadyLoaded): void { - $packagesSource = 'downloaded file ('.Url::sanitize($url).')'; - - if (true === $response) { - $packagesSource = 'cached file ('.$cacheKey.' originating from '.Url::sanitize($url).')'; - $response = $contents; - } - - if (!isset($response['packages'][$realName])) { + $promises[] = $this->startCachedAsyncDownload($name, $realName) + ->then(function (array $spec) use (&$packages, &$namesFound, $realName, $constraint, $acceptableStabilities, $stabilityFlags, $alreadyLoaded): void { + list($response, $packagesSource) = $spec; + if (null === $response) { return; } @@ -968,7 +1055,41 @@ class ComposerRepository extends ArrayRepository implements ConfigurableReposito $this->loop->wait($promises); return array('namesFound' => $namesFound, 'packages' => $packages); - // RepositorySet should call loadMetadata, getMetadata when all promises resolved, then metadataComplete when done so we can GC the loaded json and whatnot then as needed + } + + private function startCachedAsyncDownload(string $fileName, string $packageName = null): PromiseInterface + { + if (null === $this->lazyProvidersUrl) { + throw new \LogicException('startCachedAsyncDownload only supports v2 protocol composer repos with a metadata-url'); + } + + $name = strtolower($fileName); + $packageName = $packageName ?? $name; + + $url = str_replace('%package%', $name, $this->lazyProvidersUrl); + $cacheKey = 'provider-'.strtr($name, '/', '~').'.json'; + + $lastModified = null; + if ($contents = $this->cache->read($cacheKey)) { + $contents = json_decode($contents, true); + $lastModified = $contents['last-modified'] ?? null; + } + + return $this->asyncFetchFile($url, $cacheKey, $lastModified) + ->then(function ($response) use ($url, $cacheKey, $contents, $packageName): array { + $packagesSource = 'downloaded file ('.Url::sanitize($url).')'; + + if (true === $response) { + $packagesSource = 'cached file ('.$cacheKey.' originating from '.Url::sanitize($url).')'; + $response = $contents; + } + + if (!isset($response['packages'][$packageName])) { + return [null, $packagesSource]; + } + + return [$response, $packagesSource]; + }); } /** @@ -1113,6 +1234,14 @@ class ComposerRepository extends ArrayRepository implements ConfigurableReposito // Remove legacy keys as most repos need to be compatible with Composer v1 // as well but we are not interested in the old format anymore at this point unset($data['providers-url'], $data['providers'], $data['providers-includes']); + + if (isset($data['security-advisories']) && is_array($data['security-advisories'])) { + $this->securityAdvisoryConfig = [ + 'metadata' => $data['security-advisories']['metadata'] ?? false, + 'api-url' => $data['security-advisories']['api-url'] ?? null, + 'query-all' => $data['security-advisories']['query-all'] ?? false, + ]; + } } if ($this->allowSslDowngrade) { diff --git a/src/Composer/Repository/RepositorySet.php b/src/Composer/Repository/RepositorySet.php index e656d18c5..c9183ce84 100644 --- a/src/Composer/Repository/RepositorySet.php +++ b/src/Composer/Repository/RepositorySet.php @@ -17,14 +17,19 @@ use Composer\DependencyResolver\Pool; use Composer\DependencyResolver\PoolBuilder; use Composer\DependencyResolver\Request; use Composer\EventDispatcher\EventDispatcher; +use Composer\Advisory\SecurityAdvisory; +use Composer\Advisory\PartialSecurityAdvisory; use Composer\IO\IOInterface; use Composer\IO\NullIO; use Composer\Package\BasePackage; use Composer\Package\AliasPackage; use Composer\Package\CompleteAliasPackage; use Composer\Package\CompletePackage; +use Composer\Package\PackageInterface; +use Composer\Semver\Constraint\Constraint; use Composer\Semver\Constraint\ConstraintInterface; use Composer\Package\Version\StabilityFilter; +use Composer\Semver\Constraint\MatchAllConstraint; /** * @author Nils Adermann @@ -226,6 +231,57 @@ class RepositorySet return $result; } + /** + * @param string[] $packageNames + * @return ($allowPartialAdvisories is true ? array> : array>) + */ + public function getSecurityAdvisories(array $packageNames, bool $allowPartialAdvisories = false): array + { + $map = []; + foreach ($packageNames as $name) { + $map[$name] = new MatchAllConstraint(); + } + + return $this->getSecurityAdvisoriesForConstraints($map, $allowPartialAdvisories); + } + + /** + * @param PackageInterface[] $packages + * @return ($allowPartialAdvisories is true ? array> : array>) + */ + public function getMatchingSecurityAdvisories(array $packages, bool $allowPartialAdvisories = false): array + { + $map = []; + foreach ($packages as $package) { + $map[$package->getName()] = new Constraint('=', $package->getVersion()); + } + + return $this->getSecurityAdvisoriesForConstraints($map, $allowPartialAdvisories); + } + + /** + * @param array $packageConstraintMap + * @return ($allowPartialAdvisories is true ? array> : array>) + */ + private function getSecurityAdvisoriesForConstraints(array $packageConstraintMap, bool $allowPartialAdvisories): array + { + $advisories = []; + foreach ($this->repositories as $repository) { + if (!$repository instanceof AdvisoryProviderInterface || !$repository->hasSecurityAdvisories()) { + continue; + } + + $result = $repository->getSecurityAdvisories($packageConstraintMap, $allowPartialAdvisories); + foreach ($result['namesFound'] as $nameFound) { + unset($packageConstraintMap[$nameFound]); + } + + $advisories = array_merge($advisories, $result['advisories']); + } + + return $advisories; + } + /** * @param string $packageName * diff --git a/src/Composer/Util/Auditor.php b/src/Composer/Util/Auditor.php deleted file mode 100644 index ca45c45f0..000000000 --- a/src/Composer/Util/Auditor.php +++ /dev/null @@ -1,250 +0,0 @@ -httpDownloader = $httpDownloader; - } - - /** - * @param IOInterface $io - * @param PackageInterface[] $packages - * @param self::FORMAT_* $format The format that will be used to output audit results. - * @param bool $warningOnly If true, outputs a warning. If false, outputs an error. - * @return int Amount of advisories found - * @throws InvalidArgumentException If no packages are passed in - */ - public function audit(IOInterface $io, array $packages, string $format, bool $warningOnly = true): int - { - $advisories = $this->getAdvisories($packages); - $errorOrWarn = $warningOnly ? 'warning' : 'error'; - if (count($advisories) > 0) { - $numAdvisories = $this->countAdvisories($advisories); - $plurality = $numAdvisories === 1 ? 'y' : 'ies'; - $punctuation = $format === 'summary' ? '.' : ':'; - $io->writeError("<$errorOrWarn>Found $numAdvisories security vulnerability advisor{$plurality}{$punctuation}"); - $this->outputAdvisories($io, $advisories, $format); - - return count($advisories); - } - - $io->writeError('No security vulnerability advisories found'); - - return 0; - } - - /** - * Get advisories from packagist.org - * - * @param PackageInterface[] $packages - * @param ?int $updatedSince Timestamp - * @param bool $filterByVersion Filter by the package versions if true - * @return string[][][] - * @throws InvalidArgumentException If no packages and no updatedSince timestamp are passed in - */ - public function getAdvisories(array $packages = [], int $updatedSince = null, bool $filterByVersion = true): 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 query to URL if passed in - $url = self::API_URL; - if ($updatedSince !== null) { - $url .= "?updatedSince=$updatedSince"; - } - - // Get advisories from API - $response = $this->httpDownloader->get($url, $this->createPostOptions($packages)); - $advisories = $response->decodeJson()['advisories']; - - if (count($advisories) > 0 && $filterByVersion) { - return $this->filterAdvisories($advisories, $packages); - } - - return $advisories; - } - - /** - * @param PackageInterface[] $packages - * @return string[] - * @phpstan-return array|int|string>> - */ - private function createPostOptions(array $packages): array - { - $options = [ - 'http' => [ - 'method' => 'POST', - 'header' => ['Content-type: application/x-www-form-urlencoded'], - 'timeout' => 10, - ], - ]; - if (count($packages) > 0) { - $content = ['packages' => []]; - foreach ($packages as $package) { - $content['packages'][] = $package->getName(); - } - $options['http']['content'] = http_build_query($content); - } - return $options; - } - - /** - * @param string[][][] $advisories - * @param PackageInterface[] $packages - * @return string[][][] - */ - private function filterAdvisories(array $advisories, array $packages): array - { - $filteredAdvisories = []; - foreach ($packages as $package) { - if (array_key_exists($package->getName(), $advisories)) { - foreach ($advisories[$package->getName()] as $advisory) { - if (Semver::satisfies($package->getVersion(), $advisory['affectedVersions'])) { - $filteredAdvisories[$package->getName()][] = $advisory; - } - } - } - } - return $filteredAdvisories; - } - - /** - * @param string[][][] $advisories - * @return integer - */ - private function countAdvisories(array $advisories): int - { - $count = 0; - foreach ($advisories as $packageAdvisories) { - $count += count($packageAdvisories); - } - return $count; - } - - /** - * @param IOInterface $io - * @param string[][][] $advisories - * @param self::FORMAT_* $format The format that will be used to output audit results. - * @return void - */ - private function outputAdvisories(IOInterface $io, array $advisories, string $format): void - { - switch ($format) { - case self::FORMAT_TABLE: - if (!($io instanceof ConsoleIO)) { - throw new InvalidArgumentException('Cannot use table format with ' . get_class($io)); - } - $this->outputAvisoriesTable($io, $advisories); - return; - case self::FORMAT_PLAIN: - $this->outputAdvisoriesPlain($io, $advisories); - return; - case self::FORMAT_SUMMARY: - // We've already output the number of advisories in audit() - $io->writeError('Run composer audit for a full list of advisories.'); - return; - default: - throw new InvalidArgumentException('Invalid format "'.$format.'".'); - } - } - - /** - * @param ConsoleIO $io - * @param string[][][] $advisories - * @return void - */ - private function outputAvisoriesTable(ConsoleIO $io, array $advisories): void - { - foreach ($advisories as $package => $packageAdvisories) { - foreach ($packageAdvisories as $advisory) { - $io->getTable() - ->setHorizontal() - ->setHeaders([ - 'Package', - 'CVE', - 'Title', - 'URL', - 'Affected versions', - 'Reported at', - ]) - ->addRow([ - $package, - $advisory['cve'] ?? 'NO CVE', - $advisory['title'], - $advisory['link'], - $advisory['affectedVersions'], - $advisory['reportedAt'], - ]) - ->setColumnWidth(1, 80) - ->setColumnMaxWidth(1, 80) - ->render(); - } - } - } - - /** - * @param IOInterface $io - * @param string[][][] $advisories - * @return void - */ - private function outputAdvisoriesPlain(IOInterface $io, array $advisories): void - { - $error = []; - $firstAdvisory = true; - foreach ($advisories as $package => $packageAdvisories) { - foreach ($packageAdvisories as $advisory) { - if (!$firstAdvisory) { - $error[] = '--------'; - } - $cve = $advisory['cve'] ?? 'NO CVE'; - $error[] = "Package: $package"; - $error[] = "CVE: $cve"; - $error[] = "Title: {$advisory['title']}"; - $error[] = "URL: {$advisory['link']}"; - $error[] = "Affected versions: {$advisory['affectedVersions']}"; - $error[] = "Reported at: {$advisory['reportedAt']}"; - $firstAdvisory = false; - } - } - $io->writeError($error); - } -} diff --git a/tests/Composer/Test/Advisory/AuditorTest.php b/tests/Composer/Test/Advisory/AuditorTest.php new file mode 100644 index 000000000..1dec611d4 --- /dev/null +++ b/tests/Composer/Test/Advisory/AuditorTest.php @@ -0,0 +1,289 @@ + [ + 'packages' => [ + new Package('vendor1/package2', '9.0.0', '9.0.0'), + new Package('vendor1/package1', '9.0.0', '9.0.0'), + new Package('vendor3/package1', '9.0.0', '9.0.0'), + ], + 'warningOnly' => true, + ], + 'expected' => 0, + 'message' => 'Test no advisories returns 0', + ], + // Test with advisories returns 1 + [ + 'data' => [ + 'packages' => [ + new Package('vendor1/package2', '9.0.0', '9.0.0'), + new Package('vendor1/package1', '8.2.1', '8.2.1'), + new Package('vendor3/package1', '9.0.0', '9.0.0'), + ], + 'warningOnly' => true, + ], + 'expected' => 1, + 'message' => 'Test with advisories returns 1', + ], + ]; + } + + /** + * @dataProvider auditProvider + * @phpstan-param array $data + */ + public function testAudit(array $data, int $expected, string $message): void + { + if (count($data['packages']) === 0) { + $this->expectException(InvalidArgumentException::class); + } + $auditor = new Auditor(); + $result = $auditor->audit(new NullIO(), $this->getRepoSet(), $data['packages'], Auditor::FORMAT_PLAIN, $data['warningOnly']); + $this->assertSame($expected, $result, $message); + } + + private function getRepoSet(): RepositorySet + { + $repo = $this + ->getMockBuilder(ComposerRepository::class) + ->disableOriginalConstructor() + ->onlyMethods(['hasSecurityAdvisories', 'getSecurityAdvisories']) + ->getMock(); + + $repoSet = new RepositorySet(); + $repoSet->addRepository($repo); + + $repo + ->method('hasSecurityAdvisories') + ->willReturn(true); + + $repo + ->method('getSecurityAdvisories') + ->willReturnCallback(function (array $packageConstraintMap, bool $allowPartialAdvisories) { + $advisories = []; + + $parser = new VersionParser(); + /** + * @param array $data + * @param string $name + * @return ($allowPartialAdvisories is false ? SecurityAdvisory|null : PartialSecurityAdvisory|SecurityAdvisory|null) + */ + $create = function (array $data, string $name) use ($parser, $allowPartialAdvisories, $packageConstraintMap): ?PartialSecurityAdvisory { + $advisory = PartialSecurityAdvisory::create($name, $data, $parser); + if (!$allowPartialAdvisories && !$advisory instanceof SecurityAdvisory) { + throw new \RuntimeException('Advisory for '.$name.' could not be loaded as a full advisory from test repo'); + } + if (!$advisory->affectedVersions->matches($packageConstraintMap[$name])) { + return null; + } + + return $advisory; + }; + + foreach (self::getMockAdvisories() as $package => $list) { + if (!isset($packageConstraintMap[$package])) { + continue; + } + $advisories[$package] = array_filter(array_map( + function ($data) use ($package, $create) { return $create($data, $package); }, + $list + )); + } + + return ['namesFound' => array_keys($packageConstraintMap), 'advisories' => array_filter($advisories)]; + }); + + return $repoSet; + } + + /** + * @return array + */ + public static function getMockAdvisories(): array + { + $advisories = [ + 'vendor1/package1' => [ + [ + 'advisoryId' => 'ID1', + 'packageName' => 'vendor1/package1', + 'title' => 'advisory1', + 'link' => 'https://advisory.example.com/advisory1', + 'cve' => 'CVE1', + 'affectedVersions' => '>=3,<3.4.3|>=1,<2.5.6', + 'sources' => [ + [ + 'name' => 'source1', + 'remoteId' => 'RemoteID1', + ], + ], + 'reportedAt' => '2022-05-25 13:21:00', + 'composerRepository' => 'https://packagist.org', + ], + [ + 'advisoryId' => 'ID4', + 'packageName' => 'vendor1/package1', + 'title' => 'advisory4', + 'link' => 'https://advisory.example.com/advisory4', + 'cve' => 'CVE3', + 'affectedVersions' => '>=8,<8.2.2|>=1,<2.5.6', + 'sources' => [ + [ + 'name' => 'source2', + 'remoteId' => 'RemoteID2', + ], + ], + 'reportedAt' => '2022-05-25 13:21:00', + 'composerRepository' => 'https://packagist.org', + ], + [ + 'advisoryId' => 'ID5', + 'packageName' => 'vendor1/package1', + 'title' => 'advisory5', + 'link' => 'https://advisory.example.com/advisory5', + 'cve' => '', + 'affectedVersions' => '>=8,<8.2.2|>=1,<2.5.6', + 'sources' => [ + [ + 'name' => 'source1', + 'remoteId' => 'RemoteID3', + ], + ], + 'reportedAt' => '', + 'composerRepository' => 'https://packagist.org', + ] + ], + 'vendor1/package2' => [ + [ + 'advisoryId' => 'ID2', + 'packageName' => 'vendor1/package2', + 'title' => 'advisory2', + 'link' => 'https://advisory.example.com/advisory2', + 'cve' => '', + 'affectedVersions' => '>=3,<3.4.3|>=1,<2.5.6', + 'sources' => [ + [ + 'name' => 'source1', + 'remoteId' => 'RemoteID2', + ], + ], + 'reportedAt' => '2022-05-25 13:21:00', + 'composerRepository' => 'https://packagist.org', + ], + ], + 'vendorx/packagex' => [ + [ + 'advisoryId' => 'IDx', + 'packageName' => 'vendorx/packagex', + 'title' => 'advisory7', + 'link' => 'https://advisory.example.com/advisory7', + 'cve' => 'CVE5', + 'affectedVersions' => '>=3,<3.4.3|>=1,<2.5.6', + 'sources' => [ + [ + 'name' => 'source2', + 'remoteId' => 'RemoteID4', + ], + ], + 'reportedAt' => '2015-05-25 13:21:00', + 'composerRepository' => 'https://packagist.org', + ], + ], + 'vendor2/package1' => [ + [ + 'advisoryId' => 'ID3', + 'packageName' => 'vendor2/package1', + 'title' => 'advisory3', + 'link' => 'https://advisory.example.com/advisory3', + 'cve' => 'CVE2', + 'affectedVersions' => '>=3,<3.4.3|>=1,<2.5.6', + 'sources' => [ + [ + 'name' => 'source2', + 'remoteId' => 'RemoteID1', + ], + ], + 'reportedAt' => '2022-05-25 13:21:00', + 'composerRepository' => 'https://packagist.org', + ], + [ + 'advisoryId' => 'ID6', + 'packageName' => 'vendor2/package1', + 'title' => 'advisory6', + 'link' => 'https://advisory.example.com/advisory6', + 'cve' => 'CVE4', + 'affectedVersions' => '>=3,<3.4.3|>=1,<2.5.6', + 'sources' => [ + [ + 'name' => 'source2', + 'remoteId' => 'RemoteID3', + ], + ], + 'reportedAt' => '2015-05-25 13:21:00', + 'composerRepository' => 'https://packagist.org', + ] + ], + 'vendory/packagey' => [ + [ + 'advisoryId' => 'IDy', + 'packageName' => 'vendory/packagey', + 'title' => 'advisory7', + 'link' => 'https://advisory.example.com/advisory7', + 'cve' => 'CVE5', + 'affectedVersions' => '>=3,<3.4.3|>=1,<2.5.6', + 'sources' => [ + [ + 'name' => 'source2', + 'remoteId' => 'RemoteID4', + ], + ], + 'reportedAt' => '2015-05-25 13:21:00', + 'composerRepository' => 'https://packagist.org', + ], + ], + 'vendor3/package1' => [ + [ + 'advisoryId' => 'ID7', + 'packageName' => 'vendor3/package1', + 'title' => 'advisory7', + 'link' => 'https://advisory.example.com/advisory7', + 'cve' => 'CVE5', + 'affectedVersions' => '>=3,<3.4.3|>=1,<2.5.6', + 'sources' => [ + [ + 'name' => 'source2', + 'remoteId' => 'RemoteID4', + ], + ], + 'reportedAt' => '2015-05-25 13:21:00', + 'composerRepository' => 'https://packagist.org', + ], + ], + ]; + + return $advisories; + } +} diff --git a/tests/Composer/Test/Util/AuditorTest.php b/tests/Composer/Test/Util/AuditorTest.php deleted file mode 100644 index ad4c06f65..000000000 --- a/tests/Composer/Test/Util/AuditorTest.php +++ /dev/null @@ -1,395 +0,0 @@ - [ - 'packages' => [ - new Package('vendor1/package2', '9.0.0', '9.0.0'), - new Package('vendor1/package1', '9.0.0', '9.0.0'), - new Package('vendor3/package1', '9.0.0', '9.0.0'), - ], - 'warningOnly' => true, - ], - 'expected' => 0, - 'message' => 'Test no advisories returns 0', - ], - // Test with advisories returns 1 - [ - 'data' => [ - 'packages' => [ - new Package('vendor1/package2', '9.0.0', '9.0.0'), - new Package('vendor1/package1', '8.2.1', '8.2.1'), - new Package('vendor3/package1', '9.0.0', '9.0.0'), - ], - 'warningOnly' => true, - ], - 'expected' => 1, - 'message' => 'Test with advisories returns 1', - ], - // Test no packages throws InvalidArgumentException - [ - 'data' => [ - 'packages' => [], - 'warningOnly' => true, - ], - 'expected' => 1, - 'message' => 'Test no packages throws InvalidArgumentException', - ], - ]; - } - - /** - * @dataProvider auditProvider - * @phpstan-param array $data - */ - public function testAudit(array $data, int $expected, string $message): void - { - if (count($data['packages']) === 0) { - $this->expectException(InvalidArgumentException::class); - } - $auditor = new Auditor($this->getHttpDownloader()); - $result = $auditor->audit(new NullIO(), $data['packages'], Auditor::FORMAT_PLAIN, $data['warningOnly']); - $this->assertSame($expected, $result, $message); - } - - /** - * @return mixed[] - */ - public function advisoriesProvider(): array - { - $advisories = static::getMockAdvisories(null); - return [ - [ - 'data' => [ - 'packages' => [ - new Package('vendor1/package1', '8.2.1', '8.2.1'), - new Package('vendor1/package2', '3.1.0', '3.1.0'), - // Check a package with no advisories at all doesn't cause any issues - new Package('vendor5/package2', '5.0.0', '5.0.0'), - ], - 'updatedSince' => null, - 'filterByVersion' => false - ], - 'expected' => [ - 'vendor1/package1' => $advisories['vendor1/package1'], - 'vendor1/package2' => $advisories['vendor1/package2'], - ], - 'message' => 'Check not filtering by version', - ], - [ - 'data' => [ - 'packages' => [ - new Package('vendor1/package1', '8.2.1', '8.2.1'), - new Package('vendor1/package2', '3.1.0', '3.1.0'), - // Check a package with no advisories at all doesn't cause any issues - new Package('vendor5/package2', '5.0.0', '5.0.0'), - ], - 'updatedSince' => null, - 'filterByVersion' => true - ], - 'expected' => [ - 'vendor1/package1' => [ - $advisories['vendor1/package1'][1], - $advisories['vendor1/package1'][2], - ], - 'vendor1/package2' => [ - $advisories['vendor1/package2'][0], - ], - ], - 'message' => 'Check filter by version', - ], - [ - 'data' => [ - 'packages' => [ - new Package('vendor1/package1', '8.2.1', '8.2.1'), - new Package('vendor1/package2', '5.0.0', '5.0.0'), - new Package('vendor2/package1', '3.0.0', '3.0.0'), - ], - 'updatedSince' => 1335939007, - 'filterByVersion' => false - ], - 'expected' => [ - 'vendor1/package1' => [ - $advisories['vendor1/package1'][0], - $advisories['vendor1/package1'][1], - ], - 'vendor1/package2' => [ - $advisories['vendor1/package2'][0], - ], - 'vendor2/package1' => [ - $advisories['vendor2/package1'][0], - ], - ], - 'message' => 'Check updatedSince is passed through to the API', - ], - [ - 'data' => [ - 'packages' => [], - 'updatedSince' => 1335939007, - 'filterByVersion' => true - ], - 'expected' => [], - 'message' => 'No packages and filterByVersion === true should return 0 results', - ], - [ - 'data' => [ - 'packages' => [], - 'updatedSince' => 0, - 'filterByVersion' => false - ], - // All advisories expected with no packages and updatedSince === 0 - 'expected' => $advisories, - 'message' => 'No packages and updatedSince === 0 should NOT throw LogicException', - ], - [ - 'data' => [ - 'packages' => [], - 'updatedSince' => null, - 'filterByVersion' => false - ], - 'expected' => [], - 'message' => 'No packages and updatedSince === null should throw LogicException', - ], - ]; - } - - /** - * @dataProvider advisoriesProvider - * @phpstan-param array $data - * @phpstan-param string[][][] $expected - */ - public function testGetAdvisories(array $data, array $expected, string $message): void - { - if (count($data['packages']) === 0 && $data['updatedSince'] === null) { - $this->expectException(InvalidArgumentException::class); - } - $auditor = new Auditor($this->getHttpDownloader()); - $result = $auditor->getAdvisories($data['packages'], $data['updatedSince'], $data['filterByVersion']); - $this->assertSame($expected, $result, $message); - } - - /** - * @return HttpDownloader&MockObject - */ - private function getHttpDownloader(): MockObject - { - $httpDownloader = $this - ->getMockBuilder(HttpDownloader::class) - ->disableOriginalConstructor() - ->onlyMethods(['get']) - ->getMock(); - - $callback = function(string $url, array $options) { - parse_str((string) parse_url($url, PHP_URL_QUERY), $query); - $updatedSince = null; - if (isset($query['updatedSince'])) { - $updatedSince = $query['updatedSince']; - } - - $advisories = AuditorTest::getMockAdvisories($updatedSince); - - // If the mock API request is for specific packages, only include advisories for those packages - if (isset($options['http']['content'])) { - parse_str($options['http']['content'], $body); - $packages = $body['packages']; - foreach ($advisories as $package => $data) { - if (!in_array($package, $packages, true)) { - unset($advisories[$package]); - } - } - } - - return new Response(['url' => 'https://packagist.org/api/security-advisories/'], 200, [], JsonFile::encode(['advisories' => $advisories])); - }; - - $httpDownloader - ->method('get') - ->willReturnCallback($callback); - - return $httpDownloader; - } - - /** - * @return array - */ - public static function getMockAdvisories(?int $updatedSince): array - { - $advisories = [ - 'vendor1/package1' => [ - [ - 'advisoryId' => 'ID1', - 'packageName' => 'vendor1/package1', - 'title' => 'advisory1', - 'link' => 'https://advisory.example.com/advisory1', - 'cve' => 'CVE1', - 'affectedVersions' => '>=3,<3.4.3|>=1,<2.5.6', - 'sources' => [ - [ - 'name' => 'source1', - 'remoteId' => 'RemoteID1', - ], - ], - 'reportedAt' => '2022-05-25 13:21:00', - 'composerRepository' => 'https://packagist.org', - ], - [ - 'advisoryId' => 'ID4', - 'packageName' => 'vendor1/package1', - 'title' => 'advisory4', - 'link' => 'https://advisory.example.com/advisory4', - 'cve' => 'CVE3', - 'affectedVersions' => '>=8,<8.2.2|>=1,<2.5.6', - 'sources' => [ - [ - 'name' => 'source2', - 'remoteId' => 'RemoteID2', - ], - ], - 'reportedAt' => '2022-05-25 13:21:00', - 'composerRepository' => 'https://packagist.org', - ], - ], - 'vendor1/package2' => [ - [ - 'advisoryId' => 'ID2', - 'packageName' => 'vendor1/package2', - 'title' => 'advisory2', - 'link' => 'https://advisory.example.com/advisory2', - 'cve' => '', - 'affectedVersions' => '>=3,<3.4.3|>=1,<2.5.6', - 'sources' => [ - [ - 'name' => 'source1', - 'remoteId' => 'RemoteID2', - ], - ], - 'reportedAt' => '2022-05-25 13:21:00', - 'composerRepository' => 'https://packagist.org', - ], - ], - 'vendorx/packagex' => [ - [ - 'advisoryId' => 'IDx', - 'packageName' => 'vendorx/packagex', - 'title' => 'advisory7', - 'link' => 'https://advisory.example.com/advisory7', - 'cve' => 'CVE5', - 'affectedVersions' => '>=3,<3.4.3|>=1,<2.5.6', - 'sources' => [ - [ - 'name' => 'source2', - 'remoteId' => 'RemoteID4', - ], - ], - 'reportedAt' => '2015-05-25 13:21:00', - 'composerRepository' => 'https://packagist.org', - ], - ], - 'vendor2/package1' => [ - [ - 'advisoryId' => 'ID3', - 'packageName' => 'vendor2/package1', - 'title' => 'advisory3', - 'link' => 'https://advisory.example.com/advisory3', - 'cve' => 'CVE2', - 'affectedVersions' => '>=3,<3.4.3|>=1,<2.5.6', - 'sources' => [ - [ - 'name' => 'source2', - 'remoteId' => 'RemoteID1', - ], - ], - 'reportedAt' => '2022-05-25 13:21:00', - 'composerRepository' => 'https://packagist.org', - ], - ], - ]; - - if (0 === $updatedSince || null === $updatedSince) { - $advisories['vendor1/package1'][] = [ - 'advisoryId' => 'ID5', - 'packageName' => 'vendor1/package1', - 'title' => 'advisory5', - 'link' => 'https://advisory.example.com/advisory5', - 'cve' => '', - 'affectedVersions' => '>=8,<8.2.2|>=1,<2.5.6', - 'sources' => [ - [ - 'name' => 'source1', - 'remoteId' => 'RemoteID3', - ], - ], - 'reportedAt' => '', - 'composerRepository' => 'https://packagist.org', - ]; - $advisories['vendor2/package1'][] = [ - 'advisoryId' => 'ID6', - 'packageName' => 'vendor2/package1', - 'title' => 'advisory6', - 'link' => 'https://advisory.example.com/advisory6', - 'cve' => 'CVE4', - 'affectedVersions' => '>=3,<3.4.3|>=1,<2.5.6', - 'sources' => [ - [ - 'name' => 'source2', - 'remoteId' => 'RemoteID3', - ], - ], - 'reportedAt' => '2015-05-25 13:21:00', - 'composerRepository' => 'https://packagist.org', - ]; - $advisories['vendory/packagey'][] = [ - 'advisoryId' => 'IDy', - 'packageName' => 'vendory/packagey', - 'title' => 'advisory7', - 'link' => 'https://advisory.example.com/advisory7', - 'cve' => 'CVE5', - 'affectedVersions' => '>=3,<3.4.3|>=1,<2.5.6', - 'sources' => [ - [ - 'name' => 'source2', - 'remoteId' => 'RemoteID4', - ], - ], - 'reportedAt' => '2015-05-25 13:21:00', - 'composerRepository' => 'https://packagist.org', - ]; - $advisories['vendor3/package1'][] = [ - 'advisoryId' => 'ID7', - 'packageName' => 'vendor3/package1', - 'title' => 'advisory7', - 'link' => 'https://advisory.example.com/advisory7', - 'cve' => 'CVE5', - 'affectedVersions' => '>=3,<3.4.3|>=1,<2.5.6', - 'sources' => [ - [ - 'name' => 'source2', - 'remoteId' => 'RemoteID4', - ], - ], - 'reportedAt' => '2015-05-25 13:21:00', - 'composerRepository' => 'https://packagist.org', - ]; - } - - return $advisories; - } -}