diff --git a/src/Composer/Command/AuditCommand.php b/src/Composer/Command/AuditCommand.php new file mode 100644 index 0000000..f7327fb --- /dev/null +++ b/src/Composer/Command/AuditCommand.php @@ -0,0 +1,136 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Command; + +use Composer\Composer; +use Composer\FilterList\FilterListProvider\FilterListProviderSet; +use Composer\Policy\ListPolicyConfig; +use Composer\Policy\PolicyConfig; +use Composer\Repository\RepositorySet; +use Composer\Repository\RepositoryUtils; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Composer\Package\PackageInterface; +use Composer\Repository\InstalledRepository; +use Composer\Advisory\Auditor; +use Composer\Console\Input\InputOption; + +class AuditCommand extends BaseCommand +{ + protected function configure(): void + { + $this + ->setName('audit') + ->setDescription('Checks for security vulnerability advisories for installed packages') + ->setDefinition([ + 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", "json", 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.'), + new InputOption('abandoned', null, InputOption::VALUE_REQUIRED, 'Behavior on abandoned packages. Must be "ignore", "report", or "fail".', null, ListPolicyConfig::AUDITS), + new InputOption('ignore-severity', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Ignore advisories of a certain severity level.', [], ['low', 'medium', 'high', 'critical']), + new InputOption('ignore-unreachable', null, InputOption::VALUE_NONE, 'Ignore repositories that are unreachable or return a non-200 status code.'), + ]) + ->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 + +If you want to ignore repositories that are unreachable or return a non-200 status code, use --ignore-unreachable + +Read more at https://getcomposer.org/doc/03-cli.md#audit +EOT + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $composer = $this->requireComposer(); + $packages = $this->getPackages($composer, $input); + + if (count($packages) === 0) { + if ($composer->getPackage()->getRequires() !== [] + || (!$input->getOption('no-dev') && $composer->getPackage()->getDevRequires() !== [])) { + $this->getIO()->writeError('No installed packages found. Please run "composer install" before running "audit" or pass "--locked" to audit the lock file.'); + + return Auditor::STATUS_FAILED; + } + + $this->getIO()->writeError('No packages - skipping audit.'); + + return Auditor::STATUS_OK; + } + + $auditor = new Auditor(); + $repoSet = new RepositorySet(); + foreach ($composer->getRepositoryManager()->getRepositories() as $repo) { + $repoSet->addRepository($repo); + } + + $abandoned = $input->getOption('abandoned'); + if ($abandoned !== null && !in_array($abandoned, ListPolicyConfig::AUDITS, true)) { + throw new \InvalidArgumentException('--abandoned must be one of '.implode(', ', ListPolicyConfig::AUDITS).'.'); + } + + $policyConfig = $this->createPolicyConfig($composer->getConfig(), $input); + if ($abandoned !== null) { + $policyConfig = $policyConfig->withAudit($abandoned); + } + + $ignoreSeverities = $input->getOption('ignore-severity'); + if (count($ignoreSeverities) > 0) { + $policyConfig = $policyConfig->withIgnoreSeverity(array_values($ignoreSeverities)); + } + if ($input->getOption('ignore-unreachable')) { + $policyConfig = $policyConfig->withIgnoreUnreachable('audit'); + } + + $filterListProviderSet = $policyConfig->enabled ? FilterListProviderSet::create($policyConfig, $composer->getRepositoryManager()->getRepositories(), $composer->getLoop()->getHttpDownloader()) : null; + + return min(255, $auditor->audit( + $this->getIO(), + $repoSet, + $policyConfig, + $packages, + $this->getAuditFormat($input, 'format'), + false, + $filterListProviderSet + )); + + } + + /** + * @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([$composer->getRepositoryManager()->getLocalRepository()]); + + if ($input->getOption('no-dev')) { + return RepositoryUtils::filterRequiredPackages($installedRepo->getPackages(), $rootPkg); + } + + return $installedRepo->getPackages(); + } +} diff --git a/tests/Composer/Test/Command/AuditCommandTest.php b/tests/Composer/Test/Command/AuditCommandTest.php new file mode 100644 index 0000000..9aa5262 --- /dev/null +++ b/tests/Composer/Test/Command/AuditCommandTest.php @@ -0,0 +1,189 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test\Command; + +use Composer\Advisory\Auditor; +use Composer\Policy\ListPolicyConfig; +use Composer\Test\TestCase; +use UnexpectedValueException; + +class AuditCommandTest extends TestCase +{ + public function testSuccessfulResponseCodeWhenNoPackagesAreRequired(): void + { + $this->initTempComposer(); + + $appTester = $this->getApplicationTester(); + $appTester->run(['command' => 'audit']); + + $appTester->assertCommandIsSuccessful(); + self::assertEquals('No packages - skipping audit.', trim($appTester->getDisplay(true))); + } + + public function testErrorAuditingLockFileWhenItIsMissing(): void + { + $this->initTempComposer(); + $this->createInstalledJson([self::getPackage()]); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage( + "Valid composer.json and composer.lock files are required to run this command with --locked" + ); + + $appTester = $this->getApplicationTester(); + $appTester->run(['command' => 'audit', '--locked' => true]); + } + + public function testAuditPackageWithNoSecurityVulnerabilities(): void + { + $this->initTempComposer(); + $packages = [self::getPackage()]; + $this->createInstalledJson($packages); + $this->createComposerLock($packages); + + $appTester = $this->getApplicationTester(); + $appTester->run(['command' => 'audit', '--locked' => true]); + + self::assertStringContainsString( + 'No security vulnerability advisories found.', + trim($appTester->getDisplay(true)) + ); + } + + public function testErrorAuditWithNoInstalledPackages(): void + { + $this->initTempComposer(['require' => ['dummy/pkg' => '1.0.0']]); + + $appTester = $this->getApplicationTester(); + $appTester->run(['command' => 'audit']); + + self::assertSame(Auditor::STATUS_FAILED, $appTester->getStatusCode()); + self::assertStringContainsString( + 'No installed packages found. Please run "composer install" before running "audit"', + trim($appTester->getDisplay(true)) + ); + } + + public function testAuditPackageWithNoDevOptionPassed(): void + { + $this->initTempComposer(); + $devPackage = [self::getPackage()]; + $this->createInstalledJson([], $devPackage); + $this->createComposerLock([], $devPackage); + + $appTester = $this->getApplicationTester(); + $appTester->run(['command' => 'audit', '--no-dev' => true]); + + self::assertStringContainsString( + 'No packages - skipping audit.', + trim($appTester->getDisplay(true)) + ); + } + + public function testAuditWithMalwareAndCustomListBothFail(): void + { + $packages = [ + ['name' => 'safe/pkg', 'version' => '1.0.0'], + ['name' => 'malicious/pkg', 'version' => '1.0.0'], + ['name' => 'banned/pkg', 'version' => '1.0.0'], + ]; + $this->initTempComposerWithFilterLists($packages); + $this->createComposerLockWithPackages($packages); + + $appTester = $this->getApplicationTester(); + $exitCode = $appTester->run(['command' => 'audit', '--locked' => true]); + + $display = $appTester->getDisplay(true); + self::assertStringContainsString('Found 2 packages matching filters', $display); + self::assertStringContainsString('malicious/pkg', $display); + self::assertStringContainsString('banned/pkg', $display); + + self::assertSame(Auditor::STATUS_FAILED, $exitCode); + } + + public function testAuditWithCustomListAuditReportDoesNotFail(): void + { + $this->initTempComposer([ + 'repositories' => [ + 'packages' => [ + 'type' => 'package', + 'package' => $packages = [ + ['name' => 'safe/pkg', 'version' => '1.0.0'], + ['name' => 'banned/pkg', 'version' => '1.0.0'], + ], + 'filter' => [ + 'company-banned' => [ + ['package' => 'banned/pkg', 'constraint' => '*', 'reason' => 'company policy'], + ], + ], + ], + ], + 'config' => [ + 'policy' => [ + 'company-banned' => ['audit' => ListPolicyConfig::AUDIT_REPORT], + ], + ], + ]); + + $this->createComposerLockWithPackages($packages); + + $appTester = $this->getApplicationTester(); + $exitCode = $appTester->run(['command' => 'audit', '--locked' => true]); + + $display = $appTester->getDisplay(true); + self::assertStringContainsString('Found 1 package matching filters', $display); + self::assertStringContainsString('banned/pkg', $display); + self::assertSame(Auditor::STATUS_OK, $exitCode); + } + + /** + * @param list $packages + */ + private function initTempComposerWithFilterLists(array $packages): void + { + $this->initTempComposer([ + 'repositories' => [ + 'packages' => [ + 'type' => 'package', + 'package' => $packages, + 'filter' => [ + 'malware' => [ + ['package' => 'malicious/pkg', 'constraint' => '*', 'reason' => 'malware sample'], + ], + 'company-banned' => [ + ['package' => 'banned/pkg', 'constraint' => '*', 'reason' => 'company policy'], + ], + ], + ], + ], + 'config' => [ + 'policy' => [ + 'company-banned' => true, + ], + ], + ]); + } + + /** + * @param list $packages + */ + private function createComposerLockWithPackages(array $packages): void + { + $packages = array_map(function (array $package) { + return self::getPackage($package['name'], $package['version']); + }, $packages); + + $this->createInstalledJson($packages); + $this->createComposerLock($packages); + } +}