From d93239ddd9e6c18a2f7db3cdf94213dd892fa950 Mon Sep 17 00:00:00 2001 From: Guy Sartorelli Date: Wed, 22 Jun 2022 15:14:00 +0200 Subject: [PATCH] Add audit command to check for security issues (#10798) Closes #10329 --- doc/03-cli.md | 27 ++ phpstan/baseline.neon | 5 + src/Composer/Command/AuditCommand.php | 102 +++++ src/Composer/Command/CreateProjectCommand.php | 7 +- src/Composer/Command/InstallCommand.php | 5 + src/Composer/Command/RemoveCommand.php | 5 + src/Composer/Command/RequireCommand.php | 5 + src/Composer/Command/UpdateCommand.php | 5 + src/Composer/Console/Application.php | 1 + src/Composer/IO/ConsoleIO.php | 9 + src/Composer/Installer.php | 54 ++- src/Composer/Util/Auditor.php | 245 +++++++++++ .../Test/Command/UpdateCommandTest.php | 2 +- tests/Composer/Test/InstallerTest.php | 7 +- tests/Composer/Test/Util/AuditorTest.php | 399 ++++++++++++++++++ 15 files changed, 873 insertions(+), 5 deletions(-) create mode 100644 src/Composer/Command/AuditCommand.php create mode 100644 src/Composer/Util/Auditor.php create mode 100644 tests/Composer/Test/Util/AuditorTest.php diff --git a/doc/03-cli.md b/doc/03-cli.md index 039e6a8bd..494bfa965 100644 --- a/doc/03-cli.md +++ b/doc/03-cli.md @@ -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`. diff --git a/phpstan/baseline.neon b/phpstan/baseline.neon index 6a7c6d188..5538f5f70 100644 --- a/phpstan/baseline.neon +++ b/phpstan/baseline.neon @@ -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 diff --git a/src/Composer/Command/AuditCommand.php b/src/Composer/Command/AuditCommand.php new file mode 100644 index 000000000..e9770eb1c --- /dev/null +++ b/src/Composer/Command/AuditCommand.php @@ -0,0 +1,102 @@ +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( + <<audit 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; + } +} diff --git a/src/Composer/Command/CreateProjectCommand.php b/src/Composer/Command/CreateProjectCommand.php index a895c5494..0a7657271 100644 --- a/src/Composer/Command/CreateProjectCommand.php +++ b/src/Composer/Command/CreateProjectCommand.php @@ -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); diff --git a/src/Composer/Command/InstallCommand.php b/src/Composer/Command/InstallCommand.php index 6e27a5815..d707ecbb4 100644 --- a/src/Composer/Command/InstallCommand.php +++ b/src/Composer/Command/InstallCommand.php @@ -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')) { diff --git a/src/Composer/Command/RemoveCommand.php b/src/Composer/Command/RemoveCommand.php index e64125cfa..5cb509b92 100644 --- a/src/Composer/Command/RemoveCommand.php +++ b/src/Composer/Command/RemoveCommand.php @@ -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 @@ -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 diff --git a/src/Composer/Command/RequireCommand.php b/src/Composer/Command/RequireCommand.php index a04bb0c08..9b2ae44c7 100644 --- a/src/Composer/Command/RequireCommand.php +++ b/src/Composer/Command/RequireCommand.php @@ -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 diff --git a/src/Composer/Command/UpdateCommand.php b/src/Composer/Command/UpdateCommand.php index 1676f2c71..94092609e 100644 --- a/src/Composer/Command/UpdateCommand.php +++ b/src/Composer/Command/UpdateCommand.php @@ -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')) { diff --git a/src/Composer/Console/Application.php b/src/Composer/Console/Application.php index be16570b4..d1b304535 100644 --- a/src/Composer/Console/Application.php +++ b/src/Composer/Console/Application.php @@ -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(), diff --git a/src/Composer/IO/ConsoleIO.php b/src/Composer/IO/ConsoleIO.php index 508a34e8b..5ad647a20 100644 --- a/src/Composer/IO/ConsoleIO.php +++ b/src/Composer/IO/ConsoleIO.php @@ -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 */ diff --git a/src/Composer/Installer.php b/src/Composer/Installer.php index 3ea52508e..36825de36 100644 --- a/src/Composer/Installer.php +++ b/src/Composer/Installer.php @@ -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 $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. * diff --git a/src/Composer/Util/Auditor.php b/src/Composer/Util/Auditor.php new file mode 100644 index 000000000..7b3cb0715 --- /dev/null +++ b/src/Composer/Util/Auditor.php @@ -0,0 +1,245 @@ +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:"); + $this->outputAdvisories($io, $advisories, $format); + return 1; + } + $io->writeError('No security vulnerability advisories found'); + return 0; + } + + /** + * Get advisories from packagist.org + * + * @param PackageInterface[] $packages + * @param ?int $updatedSince Timestamp + * @param bool $filterByVersion Filter by the package versions if true + * @return string[][][] + * @throws InvalidArgumentException If no packages and no updatedSince timestamp are passed in + */ + public function getAdvisories(array $packages = [], int $updatedSince = null, bool $filterByVersion = true): array + { + if (count($packages) === 0 && $updatedSince === null) { + throw new InvalidArgumentException( + 'At least one package or an $updatedSince timestamp must be passed in.' + ); + } + + if (count($packages) === 0 && $filterByVersion) { + return []; + } + + // Add updatedSince query to URL if passed in + $url = self::API_URL; + if ($updatedSince !== null) { + $url .= "?updatedSince=$updatedSince"; + } + + // Get advisories from API + $response = $this->httpDownloader->get($url, $this->createPostOptions($packages)); + $advisories = $response->decodeJson()['advisories']; + + if (count($advisories) > 0 && $filterByVersion) { + return $this->filterAdvisories($advisories, $packages); + } + + return $advisories; + } + + /** + * @param PackageInterface[] $packages + * @return string[] + * @phpstan-return array|int|string>> + */ + private function createPostOptions(array $packages): array + { + $options = [ + 'http' => [ + 'method' => 'POST', + 'header' => ['Content-type: application/x-www-form-urlencoded'], + 'timeout' => 10, + ], + ]; + if (count($packages) > 0) { + $content = ['packages' => []]; + foreach ($packages as $package) { + $content['packages'][] = $package->getName(); + } + $options['http']['content'] = http_build_query($content); + } + return $options; + } + + /** + * @param string[][][] $advisories + * @param PackageInterface[] $packages + * @return string[][][] + */ + private function filterAdvisories(array $advisories, array $packages): array + { + $filteredAdvisories = []; + foreach ($packages as $package) { + if (array_key_exists($package->getName(), $advisories)) { + foreach ($advisories[$package->getName()] as $advisory) { + if (Semver::satisfies($package->getVersion(), $advisory['affectedVersions'])) { + $filteredAdvisories[$package->getName()][] = $advisory; + } + } + } + } + return $filteredAdvisories; + } + + /** + * @param string[][][] $advisories + * @return integer + */ + private function countAdvisories(array $advisories): int + { + $count = 0; + foreach ($advisories as $packageAdvisories) { + $count += count($packageAdvisories); + } + return $count; + } + + /** + * @param IOInterface $io + * @param string[][][] $advisories + * @param self::FORMAT_* $format The format that will be used to output audit results. + * @return void + */ + private function outputAdvisories(IOInterface $io, array $advisories, string $format): void + { + switch ($format) { + case self::FORMAT_TABLE: + if (!($io instanceof ConsoleIO)) { + throw new InvalidArgumentException('Cannot use table format with ' . get_class($io)); + } + $this->outputAvisoriesTable($io, $advisories); + return; + case self::FORMAT_PLAIN: + $this->outputAdvisoriesPlain($io, $advisories); + return; + case self::FORMAT_SUMMARY: + // We've already output the number of advisories in audit() + $io->writeError('Run composer audit for a full list of advisories.'); + 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); + } +} diff --git a/tests/Composer/Test/Command/UpdateCommandTest.php b/tests/Composer/Test/Command/UpdateCommandTest.php index 9a12a49bd..ba9757772 100644 --- a/tests/Composer/Test/Command/UpdateCommandTest.php +++ b/tests/Composer/Test/Command/UpdateCommandTest.php @@ -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))); } diff --git a/tests/Composer/Test/InstallerTest.php b/tests/Composer/Test/InstallerTest.php index baed74379..d714e618b 100644 --- a/tests/Composer/Test/InstallerTest.php +++ b/tests/Composer/Test/InstallerTest.php @@ -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(); }); diff --git a/tests/Composer/Test/Util/AuditorTest.php b/tests/Composer/Test/Util/AuditorTest.php new file mode 100644 index 000000000..8a64a56ee --- /dev/null +++ b/tests/Composer/Test/Util/AuditorTest.php @@ -0,0 +1,399 @@ +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 $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 $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; + } +}