From e4aba917d54d8399263308c52e35f6932ab2779a Mon Sep 17 00:00:00 2001 From: Guy Sartorelli Date: Mon, 30 May 2022 19:57:20 +1200 Subject: [PATCH] Add audit command to check for security issues --- doc/03-cli.md | 19 +++ phpstan/baseline.neon | 15 +++ src/Composer/Command/AuditCommand.php | 49 ++++++++ src/Composer/Command/CreateProjectCommand.php | 4 +- src/Composer/Command/InstallCommand.php | 2 + src/Composer/Command/RemoveCommand.php | 1 + src/Composer/Command/RequireCommand.php | 2 + src/Composer/Command/UpdateCommand.php | 2 + src/Composer/Console/Application.php | 1 + src/Composer/Installer.php | 26 ++++- src/Composer/Util/Auditor.php | 109 ++++++++++++++++++ tests/Composer/Test/Util/AuditorTest.php | 79 +++++++++++++ 12 files changed, 307 insertions(+), 2 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 9fd8256b610b..5545851ed80b 100644 --- a/doc/03-cli.md +++ b/doc/03-cli.md @@ -103,6 +103,7 @@ 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. * **--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 +183,7 @@ 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. * **--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 +255,7 @@ 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. * **--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. @@ -850,6 +853,7 @@ 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. * **--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 +959,21 @@ 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 in your composer.lock +for possible security issues. Currently this only checks for and +lists security vulnerability advisories according to the +[packagist api](https://packagist.org/apidoc#list-security-advisories). + +```sh +php composer.phar audit +``` + +### Options + +* **--no-dev:** Disables auditing of require-dev packages. + ## 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..908441985a93 100644 --- a/phpstan/baseline.neon +++ b/phpstan/baseline.neon @@ -4578,6 +4578,21 @@ parameters: count: 1 path: ../src/Composer/SelfUpdate/Versions.php + - + message: "#^Method Composer\\\\Command\\\\AuditCommand\\:\\:configure\\(\\) has no return type specified\\.$#" + count: 1 + path: src/Composer/Command/AuditCommand.php + + - + message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" + count: 2 + path: src/Composer/Util/Auditor.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..6edbd5ffc5a7 --- /dev/null +++ b/src/Composer/Command/AuditCommand.php @@ -0,0 +1,49 @@ +setName('audit') + ->setDescription('Checks for security vulnerability advisories for packages in your composer.lock.') + ->setDefinition(array( + new InputOption('no-dev', null, InputOption::VALUE_NONE, 'Disables auditing of require-dev packages.'), + )) + ->setHelp( + <<audit command checks for security vulnerability advisories for packages in your composer.lock. + +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) + { + $lockFile = Factory::getLockFile(Factory::getComposerFile()); + if (!Filesystem::isReadable($lockFile)) { + $this->getIO()->writeError('' . $lockFile . ' is not readable.'); + return 1; + } + + $composer = $this->requireComposer($input->getOption('no-plugins'), $input->getOption('no-scripts')); + $locker = $composer->getLocker(); + $packages = $locker->getLockedRepository(!$input->getOption('no-dev'))->getPackages(); + $httpDownloader = Factory::createHttpDownloader($this->getIO(), $composer->getConfig()); + + return Auditor::audit($this->getIO(), $httpDownloader, $packages, false); + } +} diff --git a/src/Composer/Command/CreateProjectCommand.php b/src/Composer/Command/CreateProjectCommand.php index 1e98f836df96..3950de68e01a 100644 --- a/src/Composer/Command/CreateProjectCommand.php +++ b/src/Composer/Command/CreateProjectCommand.php @@ -88,6 +88,7 @@ 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('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 +258,8 @@ 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')); if (!$composer->getLocker()->isLocked()) { $installer->setUpdate(true); diff --git a/src/Composer/Command/InstallCommand.php b/src/Composer/Command/InstallCommand.php index 13b6ff400628..aab124fc9aa8 100644 --- a/src/Composer/Command/InstallCommand.php +++ b/src/Composer/Command/InstallCommand.php @@ -49,6 +49,7 @@ 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('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 +129,7 @@ protected function execute(InputInterface $input, OutputInterface $output) ->setClassMapAuthoritative($authoritative) ->setApcuAutoloader($apcu, $apcuPrefix) ->setPlatformRequirementFilter($this->getPlatformRequirementFilter($input)) + ->setAudit(!$input->getOption('no-audit')) ; if ($input->getOption('no-plugins')) { diff --git a/src/Composer/Command/RemoveCommand.php b/src/Composer/Command/RemoveCommand.php index 9d1fa4965027..ca732f8c2330 100644 --- a/src/Composer/Command/RemoveCommand.php +++ b/src/Composer/Command/RemoveCommand.php @@ -280,6 +280,7 @@ protected function execute(InputInterface $input, OutputInterface $output) ->setUpdateAllowTransitiveDependencies($updateAllowTransitiveDependencies) ->setPlatformRequirementFilter($this->getPlatformRequirementFilter($input)) ->setDryRun($dryRun) + ->setAudit(false) ; // 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..08319bf9b281 100644 --- a/src/Composer/Command/RequireCommand.php +++ b/src/Composer/Command/RequireCommand.php @@ -78,6 +78,7 @@ 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('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 +415,7 @@ 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')) ; // 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..00d6acde4cbf 100644 --- a/src/Composer/Command/UpdateCommand.php +++ b/src/Composer/Command/UpdateCommand.php @@ -58,6 +58,7 @@ 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('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 +228,7 @@ protected function execute(InputInterface $input, OutputInterface $output) ->setPreferStable($input->getOption('prefer-stable')) ->setPreferLowest($input->getOption('prefer-lowest')) ->setTemporaryConstraints($temporaryConstraints) + ->setAudit(!$input->getOption('no-audit')) ; 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/Installer.php b/src/Composer/Installer.php index c4cf9785eed6..8b83548b4568 100644 --- a/src/Composer/Installer.php +++ b/src/Composer/Installer.php @@ -61,6 +61,7 @@ use Composer\Repository\LockArrayRepository; use Composer\Script\ScriptEvents; use Composer\Semver\Constraint\ConstraintInterface; +use Composer\Util\Auditor; use Composer\Util\Platform; /** @@ -163,6 +164,8 @@ class Installer protected $writeLock; /** @var bool */ protected $executeOperations = true; + /** @var bool */ + protected $audit = true; /** @var bool */ protected $updateMirrors = false; @@ -381,6 +384,11 @@ public function run(): int gc_enable(); } + if ($this->audit) { + $packages = $this->locker->getLockedRepository($this->devMode)->getPackages(); + Auditor::audit($this->io, Factory::createHttpDownloader($this->io, $this->config), $packages); + } + return 0; } @@ -1071,10 +1079,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 +1429,19 @@ 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; + } + /** * Disables plugins. * diff --git a/src/Composer/Util/Auditor.php b/src/Composer/Util/Auditor.php new file mode 100644 index 000000000000..d1c0ff2a90e6 --- /dev/null +++ b/src/Composer/Util/Auditor.php @@ -0,0 +1,109 @@ +Found the following security vulnerability advisories:"]; + foreach ($advisories as $package => $packageAdvisories) { + $error[] = "$package:"; + foreach ($packageAdvisories as $advisory) { + $cve = $advisory['cve'] ?: 'NO CVE'; + $error[] = "\t{$cve}"; + $error[] = "\t\tTitle: {$advisory['title']}"; + $error[] = "\t\tURL: {$advisory['link']}"; + $error[] = "\t\tAffected versions: {$advisory['affectedVersions']}"; + $error[] = "\t\tReported on: {$advisory['reportedAt']}"; + } + } + $error[] = ""; + $io->writeError($error); + return 1; + } + $io->writeError('No issues found'); + return 0; + } + + /** + * Get advisories from packagist.org + * + * @param HttpDownloader $httpDownloader + * @param BasePackage[] $packages + * @param ?int $updatedSince Timestamp + * @param bool $filterByVersion If true, don't filter by the package versions + * @return string[][][] + * @throws InvalidArgumentException If no packages and no updatedSince timestamp are passed in + */ + public static function getAdvisories( + HttpDownloader $httpDownloader, + array $packages = [], + int $updatedSince = null, + bool $filterByVersion = false + ): array + { + if (empty($packages) && $updatedSince === null) { + throw new InvalidArgumentException('At least one package or an $updatedSince timestamp must be passed in.'); + } + + $url = static::API_URL; + + // Get advisories for the given packages + $body = ['packages' => []]; + foreach ($packages as $package) { + $body['packages'][] = $package->getName(); + } + + // Add updatedSince if passed in + if ($updatedSince !== null) { + $url .= "?updatedSince=$updatedSince"; + } + + // Get API response + $response = $httpDownloader->get($url, [ + 'http' => [ + 'method' => 'POST', + 'header' => ['Content-type: application/x-www-form-urlencoded'], + 'content' => http_build_query($body), + 'timeout' => 3, + ], + ]); + $advisories = $response->decodeJson()['advisories']; + + if (!empty($packages) && !$filterByVersion) { + // Filter out any advisories that aren't relevant for these package versions + $relevantAdvisories = []; + foreach ($packages as $package) { + if (array_key_exists($package->getName(), $advisories)) { + foreach ($advisories[$package->getName()] as $advisory) { + if (Semver::satisfies($package->getVersion(), $advisory['affectedVersions'])) { + $relevantAdvisories[$package->getName()][] = $advisory; + } + } + } + } + return $relevantAdvisories; + } + + return $advisories; + } +} diff --git a/tests/Composer/Test/Util/AuditorTest.php b/tests/Composer/Test/Util/AuditorTest.php new file mode 100644 index 000000000000..66512d8d182d --- /dev/null +++ b/tests/Composer/Test/Util/AuditorTest.php @@ -0,0 +1,79 @@ +io = $this + ->getMockBuilder(IOInterface::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->httpProvider = $this + ->getMockBuilder(HttpDownloader::class) + ->onlyMethods(['get']) + ->getMock(); + } + + public function auditProvider() + { + // TODO + return [ + [ + 'data' => [ + 'packages' => [], + 'warningOnly' => true, + ], + 'expected' => 0, + ], + ]; + } + + /** + * @dataProvider auditProvider + * @phpstan-param array $data + */ + public function testAudit(array $data, int $expected): void + { + $result = Auditor::audit($this->io, $this->httpProvider, $data['packages'], $data['warningOnly']); + $this->assertSame($expected, $result); + } + + public function advisoriesProvider() + { + // TODO + return [ + [ + 'data' => [ + 'packages' => [], + 'updatedSince' => null, + 'filterByVersion' => false + ], + 'expected' => [], + ], + ]; + } + + /** + * @dataProvider advisoriesProvider + * @phpstan-param array $data + */ + public function testGetAdvisories(array $data, array $expected): void + { + $result = Auditor::getAdvisories($this->httpProvider, $data['packages'], $data['updatedSince'], $data['filterByVersion']); + $this->assertSame($expected, $result); + } +}