diff --git a/doc/03-cli.md b/doc/03-cli.md index 9fd8256b610b..1db9045bfe95 100644 --- a/doc/03-cli.md +++ b/doc/03-cli.md @@ -103,6 +103,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. @@ -182,6 +184,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 @@ -253,6 +257,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. @@ -295,6 +301,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) @@ -850,6 +858,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. @@ -955,6 +965,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 32b12b27661d..e5e873e20deb 100644 --- a/phpstan/baseline.neon +++ b/phpstan/baseline.neon @@ -4578,6 +4578,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 000000000000..e9770eb1ce42 --- /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 1e98f836df96..7459704252dd 100644 --- a/src/Composer/Command/CreateProjectCommand.php +++ b/src/Composer/Command/CreateProjectCommand.php @@ -44,6 +44,7 @@ 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. @@ -88,6 +89,8 @@ protected function configure(): void 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.'), @@ -257,7 +260,9 @@ public function installProject(IOInterface $io, Config $config, InputInterface $ ->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 13b6ff400628..8297cbafed71 100644 --- a/src/Composer/Command/InstallCommand.php +++ b/src/Composer/Command/InstallCommand.php @@ -15,6 +15,7 @@ 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 Symfony\Component\Console\Input\InputOption; @@ -49,6 +50,8 @@ protected function configure() 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`.'), @@ -128,6 +131,8 @@ protected function execute(InputInterface $input, OutputInterface $output) ->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 9d1fa4965027..9e53ebea7275 100644 --- a/src/Composer/Command/RemoveCommand.php +++ b/src/Composer/Command/RemoveCommand.php @@ -25,6 +25,7 @@ use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Output\OutputInterface; use Composer\Package\BasePackage; +use Composer\Util\Auditor; /** * @author Pierre du Plessis @@ -47,6 +48,8 @@ protected function configure() 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.'), @@ -280,6 +283,8 @@ protected function execute(InputInterface $input, OutputInterface $output) ->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 230e449c6ef4..deb2df067a3c 100644 --- a/src/Composer/Command/RequireCommand.php +++ b/src/Composer/Command/RequireCommand.php @@ -31,6 +31,7 @@ use Composer\Repository\CompositeRepository; use Composer\Repository\PlatformRepository; use Composer\IO\IOInterface; +use Composer\Util\Auditor; use Composer\Util\Silencer; /** @@ -78,6 +79,8 @@ protected function configure() 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.'), @@ -414,6 +417,8 @@ private function doUpdate(InputInterface $input, OutputInterface $output, IOInte ->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 2bd15c9f550c..084e7a007f48 100644 --- a/src/Composer/Command/UpdateCommand.php +++ b/src/Composer/Command/UpdateCommand.php @@ -25,6 +25,7 @@ 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 Symfony\Component\Console\Input\InputOption; @@ -58,6 +59,8 @@ protected function configure() 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.'), @@ -227,6 +230,8 @@ protected function execute(InputInterface $input, OutputInterface $output) ->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 dac353a94cc1..b8bc3225c618 100644 --- a/src/Composer/Console/Application.php +++ b/src/Composer/Console/Application.php @@ -529,6 +529,7 @@ protected function getDefaultCommands(): array 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 971d52736c9c..cd0db94a869b 100644 --- a/src/Composer/IO/ConsoleIO.php +++ b/src/Composer/IO/ConsoleIO.php @@ -15,6 +15,7 @@ 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 @@ public function select($question, $choices, $default, $attempts = false, $errorM 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 c4cf9785eed6..49d240248ed8 100644 --- a/src/Composer/Installer.php +++ b/src/Composer/Installer.php @@ -27,6 +27,7 @@ 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\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 @@ public function run(): int 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 @@ public function setAdditionalFixedRepository(RepositoryInterface $additionalFixe /** * @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 @@ public function setExecuteOperations(bool $executeOperations = true): self 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 000000000000..7b3cb071562b --- /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 fdc876b9c83f..723ce21b37f8 100644 --- a/tests/Composer/Test/Command/UpdateCommandTest.php +++ b/tests/Composer/Test/Command/UpdateCommandTest.php @@ -26,7 +26,7 @@ public function testUpdate(array $composerJson, array $command, string $expected $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())); } diff --git a/tests/Composer/Test/InstallerTest.php b/tests/Composer/Test/InstallerTest.php index baed74379fab..d714e618b650 100644 --- a/tests/Composer/Test/InstallerTest.php +++ b/tests/Composer/Test/InstallerTest.php @@ -136,6 +136,7 @@ public function testInstaller(RootPackageInterface $rootPackage, array $reposito $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 @@ private function doTestIntegration(string $file, string $message, ?string $condi $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 @@ private function doTestIntegration(string $file, string $message, ?string $condi ->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 000000000000..8a64a56eef13 --- /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; + } +}