1
0
Fork 0

Move security advisory loading to repositories, allows others to provider them and reduces load on packagist.org for summary advisory reports

pull/10898/head
Jordi Boggiano 2022-06-24 16:21:01 +02:00
parent 978037fbfa
commit 8c9f82dc1e
No known key found for this signature in database
GPG Key ID: 7BBD42C429EC80BC
18 changed files with 836 additions and 799 deletions

View File

@ -1,13 +1,13 @@
parameters: parameters:
ignoreErrors: 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\\<string, array\\<Composer\\\\Advisory\\\\SecurityAdvisory\\>\\>, array\\<string, array\\<Composer\\\\Advisory\\\\PartialSecurityAdvisory\\>\\> given\\.$#"
count: 1 count: 1
path: ../src/Composer/Autoload/AutoloadGenerator.php path: ../src/Composer/Advisory/Auditor.php
- -
message: "#^Cannot call method writeError\\(\\) on Composer\\\\IO\\\\IOInterface\\|null\\.$#" message: "#^Binary operation \"\\.\" between non\\-empty\\-string and array\\|string\\|null results in an error\\.$#"
count: 2 count: 1
path: ../src/Composer/Autoload/AutoloadGenerator.php path: ../src/Composer/Autoload/AutoloadGenerator.php
- -
@ -20,11 +20,6 @@ parameters:
count: 1 count: 1
path: ../src/Composer/Autoload/AutoloadGenerator.php 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\\<string, array\\<int\\|string, array\\<string\\>\\|int\\|string\\>\\|bool\\|string\\>\\|bool\\|string\\|null given on the left side\\.$#" message: "#^Only booleans are allowed in &&, array\\<string, array\\<int\\|string, array\\<string\\>\\|int\\|string\\>\\|bool\\|string\\>\\|bool\\|string\\|null given on the left side\\.$#"
count: 1 count: 1
@ -55,21 +50,11 @@ parameters:
count: 3 count: 3
path: ../src/Composer/Autoload/AutoloadGenerator.php path: ../src/Composer/Autoload/AutoloadGenerator.php
-
message: "#^Only booleans are allowed in a ternary operator condition, array\\<int, string\\> given\\.$#"
count: 1
path: ../src/Composer/Autoload/AutoloadGenerator.php
- -
message: "#^Only booleans are allowed in a ternary operator condition, mixed given\\.$#" message: "#^Only booleans are allowed in a ternary operator condition, mixed given\\.$#"
count: 1 count: 1
path: ../src/Composer/Autoload/AutoloadGenerator.php path: ../src/Composer/Autoload/AutoloadGenerator.php
-
message: "#^Only booleans are allowed in an if condition, array\\<int, string\\>\\|null given\\.$#"
count: 1
path: ../src/Composer/Autoload/AutoloadGenerator.php
- -
message: "#^Only booleans are allowed in an if condition, mixed given\\.$#" message: "#^Only booleans are allowed in an if condition, mixed given\\.$#"
count: 1 count: 1
@ -115,11 +100,6 @@ parameters:
count: 1 count: 1
path: ../src/Composer/Autoload/AutoloadGenerator.php 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\\.$#" message: "#^Parameter \\#2 \\$subject of static method Composer\\\\Pcre\\\\Preg\\:\\:isMatch\\(\\) expects string, string\\|false given\\.$#"
count: 1 count: 1
@ -165,66 +145,6 @@ parameters:
count: 1 count: 1
path: ../src/Composer/Autoload/ClassLoader.php 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\\<int, class\\-string\\> but returns array\\<int, string\\>\\.$#"
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\\<int, class\\-string\\> 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\\>\\|string\\|Traversable\\<mixed, SplFileInfo\\> given\\.$#"
count: 1
path: ../src/Composer/Autoload/ClassMapGenerator.php
- -
message: "#^Casting to bool something that's already bool\\.$#" message: "#^Casting to bool something that's already bool\\.$#"
count: 2 count: 2
@ -717,7 +637,7 @@ parameters:
- -
message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#"
count: 3 count: 2
path: ../src/Composer/Command/RequireCommand.php path: ../src/Composer/Command/RequireCommand.php
- -
@ -735,11 +655,6 @@ parameters:
count: 1 count: 1
path: ../src/Composer/Command/RequireCommand.php 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\\.$#" message: "#^Only booleans are allowed in an if condition, string\\|null given\\.$#"
count: 2 count: 2
@ -1842,7 +1757,7 @@ parameters:
- -
message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#"
count: 2 count: 1
path: ../src/Composer/DependencyResolver/SolverProblemsException.php path: ../src/Composer/DependencyResolver/SolverProblemsException.php
- -
@ -3725,11 +3640,6 @@ parameters:
count: 2 count: 2
path: ../src/Composer/Repository/ComposerRepository.php 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\\.$#" message: "#^Only booleans are allowed in a ternary operator condition, string\\|null given\\.$#"
count: 1 count: 1
@ -4518,11 +4428,6 @@ parameters:
count: 1 count: 1
path: ../src/Composer/SelfUpdate/Versions.php 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\\.$#" message: "#^Call to function in_array\\(\\) requires parameter \\#3 to be set\\.$#"
count: 1 count: 1
@ -5598,21 +5503,6 @@ parameters:
count: 2 count: 2
path: ../tests/Composer/Test/Autoload/AutoloadGeneratorTest.php 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\\<class\\-string\\>, array\\<string, string\\> given\\.$#"
count: 3
path: ../tests/Composer/Test/Autoload/ClassMapGeneratorTest.php
-
message: "#^Parameter \\#2 \\$actual of method Composer\\\\Test\\\\Autoload\\\\ClassMapGeneratorTest\\:\\:assertEqualsNormalized\\(\\) expects array\\<class\\-string\\>, array\\<class\\-string, string\\> 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\\.$#" message: "#^Short ternary operator is not allowed\\. Use null coalesce operator if applicable or consider using long ternary\\.$#"
count: 1 count: 1

View File

@ -0,0 +1,174 @@
<?php declare(strict_types=1);
namespace Composer\Advisory;
use Composer\IO\ConsoleIO;
use Composer\IO\IOInterface;
use Composer\Package\PackageInterface;
use Composer\Repository\RepositorySet;
use InvalidArgumentException;
use Symfony\Component\Console\Formatter\OutputFormatter;
/**
* @internal
*/
class Auditor
{
public const FORMAT_TABLE = 'table';
public const FORMAT_PLAIN = 'plain';
public const FORMAT_SUMMARY = 'summary';
public const FORMATS = [
self::FORMAT_TABLE,
self::FORMAT_PLAIN,
self::FORMAT_SUMMARY,
];
/**
* @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 packages with vulnerabilities found
* @throws InvalidArgumentException If no packages are passed in
*/
public function audit(IOInterface $io, RepositorySet $repoSet, array $packages, string $format, bool $warningOnly = true): int
{
$advisories = $repoSet->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}</$errorOrWarn>");
$this->outputAdvisories($io, $advisories, $format);
return $affectedPackages;
}
$io->writeError('<info>No security vulnerability advisories found</info>');
return 0;
}
/**
* @param array<string, array<PartialSecurityAdvisory>> $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<string, array<SecurityAdvisory>> $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<string, array<SecurityAdvisory>> $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<string, array<SecurityAdvisory>> $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 '<href=https://cve.mitre.org/cgi-bin/cvename.cgi?name='.$advisory->cve.'>'.$advisory->cve.'</>';
}
private function getURL(SecurityAdvisory $advisory): string
{
if ($advisory->link === null) {
return '';
}
return '<href='.OutputFormatter::escape($advisory->link).'>'.OutputFormatter::escape($advisory->link).'</>';
}
}

View File

@ -0,0 +1,48 @@
<?php declare(strict_types=1);
namespace Composer\Advisory;
use Composer\Semver\Constraint\ConstraintInterface;
use Composer\Semver\VersionParser;
class PartialSecurityAdvisory
{
/**
* @var string
* @readonly
*/
public $advisoryId;
/**
* @var string
* @readonly
*/
public $packageName;
/**
* @var ConstraintInterface
* @readonly
*/
public $affectedVersions;
/**
* @param array<mixed> $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;
}
}

View File

@ -0,0 +1,54 @@
<?php declare(strict_types=1);
namespace Composer\Advisory;
use Composer\Semver\Constraint\ConstraintInterface;
use DateTimeImmutable;
class SecurityAdvisory extends PartialSecurityAdvisory
{
/**
* @var string
* @readonly
*/
public $title;
/**
* @var string|null
* @readonly
*/
public $cve;
/**
* @var string|null
* @readonly
*/
public $link;
/**
* @var DateTimeImmutable
* @readonly
*/
public $reportedAt;
/**
* @var array<array{name: string, remoteId: string}>
* @readonly
*/
public $sources;
/**
* @param non-empty-array<array{name: string, remoteId: string}> $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;
}
}

View File

@ -1,14 +1,15 @@
<?php <?php declare(strict_types=1);
namespace Composer\Command; namespace Composer\Command;
use Composer\Composer; use Composer\Composer;
use Composer\Repository\RepositorySet;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Composer\Package\PackageInterface; use Composer\Package\PackageInterface;
use Composer\Repository\InstalledRepository; use Composer\Repository\InstalledRepository;
use Composer\Repository\RepositoryInterface; use Composer\Repository\RepositoryInterface;
use Composer\Util\Auditor; use Composer\Advisory\Auditor;
use Composer\Console\Input\InputOption; use Composer\Console\Input\InputOption;
class AuditCommand extends BaseCommand class AuditCommand extends BaseCommand
@ -39,16 +40,19 @@ EOT
{ {
$composer = $this->requireComposer(); $composer = $this->requireComposer();
$packages = $this->getPackages($composer, $input); $packages = $this->getPackages($composer, $input);
$httpDownloader = $composer->getLoop()->getHttpDownloader();
if (count($packages) === 0) { if (count($packages) === 0) {
$this->getIO()->writeError('No packages - skipping audit.'); $this->getIO()->writeError('No packages - skipping audit.');
return 0; 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));
} }
/** /**

View File

@ -25,7 +25,7 @@ use Composer\IO\NullIO;
use Composer\Plugin\PreCommandRunEvent; use Composer\Plugin\PreCommandRunEvent;
use Composer\Package\Version\VersionParser; use Composer\Package\Version\VersionParser;
use Composer\Plugin\PluginEvents; use Composer\Plugin\PluginEvents;
use Composer\Util\Auditor; use Composer\Advisory\Auditor;
use Composer\Util\Platform; use Composer\Util\Platform;
use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Completion\CompletionSuggestions;

View File

@ -44,7 +44,7 @@ use Composer\Util\Filesystem;
use Composer\Util\Platform; use Composer\Util\Platform;
use Composer\Util\ProcessExecutor; use Composer\Util\ProcessExecutor;
use Composer\Package\Version\VersionParser; use Composer\Package\Version\VersionParser;
use Composer\Util\Auditor; use Composer\Advisory\Auditor;
/** /**
* Install a package as new project into new directory. * Install a package as new project into new directory.

View File

@ -15,7 +15,7 @@ namespace Composer\Command;
use Composer\Installer; use Composer\Installer;
use Composer\Plugin\CommandEvent; use Composer\Plugin\CommandEvent;
use Composer\Plugin\PluginEvents; use Composer\Plugin\PluginEvents;
use Composer\Util\Auditor; use Composer\Advisory\Auditor;
use Composer\Util\HttpDownloader; use Composer\Util\HttpDownloader;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Composer\Console\Input\InputOption; use Composer\Console\Input\InputOption;

View File

@ -25,7 +25,7 @@ use Composer\Console\Input\InputOption;
use Composer\Console\Input\InputArgument; use Composer\Console\Input\InputArgument;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Composer\Package\BasePackage; use Composer\Package\BasePackage;
use Composer\Util\Auditor; use Composer\Advisory\Auditor;
/** /**
* @author Pierre du Plessis <pdples@gmail.com> * @author Pierre du Plessis <pdples@gmail.com>

View File

@ -31,7 +31,7 @@ use Composer\Plugin\PluginEvents;
use Composer\Repository\CompositeRepository; use Composer\Repository\CompositeRepository;
use Composer\Repository\PlatformRepository; use Composer\Repository\PlatformRepository;
use Composer\IO\IOInterface; use Composer\IO\IOInterface;
use Composer\Util\Auditor; use Composer\Advisory\Auditor;
use Composer\Util\Silencer; use Composer\Util\Silencer;
/** /**

View File

@ -25,7 +25,7 @@ use Composer\Semver\Constraint\ConstraintInterface;
use Composer\Util\HttpDownloader; use Composer\Util\HttpDownloader;
use Composer\Semver\Constraint\MultiConstraint; use Composer\Semver\Constraint\MultiConstraint;
use Composer\Package\Link; use Composer\Package\Link;
use Composer\Util\Auditor; use Composer\Advisory\Auditor;
use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Composer\Console\Input\InputOption; use Composer\Console\Input\InputOption;

View File

@ -62,7 +62,7 @@ use Composer\Repository\RepositoryManager;
use Composer\Repository\LockArrayRepository; use Composer\Repository\LockArrayRepository;
use Composer\Script\ScriptEvents; use Composer\Script\ScriptEvents;
use Composer\Semver\Constraint\ConstraintInterface; use Composer\Semver\Constraint\ConstraintInterface;
use Composer\Util\Auditor; use Composer\Advisory\Auditor;
use Composer\Util\Platform; use Composer\Util\Platform;
/** /**
@ -397,8 +397,12 @@ class Installer
} }
if (count($packages) > 0) { if (count($packages) > 0) {
try { try {
$auditor = new Auditor(Factory::createHttpDownloader($this->io, $this->config)); $auditor = new Auditor();
$auditor->audit($this->io, $packages, $this->auditFormat); $repoSet = new RepositorySet();
foreach ($this->repositoryManager->getRepositories() as $repo) {
$repoSet->addRepository($repo);
}
$auditor->audit($this->io, $repoSet, $packages, $this->auditFormat);
} catch (TransportException $e) { } catch (TransportException $e) {
$this->io->error('Failed to audit '.$target.' packages.'); $this->io->error('Failed to audit '.$target.' packages.');
if ($this->io->isVerbose()) { if ($this->io->isVerbose()) {

View File

@ -0,0 +1,34 @@
<?php declare(strict_types=1);
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* 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 <j.boggiano@seld.be>
* @internal
*/
interface AdvisoryProviderInterface
{
public function hasSecurityAdvisories(): bool;
/**
* @param array<string, ConstraintInterface> $packageConstraintMap Map of package name to constraint (can be MatchAllConstraint to fetch all advisories)
* @return ($allowPartialAdvisories is true ? array{namesFound: string[], advisories: array<string, array<PartialSecurityAdvisory|SecurityAdvisory>>} : array{namesFound: string[], advisories: array<string, array<SecurityAdvisory>>})
*/
public function getSecurityAdvisories(array $packageConstraintMap, bool $allowPartialAdvisories = false): array;
}

View File

@ -12,6 +12,8 @@
namespace Composer\Repository; namespace Composer\Repository;
use Composer\Advisory\PartialSecurityAdvisory;
use Composer\Advisory\SecurityAdvisory;
use Composer\Package\BasePackage; use Composer\Package\BasePackage;
use Composer\Package\Loader\ArrayLoader; use Composer\Package\Loader\ArrayLoader;
use Composer\Package\PackageInterface; use Composer\Package\PackageInterface;
@ -40,11 +42,12 @@ use Composer\Util\Http\Response;
use Composer\MetadataMinifier\MetadataMinifier; use Composer\MetadataMinifier\MetadataMinifier;
use Composer\Util\Url; use Composer\Util\Url;
use React\Promise\PromiseInterface; use React\Promise\PromiseInterface;
use function React\Promise\resolve;
/** /**
* @author Jordi Boggiano <j.boggiano@seld.be> * @author Jordi Boggiano <j.boggiano@seld.be>
*/ */
class ComposerRepository extends ArrayRepository implements ConfigurableRepositoryInterface class ComposerRepository extends ArrayRepository implements ConfigurableRepositoryInterface, AdvisoryProviderInterface
{ {
/** /**
* @var mixed[] * @var mixed[]
@ -107,6 +110,8 @@ class ComposerRepository extends ArrayRepository implements ConfigurableReposito
private $partialPackagesByName = null; private $partialPackagesByName = null;
/** @var bool */ /** @var bool */
private $displayedWarningAboutNonMatchingPackageIndex = false; 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 * @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 // this call initializes loadRootServerFile which is needed for the rest below to work
$hasProviders = $this->hasProviders(); $hasProviders = $this->hasProviders();
if (!$hasProviders && !$this->hasPartialPackages() && !$this->lazyProvidersUrl) { if (!$hasProviders && !$this->hasPartialPackages() && null === $this->lazyProvidersUrl) {
return parent::loadPackages($packageNameMap, $acceptableStabilities, $stabilityFlags, $alreadyLoaded); return parent::loadPackages($packageNameMap, $acceptableStabilities, $stabilityFlags, $alreadyLoaded);
} }
@ -604,6 +609,102 @@ class ComposerRepository extends ArrayRepository implements ConfigurableReposito
return parent::search($query, $mode); 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<mixed> $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) public function getProviders(string $packageName)
{ {
$this->loadRootServerFile(); $this->loadRootServerFile();
@ -863,7 +964,8 @@ class ComposerRepository extends ArrayRepository implements ConfigurableReposito
} }
/** /**
* @param array<string, ConstraintInterface|null> $packageNames array of package name => ConstraintInterface|null - if a constraint is provided, only packages matching it will be loaded * @param array<string, ConstraintInterface|null> $packageNames array of package name => ConstraintInterface|null - if a constraint is provided, only
* packages matching it will be loaded
* @param array<string, int>|null $acceptableStabilities * @param array<string, int>|null $acceptableStabilities
* @phpstan-param array<string, BasePackage::STABILITY_*>|null $acceptableStabilities * @phpstan-param array<string, BasePackage::STABILITY_*>|null $acceptableStabilities
* @param array<string, int>|null $stabilityFlags an array of package name => BasePackage::STABILITY_* value * @param array<string, int>|null $stabilityFlags an array of package name => BasePackage::STABILITY_* value
@ -880,7 +982,7 @@ class ComposerRepository extends ArrayRepository implements ConfigurableReposito
$namesFound = array(); $namesFound = array();
$promises = array(); $promises = array();
if (!$this->lazyProvidersUrl) { if (null === $this->lazyProvidersUrl) {
throw new \LogicException('loadAsyncPackages only supports v2 protocol composer repos with a metadata-url'); 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; continue;
} }
$url = str_replace('%package%', $name, $this->lazyProvidersUrl); $promises[] = $this->startCachedAsyncDownload($name, $realName)
$cacheKey = 'provider-'.strtr($name, '/', '~').'.json'; ->then(function (array $spec) use (&$packages, &$namesFound, $realName, $constraint, $acceptableStabilities, $stabilityFlags, $alreadyLoaded): void {
list($response, $packagesSource) = $spec;
$lastModified = null; if (null === $response) {
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])) {
return; return;
} }
@ -968,7 +1055,41 @@ class ComposerRepository extends ArrayRepository implements ConfigurableReposito
$this->loop->wait($promises); $this->loop->wait($promises);
return array('namesFound' => $namesFound, 'packages' => $packages); 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 // 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 // as well but we are not interested in the old format anymore at this point
unset($data['providers-url'], $data['providers'], $data['providers-includes']); 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) { if ($this->allowSslDowngrade) {

View File

@ -17,14 +17,19 @@ use Composer\DependencyResolver\Pool;
use Composer\DependencyResolver\PoolBuilder; use Composer\DependencyResolver\PoolBuilder;
use Composer\DependencyResolver\Request; use Composer\DependencyResolver\Request;
use Composer\EventDispatcher\EventDispatcher; use Composer\EventDispatcher\EventDispatcher;
use Composer\Advisory\SecurityAdvisory;
use Composer\Advisory\PartialSecurityAdvisory;
use Composer\IO\IOInterface; use Composer\IO\IOInterface;
use Composer\IO\NullIO; use Composer\IO\NullIO;
use Composer\Package\BasePackage; use Composer\Package\BasePackage;
use Composer\Package\AliasPackage; use Composer\Package\AliasPackage;
use Composer\Package\CompleteAliasPackage; use Composer\Package\CompleteAliasPackage;
use Composer\Package\CompletePackage; use Composer\Package\CompletePackage;
use Composer\Package\PackageInterface;
use Composer\Semver\Constraint\Constraint;
use Composer\Semver\Constraint\ConstraintInterface; use Composer\Semver\Constraint\ConstraintInterface;
use Composer\Package\Version\StabilityFilter; use Composer\Package\Version\StabilityFilter;
use Composer\Semver\Constraint\MatchAllConstraint;
/** /**
* @author Nils Adermann <naderman@naderman.de> * @author Nils Adermann <naderman@naderman.de>
@ -226,6 +231,57 @@ class RepositorySet
return $result; return $result;
} }
/**
* @param string[] $packageNames
* @return ($allowPartialAdvisories is true ? array<string, array<PartialSecurityAdvisory|SecurityAdvisory>> : array<string, array<SecurityAdvisory>>)
*/
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<string, array<PartialSecurityAdvisory|SecurityAdvisory>> : array<string, array<SecurityAdvisory>>)
*/
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<string, ConstraintInterface> $packageConstraintMap
* @return ($allowPartialAdvisories is true ? array<string, array<PartialSecurityAdvisory|SecurityAdvisory>> : array<string, array<SecurityAdvisory>>)
*/
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 * @param string $packageName
* *

View File

@ -1,250 +0,0 @@
<?php
namespace Composer\Util;
use Composer\IO\ConsoleIO;
use Composer\IO\IOInterface;
use Composer\Package\PackageInterface;
use Composer\Semver\Semver;
use InvalidArgumentException;
/**
* @internal
*/
class Auditor
{
private const API_URL = 'https://packagist.org/api/security-advisories/';
public const FORMAT_TABLE = 'table';
public const FORMAT_PLAIN = 'plain';
public const FORMAT_SUMMARY = 'summary';
public const FORMATS = [
self::FORMAT_TABLE,
self::FORMAT_PLAIN,
self::FORMAT_SUMMARY,
];
/** @var HttpDownloader */
private $httpDownloader;
/**
* @param HttpDownloader $httpDownloader
*/
public function __construct(HttpDownloader $httpDownloader)
{
$this->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}</$errorOrWarn>");
$this->outputAdvisories($io, $advisories, $format);
return count($advisories);
}
$io->writeError('<info>No security vulnerability advisories found</info>');
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<string, array<string, array<int, string>|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);
}
}

View File

@ -0,0 +1,289 @@
<?php declare(strict_types=1);
namespace Composer\Test\Advisory;
use Composer\Advisory\PartialSecurityAdvisory;
use Composer\Advisory\SecurityAdvisory;
use Composer\IO\IOInterface;
use Composer\IO\NullIO;
use Composer\Json\JsonFile;
use Composer\Package\Package;
use Composer\Package\Version\VersionParser;
use Composer\Repository\ComposerRepository;
use Composer\Repository\RepositorySet;
use Composer\Test\TestCase;
use Composer\Advisory\Auditor;
use Composer\Util\Http\Response;
use Composer\Util\HttpDownloader;
use InvalidArgumentException;
use PHPUnit\Framework\MockObject\MockObject;
class AuditorTest extends TestCase
{
public function auditProvider()
{
return [
// Test no advisories returns 0
[
'data' => [
'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<string, mixed> $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<mixed> $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<mixed>
*/
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;
}
}

View File

@ -1,395 +0,0 @@
<?php
namespace Composer\Test\Util;
use Composer\IO\IOInterface;
use Composer\IO\NullIO;
use Composer\Json\JsonFile;
use Composer\Package\Package;
use Composer\Test\TestCase;
use Composer\Util\Auditor;
use Composer\Util\Http\Response;
use Composer\Util\HttpDownloader;
use InvalidArgumentException;
use PHPUnit\Framework\MockObject\MockObject;
class AuditorTest extends TestCase
{
public function auditProvider()
{
return [
// Test no advisories returns 0
[
'data' => [
'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<string, mixed> $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<string, mixed> $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<mixed>
*/
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;
}
}