diff --git a/doc/03-cli.md b/doc/03-cli.md index b76891b6f542..df9096b0b2c3 100644 --- a/doc/03-cli.md +++ b/doc/03-cli.md @@ -955,6 +955,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/src/Composer/Command/AuditCommand.php b/src/Composer/Command/AuditCommand.php new file mode 100644 index 000000000000..178b507aec99 --- /dev/null +++ b/src/Composer/Command/AuditCommand.php @@ -0,0 +1,54 @@ +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(), $packages, $httpDownloader); + } + + protected function interact(InputInterface $input, OutputInterface $output) + { + return; + } +} diff --git a/src/Composer/Console/Application.php b/src/Composer/Console/Application.php index ba140a1496ea..56776deee7f2 100644 --- a/src/Composer/Console/Application.php +++ b/src/Composer/Console/Application.php @@ -528,6 +528,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/Util/Auditor.php b/src/Composer/Util/Auditor.php new file mode 100644 index 000000000000..7ae778f42c5d --- /dev/null +++ b/src/Composer/Util/Auditor.php @@ -0,0 +1,84 @@ +Found the following security vulnerability advisories:']; + foreach ($allAdvisories as $package => $advisories) { + $error[] = "$package:"; + foreach ($advisories 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 that match the package versions + * + * @param BasePackage[] $packages + * @param HttpDownloader $httpDownloader + * @param bool $allAdvisories If true, don't filter by the package versions + * @return array + */ + public static function getAdvisories(array $packages, HttpDownloader $httpDownloader, bool $allAdvisories = false): array + { + // Get advisories for the given packages + $packageNames = []; + foreach ($packages as $package) { + $packageNames[] = $package->getName(); + } + $response = $httpDownloader->get(static::API_URL, [ + 'http' => [ + 'method' => 'POST', + 'header' => ['Content-type: application/x-www-form-urlencoded'], + 'content' => http_build_query(['packages' => $packageNames]), + 'timeout' => 3, + ], + ]); + $advisories = $response->decodeJson()['advisories']; + + if (!$allAdvisories) { + // 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..1f4b6fcb3bb3 --- /dev/null +++ b/tests/Composer/Test/Util/AuditorTest.php @@ -0,0 +1,50 @@ +io = $this + ->getMockBuilder('Composer\IO\IOInterface') + ->disableOriginalConstructor() + ->getMock(); + } + + public function auditProvider() + { + return array( + array( + 'some value', + 'some other value', + ), + array( + 'some value', + 'some other value', + ), + ); + } + + /** + * @dataProvider auditProvider + */ + public function testAudit(string $value1, string $value2) + { + Auditor::audit($this->io, $packages, $this->httpProvider); + } + + /** + * @dataProvider auditProvider + */ + public function testGetAdvisories(string $value1, string $value2) + { + Auditor::getAdvisories($packages, $this->httpProvider); + } +}