1
0
Fork 0

Add audit command to check for security issues (#10798)

Closes #10329
pull/10893/head
Guy Sartorelli 2022-06-22 15:14:00 +02:00 committed by Jordi Boggiano
parent d17c724f23
commit d93239ddd9
No known key found for this signature in database
GPG Key ID: 7BBD42C429EC80BC
15 changed files with 873 additions and 5 deletions

View File

@ -109,6 +109,8 @@ resolution.
* **--no-autoloader:** Skips autoloader generation.
* **--no-progress:** Removes the progress display that can mess with some
terminals or scripts which don't handle backspace characters.
* **--no-audit:** Does not run the audit step after installation is complete.
* **--audit-format:** Audit output format. Must be "table", "plain", or "summary" (default).
* **--optimize-autoloader (-o):** Convert PSR-0/4 autoloading to classmap to get a faster
autoloader. This is recommended especially for production, but can take
a bit of time to run so it is currently not done by default.
@ -188,6 +190,8 @@ and this feature is only available for your root package dependencies.
* **--dev:** Install packages listed in `require-dev` (this is the default behavior).
* **--no-dev:** Skip installing packages listed in `require-dev`. The autoloader generation skips the `autoload-dev` rules.
* **--no-install:** Does not run the install step after updating the composer.lock file.
* **--no-audit:** Does not run the audit steps after updating the composer.lock file.
* **--audit-format:** Audit output format. Must be "table", "plain", or "summary" (default).
* **--lock:** Only updates the lock file hash to suppress warning about the
lock file being out of date.
* **--with:** Temporary version constraint to add, e.g. foo/bar:1.0.0 or foo/bar=1.0.0
@ -259,6 +263,8 @@ If you do not specify a package, Composer will prompt you to search for a packag
terminals or scripts which don't handle backspace characters.
* **--no-update:** Disables the automatic update of the dependencies (implies --no-install).
* **--no-install:** Does not run the install step after updating the composer.lock file.
* **--no-audit:** Does not run the audit steps after updating the composer.lock file.
* **--audit-format:** Audit output format. Must be "table", "plain", or "summary" (default).
* **--update-no-dev:** Run the dependency update with the `--no-dev` option.
* **--update-with-dependencies (-w):** Also update dependencies of the newly required packages, except those that are root requirements.
* **--update-with-all-dependencies (-W):** Also update dependencies of the newly required packages, including those that are root requirements.
@ -301,6 +307,8 @@ uninstalled.
terminals or scripts which don't handle backspace characters.
* **--no-update:** Disables the automatic update of the dependencies (implies --no-install).
* **--no-install:** Does not run the install step after updating the composer.lock file.
* **--no-audit:** Does not run the audit steps after installation is complete.
* **--audit-format:** Audit output format. Must be "table", "plain", or "summary" (default).
* **--update-no-dev:** Run the dependency update with the --no-dev option.
* **--update-with-dependencies (-w):** Also update dependencies of the removed packages.
(Deprecated, is now default behavior)
@ -880,6 +888,8 @@ By default the command checks for the packages on packagist.org.
mode.
* **--remove-vcs:** Force-remove the VCS metadata without prompting.
* **--no-install:** Disables installation of the vendors.
* **--no-audit:** Does not run the audit steps after installation is complete.
* **--audit-format:** Audit output format. Must be "table", "plain", or "summary" (default).
* **--ignore-platform-reqs:** ignore all platform requirements (`php`, `hhvm`,
`lib-*` and `ext-*`) and force the installation even if the local machine does
not fulfill these.
@ -991,6 +1001,23 @@ php composer.phar archive vendor/package 2.0.21 --format=zip
* **--dir:** Write the archive to this directory (default: ".")
* **--file:** Write the archive with the given file name.
## audit
This command is used to audit the packages you have installed
for possible security issues. Currently this only checks for and
lists security vulnerability advisories according to the
[Packagist.org api](https://packagist.org/apidoc#list-security-advisories).
```sh
php composer.phar audit
```
### Options
* **--no-dev:** Disables auditing of require-dev packages.
* **--format (-f):** Audit output format. Must be "table" (default), "plain", or "summary".
* **--locked:** Audit packages from the lock file, regardless of what is currently in vendor dir.
## help
To get more information about a certain command, you can use `help`.

View File

@ -4518,6 +4518,11 @@ 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

View File

@ -0,0 +1,102 @@
<?php
namespace Composer\Command;
use Composer\Composer;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Composer\Package\PackageInterface;
use Composer\Repository\InstalledRepository;
use Composer\Repository\RepositoryInterface;
use Composer\Util\Auditor;
use Symfony\Component\Console\Input\InputOption;
class AuditCommand extends BaseCommand
{
protected function configure(): void
{
$this
->setName('audit')
->setDescription('Checks for security vulnerability advisories for installed packages.')
->setDefinition(array(
new InputOption('no-dev', null, InputOption::VALUE_NONE, 'Disables auditing of require-dev packages.'),
new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'Output format. Must be "table", "plain", or "summary".', Auditor::FORMAT_TABLE, Auditor::FORMATS),
new InputOption('locked', null, InputOption::VALUE_NONE, 'Audit based on the lock file instead of the installed packages.'),
))
->setHelp(
<<<EOT
The <info>audit</info> command checks for security vulnerability advisories for installed packages.
If you do not want to include dev dependencies in the audit you can omit them with --no-dev
Read more at https://getcomposer.org/doc/03-cli.md#audit
EOT
)
;
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$composer = $this->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);
return $auditor->audit($this->getIO(), $packages, $input->getOption('format'), false);
}
/**
* @param InputInterface $input
* @return PackageInterface[]
*/
private function getPackages(Composer $composer, InputInterface $input): array
{
if ($input->getOption('locked')) {
if (!$composer->getLocker()->isLocked()) {
throw new \UnexpectedValueException('Valid composer.json and composer.lock files are required to run this command with --locked');
}
$locker = $composer->getLocker();
return $locker->getLockedRepository(!$input->getOption('no-dev'))->getPackages();
}
$rootPkg = $composer->getPackage();
$installedRepo = new InstalledRepository(array($composer->getRepositoryManager()->getLocalRepository()));
if ($input->getOption('no-dev')) {
return $this->filterRequiredPackages($installedRepo, $rootPkg);
}
return $installedRepo->getPackages();
}
/**
* Find package requires and child requires.
* Effectively filters out dev dependencies.
*
* @param PackageInterface[] $bucket
* @return PackageInterface[]
*/
private function filterRequiredPackages(RepositoryInterface $repo, PackageInterface $package, array $bucket = array()): array
{
$requires = $package->getRequires();
foreach ($repo->getPackages() as $candidate) {
foreach ($candidate->getNames() as $name) {
if (isset($requires[$name])) {
if (!in_array($candidate, $bucket, true)) {
$bucket[] = $candidate;
$bucket = $this->filterRequiredPackages($repo, $candidate, $bucket);
}
break;
}
}
}
return $bucket;
}
}

View File

@ -44,6 +44,7 @@ use Composer\Util\Filesystem;
use Composer\Util\Platform;
use Composer\Util\ProcessExecutor;
use Composer\Package\Version\VersionParser;
use Composer\Util\Auditor;
/**
* Install a package as new project into new directory.
@ -90,6 +91,8 @@ class CreateProjectCommand extends BaseCommand
new InputOption('keep-vcs', null, InputOption::VALUE_NONE, 'Whether to prevent deleting the vcs folder.'),
new InputOption('remove-vcs', null, InputOption::VALUE_NONE, 'Whether to force deletion of the vcs folder without prompting.'),
new InputOption('no-install', null, InputOption::VALUE_NONE, 'Whether to skip installation of the package dependencies.'),
new InputOption('no-audit', null, InputOption::VALUE_NONE, 'Whether to skip auditing of the installed package dependencies.'),
new InputOption('audit-format', null, InputOption::VALUE_REQUIRED, 'Audit output format. Must be "table", "plain", or "summary".', Auditor::FORMAT_SUMMARY, Auditor::FORMATS),
new InputOption('ignore-platform-req', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Ignore a specific platform requirement (php & ext- packages).'),
new InputOption('ignore-platform-reqs', null, InputOption::VALUE_NONE, 'Ignore all platform requirements (php & ext- packages).'),
new InputOption('ask', null, InputOption::VALUE_NONE, 'Whether to ask for project directory.'),
@ -259,7 +262,9 @@ EOT
->setSuggestedPackagesReporter($this->suggestedPackagesReporter)
->setOptimizeAutoloader($config->get('optimize-autoloader'))
->setClassMapAuthoritative($config->get('classmap-authoritative'))
->setApcuAutoloader($config->get('apcu-autoloader'));
->setApcuAutoloader($config->get('apcu-autoloader'))
->setAudit(!$input->getOption('no-audit'))
->setAuditFormat($input->getOption('audit-format'));
if (!$composer->getLocker()->isLocked()) {
$installer->setUpdate(true);

View File

@ -15,6 +15,7 @@ namespace Composer\Command;
use Composer\Installer;
use Composer\Plugin\CommandEvent;
use Composer\Plugin\PluginEvents;
use Composer\Util\Auditor;
use Composer\Util\HttpDownloader;
use Symfony\Component\Console\Input\InputInterface;
use Composer\Console\Input\InputOption;
@ -51,6 +52,8 @@ class InstallCommand extends BaseCommand
new InputOption('no-autoloader', null, InputOption::VALUE_NONE, 'Skips autoloader generation'),
new InputOption('no-progress', null, InputOption::VALUE_NONE, 'Do not output download progress.'),
new InputOption('no-install', null, InputOption::VALUE_NONE, 'Do not use, only defined here to catch misuse of the install command.'),
new InputOption('no-audit', null, InputOption::VALUE_NONE, 'Skip the audit step after installation is complete.'),
new InputOption('audit-format', null, InputOption::VALUE_REQUIRED, 'Audit output format. Must be "table", "plain", or "summary".', Auditor::FORMAT_SUMMARY, Auditor::FORMATS),
new InputOption('verbose', 'v|vv|vvv', InputOption::VALUE_NONE, 'Shows more details including new commits pulled in when updating packages.'),
new InputOption('optimize-autoloader', 'o', InputOption::VALUE_NONE, 'Optimize autoloader during autoloader dump'),
new InputOption('classmap-authoritative', 'a', InputOption::VALUE_NONE, 'Autoload classes from the classmap only. Implicitly enables `--optimize-autoloader`.'),
@ -130,6 +133,8 @@ EOT
->setClassMapAuthoritative($authoritative)
->setApcuAutoloader($apcu, $apcuPrefix)
->setPlatformRequirementFilter($this->getPlatformRequirementFilter($input))
->setAudit(!$input->getOption('no-audit'))
->setAuditFormat($input->getOption('audit-format'))
;
if ($input->getOption('no-plugins')) {

View File

@ -25,6 +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;
/**
* @author Pierre du Plessis <pdples@gmail.com>
@ -49,6 +50,8 @@ class RemoveCommand extends BaseCommand
new InputOption('no-progress', null, InputOption::VALUE_NONE, 'Do not output download progress.'),
new InputOption('no-update', null, InputOption::VALUE_NONE, 'Disables the automatic update of the dependencies (implies --no-install).'),
new InputOption('no-install', null, InputOption::VALUE_NONE, 'Skip the install step after updating the composer.lock file.'),
new InputOption('no-audit', null, InputOption::VALUE_NONE, 'Skip the audit step after updating the composer.lock file.'),
new InputOption('audit-format', null, InputOption::VALUE_REQUIRED, 'Audit output format. Must be "table", "plain", or "summary".', Auditor::FORMAT_SUMMARY, Auditor::FORMATS),
new InputOption('update-no-dev', null, InputOption::VALUE_NONE, 'Run the dependency update with the --no-dev option.'),
new InputOption('update-with-dependencies', 'w', InputOption::VALUE_NONE, 'Allows inherited dependencies to be updated with explicit dependencies. (Deprecrated, is now default behavior)'),
new InputOption('update-with-all-dependencies', 'W', InputOption::VALUE_NONE, 'Allows all inherited dependencies to be updated, including those that are root requirements.'),
@ -282,6 +285,8 @@ EOT
->setUpdateAllowTransitiveDependencies($updateAllowTransitiveDependencies)
->setPlatformRequirementFilter($this->getPlatformRequirementFilter($input))
->setDryRun($dryRun)
->setAudit(!$input->getOption('no-audit'))
->setAuditFormat($input->getOption('audit-format'))
;
// if no lock is present, we do not do a partial update as

View File

@ -31,6 +31,7 @@ use Composer\Plugin\PluginEvents;
use Composer\Repository\CompositeRepository;
use Composer\Repository\PlatformRepository;
use Composer\IO\IOInterface;
use Composer\Util\Auditor;
use Composer\Util\Silencer;
/**
@ -79,6 +80,8 @@ class RequireCommand extends BaseCommand
new InputOption('no-progress', null, InputOption::VALUE_NONE, 'Do not output download progress.'),
new InputOption('no-update', null, InputOption::VALUE_NONE, 'Disables the automatic update of the dependencies (implies --no-install).'),
new InputOption('no-install', null, InputOption::VALUE_NONE, 'Skip the install step after updating the composer.lock file.'),
new InputOption('no-audit', null, InputOption::VALUE_NONE, 'Skip the audit step after updating the composer.lock file.'),
new InputOption('audit-format', null, InputOption::VALUE_REQUIRED, 'Audit output format. Must be "table", "plain", or "summary".', Auditor::FORMAT_SUMMARY, Auditor::FORMATS),
new InputOption('update-no-dev', null, InputOption::VALUE_NONE, 'Run the dependency update with the --no-dev option.'),
new InputOption('update-with-dependencies', 'w', InputOption::VALUE_NONE, 'Allows inherited dependencies to be updated, except those that are root requirements.'),
new InputOption('update-with-all-dependencies', 'W', InputOption::VALUE_NONE, 'Allows all inherited dependencies to be updated, including those that are root requirements.'),
@ -415,6 +418,8 @@ EOT
->setPlatformRequirementFilter($this->getPlatformRequirementFilter($input))
->setPreferStable($input->getOption('prefer-stable'))
->setPreferLowest($input->getOption('prefer-lowest'))
->setAudit(!$input->getOption('no-audit'))
->setAuditFormat($input->getOption('audit-format'))
;
// if no lock is present, or the file is brand new, we do not do a

View File

@ -25,6 +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 Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Composer\Console\Input\InputOption;
@ -60,6 +61,8 @@ class UpdateCommand extends BaseCommand
new InputOption('no-dev', null, InputOption::VALUE_NONE, 'Disables installation of require-dev packages.'),
new InputOption('lock', null, InputOption::VALUE_NONE, 'Overwrites the lock file hash to suppress warning about the lock file being out of date without updating package versions. Package metadata like mirrors and URLs are updated if they changed.'),
new InputOption('no-install', null, InputOption::VALUE_NONE, 'Skip the install step after updating the composer.lock file.'),
new InputOption('no-audit', null, InputOption::VALUE_NONE, 'Skip the audit step after updating the composer.lock file.'),
new InputOption('audit-format', null, InputOption::VALUE_REQUIRED, 'Audit output format. Must be "table", "plain", or "summary".', Auditor::FORMAT_SUMMARY, Auditor::FORMATS),
new InputOption('no-autoloader', null, InputOption::VALUE_NONE, 'Skips autoloader generation'),
new InputOption('no-suggest', null, InputOption::VALUE_NONE, 'DEPRECATED: This flag does not exist anymore.'),
new InputOption('no-progress', null, InputOption::VALUE_NONE, 'Do not output download progress.'),
@ -229,6 +232,8 @@ EOT
->setPreferStable($input->getOption('prefer-stable'))
->setPreferLowest($input->getOption('prefer-lowest'))
->setTemporaryConstraints($temporaryConstraints)
->setAudit(!$input->getOption('no-audit'))
->setAuditFormat($input->getOption('audit-format'))
;
if ($input->getOption('no-plugins')) {

View File

@ -529,6 +529,7 @@ class Application extends BaseApplication
new Command\UpdateCommand(),
new Command\SearchCommand(),
new Command\ValidateCommand(),
new Command\AuditCommand(),
new Command\ShowCommand(),
new Command\SuggestsCommand(),
new Command\RequireCommand(),

View File

@ -15,6 +15,7 @@ namespace Composer\IO;
use Composer\Question\StrictConfirmationQuestion;
use Symfony\Component\Console\Helper\HelperSet;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@ -342,6 +343,14 @@ class ConsoleIO extends BaseIO
return $results;
}
/**
* @return Table
*/
public function getTable(): Table
{
return new Table($this->output);
}
/**
* @return OutputInterface
*/

View File

@ -27,6 +27,7 @@ use Composer\DependencyResolver\Solver;
use Composer\DependencyResolver\SolverProblemsException;
use Composer\DependencyResolver\PolicyInterface;
use Composer\Downloader\DownloadManager;
use Composer\Downloader\TransportException;
use Composer\EventDispatcher\EventDispatcher;
use Composer\Filter\PlatformRequirementFilter\IgnoreListPlatformRequirementFilter;
use Composer\Filter\PlatformRequirementFilter\PlatformRequirementFilterFactory;
@ -61,6 +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\Util\Platform;
/**
@ -163,6 +165,10 @@ class Installer
protected $writeLock;
/** @var bool */
protected $executeOperations = true;
/** @var bool */
protected $audit = true;
/** @var string */
protected $auditFormat = Auditor::FORMAT_TABLE;
/** @var bool */
protected $updateMirrors = false;
@ -381,6 +387,23 @@ class Installer
gc_enable();
}
if ($this->audit) {
$packages = $localRepo->getCanonicalPackages();
if (count($packages) > 0) {
try {
$auditor = new Auditor(Factory::createHttpDownloader($this->io, $this->config));
$auditor->audit($this->io, $packages, $this->auditFormat);
} catch (TransportException $e) {
$this->io->error('Failed to audit installed packages.');
if ($this->io->isVerbose()) {
$this->io->error($e->getMessage());
}
}
} else {
$this->io->writeError('No packages - skipping audit.');
}
}
return 0;
}
@ -1071,10 +1094,13 @@ class Installer
/**
* @param array<string, ConstraintInterface> $constraints
* @return Installer
*/
public function setTemporaryConstraints(array $constraints): void
public function setTemporaryConstraints(array $constraints): self
{
$this->temporaryConstraints = $constraints;
return $this;
}
/**
@ -1418,6 +1444,32 @@ class Installer
return $this;
}
/**
* Should an audit be run after installation is complete?
*
* @param boolean $audit
* @return Installer
*/
public function setAudit(bool $audit): self
{
$this->audit = $audit;
return $this;
}
/**
* What format should be used for audit output?
*
* @param string $auditFormat
* @return Installer
*/
public function setAuditFormat(string $auditFormat): self
{
$this->auditFormat = $auditFormat;
return $this;
}
/**
* Disables plugins.
*

View File

@ -0,0 +1,245 @@
<?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
* @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';
$io->writeError("<$errorOrWarn>Found $numAdvisories security vulnerability advisor$plurality:</$errorOrWarn>");
$this->outputAdvisories($io, $advisories, $format);
return 1;
}
$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.');
default:
throw new InvalidArgumentException('Invalid 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

@ -26,7 +26,7 @@ class UpdateCommandTest extends TestCase
$this->initTempComposer($composerJson);
$appTester = $this->getApplicationTester();
$appTester->run(array_merge(['command' => 'update', '--dry-run' => true], $command));
$appTester->run(array_merge(['command' => 'update', '--dry-run' => true, '--no-audit' => true], $command));
$this->assertSame(trim($expected), trim($appTester->getDisplay(true)));
}

View File

@ -136,6 +136,7 @@ class InstallerTest extends TestCase
$autoloadGenerator = $this->getMockBuilder('Composer\Autoload\AutoloadGenerator')->disableOriginalConstructor()->getMock();
$installer = new Installer($io, $config, clone $rootPackage, $downloadManager, $repositoryManager, $locker, $installationManager, $eventDispatcher, $autoloadGenerator);
$installer->setAudit(false);
$result = $installer->run();
$output = str_replace("\r", '', $io->getOutput());
@ -395,7 +396,8 @@ class InstallerTest extends TestCase
$installer
->setDevMode(!$input->getOption('no-dev'))
->setDryRun($input->getOption('dry-run'))
->setPlatformRequirementFilter(PlatformRequirementFilterFactory::fromBoolOrList($ignorePlatformReqs));
->setPlatformRequirementFilter(PlatformRequirementFilterFactory::fromBoolOrList($ignorePlatformReqs))
->setAudit(false);
return $installer->run();
});
@ -440,7 +442,8 @@ class InstallerTest extends TestCase
->setUpdateAllowTransitiveDependencies($updateAllowTransitiveDependencies)
->setPreferStable($input->getOption('prefer-stable'))
->setPreferLowest($input->getOption('prefer-lowest'))
->setPlatformRequirementFilter(PlatformRequirementFilterFactory::fromBoolOrList($ignorePlatformReqs));
->setPlatformRequirementFilter(PlatformRequirementFilterFactory::fromBoolOrList($ignorePlatformReqs))
->setAudit(false);
return $installer->run();
});

View File

@ -0,0 +1,399 @@
<?php
namespace Composer\Test\Util;
use Composer\IO\IOInterface;
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
{
/** @var IOInterface&\PHPUnit\Framework\MockObject\MockObject */
private $io;
protected function setUp(): void
{
$this->io = $this
->getMockBuilder(IOInterface::class)
->disableOriginalConstructor()
->getMock();
}
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($this->io, $data['packages'], Auditor::FORMAT_PLAIN, $data['warningOnly']);
$this->assertSame($expected, $result, $message);
}
public function advisoriesProvider()
{
$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(), Auditor::FORMAT_PLAIN);
$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(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)) {
unset($advisories[$package]);
}
}
}
return new Response(['url' => 'https://packagist.org/api/security-advisories/'], 200, [], json_encode(['advisories' => $advisories]));
};
$httpDownloader
->method('get')
->willReturnCallback($callback);
return $httpDownloader;
}
public static function getMockAdvisories(?int $updatedSince)
{
$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',
],
],
];
// Intentionally allow updatedSince === 0 to include these advisories
if (!$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;
}
}