diff --git a/doc/03-cli.md b/doc/03-cli.md index bdf95c854..7041617ad 100644 --- a/doc/03-cli.md +++ b/doc/03-cli.md @@ -1087,7 +1087,8 @@ php composer.phar audit * **--abandoned:** Behavior on abandoned packages. Must be "ignore", "report", or "fail". See also [audit.abandoned](06-config.md#abandoned). Passing this flag will override the config value and the environment variable. - +* **--ignore-severity:** Ignore advisories of a certain severity level. Can be passed one or more + time to ignore multiple severities. ## help diff --git a/src/Composer/Advisory/Auditor.php b/src/Composer/Advisory/Auditor.php index bfd62a087..de8034bad 100644 --- a/src/Composer/Advisory/Auditor.php +++ b/src/Composer/Advisory/Auditor.php @@ -19,7 +19,6 @@ use Composer\Package\CompletePackageInterface; use Composer\Package\PackageInterface; use Composer\Repository\RepositorySet; use Composer\Util\PackageInfo; -use Composer\Util\Platform; use InvalidArgumentException; use Symfony\Component\Console\Formatter\OutputFormatter; @@ -60,11 +59,12 @@ class Auditor * @param bool $warningOnly If true, outputs a warning. If false, outputs an error. * @param string[] $ignoreList List of advisory IDs, remote IDs or CVE IDs that reported but not listed as vulnerabilities. * @param self::ABANDONED_* $abandoned + * @param array $ignoredSeverities List of ignored severity levels * * @return int Amount of packages with vulnerabilities found * @throws InvalidArgumentException If no packages are passed in */ - public function audit(IOInterface $io, RepositorySet $repoSet, array $packages, string $format, bool $warningOnly = true, array $ignoreList = [], string $abandoned = self::ABANDONED_FAIL): int + public function audit(IOInterface $io, RepositorySet $repoSet, array $packages, string $format, bool $warningOnly = true, array $ignoreList = [], string $abandoned = self::ABANDONED_FAIL, array $ignoredSeverities = []): int { $allAdvisories = $repoSet->getMatchingSecurityAdvisories($packages, $format === self::FORMAT_SUMMARY); // we need the CVE & remote IDs set to filter ignores correctly so if we have any matches using the optimized codepath above @@ -72,7 +72,7 @@ class Auditor if (count($allAdvisories) > 0 && $ignoreList !== [] && $format === self::FORMAT_SUMMARY) { $allAdvisories = $repoSet->getMatchingSecurityAdvisories($packages, false); } - ['advisories' => $advisories, 'ignoredAdvisories' => $ignoredAdvisories] = $this->processAdvisories($allAdvisories, $ignoreList); + ['advisories' => $advisories, 'ignoredAdvisories' => $ignoredAdvisories] = $this->processAdvisories($allAdvisories, $ignoreList, $ignoredSeverities); $abandonedCount = 0; $affectedPackagesCount = 0; @@ -90,8 +90,9 @@ class Auditor if ($ignoredAdvisories !== []) { $json['ignored-advisories'] = $ignoredAdvisories; } - $json['abandoned'] = array_reduce($abandonedPackages, static function(array $carry, CompletePackageInterface $package): array { + $json['abandoned'] = array_reduce($abandonedPackages, static function (array $carry, CompletePackageInterface $package): array { $carry[$package->getPrettyName()] = $package->getReplacementPackage(); + return $carry; }, []); @@ -146,11 +147,12 @@ class Auditor /** * @phpstan-param array> $allAdvisories * @param array|array $ignoreList List of advisory IDs, remote IDs or CVE IDs that reported but not listed as vulnerabilities. + * @param array $ignoredSeverities List of ignored severity levels * @phpstan-return array{advisories: array>, ignoredAdvisories: array>} */ - private function processAdvisories(array $allAdvisories, array $ignoreList): array + private function processAdvisories(array $allAdvisories, array $ignoreList, array $ignoredSeverities): array { - if ($ignoreList === []) { + if ($ignoreList === [] && $ignoredSeverities === []) { return ['advisories' => $allAdvisories, 'ignoredAdvisories' => []]; } @@ -174,6 +176,11 @@ class Auditor } if ($advisory instanceof SecurityAdvisory) { + if (in_array($advisory->severity, $ignoredSeverities, true)) { + $isActive = false; + $ignoreReason = "Ignored via --ignore-severity={$advisory->severity}"; + } + if (in_array($advisory->cve, $ignoredIds, true)) { $isActive = false; $ignoreReason = $ignoreList[$advisory->cve] ?? null; @@ -394,5 +401,4 @@ class Auditor return 'link).'>'.OutputFormatter::escape($advisory->link).''; } - } diff --git a/src/Composer/Command/AuditCommand.php b/src/Composer/Command/AuditCommand.php index c0b0dcfad..e4a2094b8 100644 --- a/src/Composer/Command/AuditCommand.php +++ b/src/Composer/Command/AuditCommand.php @@ -34,6 +34,7 @@ class AuditCommand extends BaseCommand 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, Auditor::ABANDONEDS), + new InputOption('ignore-severity', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Ignore advisories of a certain severity level.', [], ['low', 'medium', 'high', 'critical']), ]) ->setHelp( <<getOption('ignore-severity') ?? []; + return min(255, $auditor->audit( $this->getIO(), $repoSet, @@ -80,8 +83,10 @@ EOT $this->getAuditFormat($input, 'format'), false, $auditConfig['ignore'] ?? [], - $abandoned + $abandoned, + $ignoreSeverities )); + } /** diff --git a/tests/Composer/Test/Advisory/AuditorTest.php b/tests/Composer/Test/Advisory/AuditorTest.php index 5e66951a0..cc9efb985 100644 --- a/tests/Composer/Test/Advisory/AuditorTest.php +++ b/tests/Composer/Test/Advisory/AuditorTest.php @@ -15,7 +15,6 @@ namespace Composer\Test\Advisory; use Composer\Advisory\PartialSecurityAdvisory; use Composer\Advisory\SecurityAdvisory; use Composer\IO\BufferIO; -use Composer\IO\NullIO; use Composer\Package\CompletePackage; use Composer\Package\Package; use Composer\Package\Version\VersionParser; @@ -23,7 +22,6 @@ use Composer\Repository\ComposerRepository; use Composer\Repository\RepositorySet; use Composer\Test\TestCase; use Composer\Advisory\Auditor; -use Composer\Util\Platform; use InvalidArgumentException; class AuditorTest extends TestCase @@ -162,7 +160,8 @@ Found 2 abandoned packages: self::assertSame($output, trim(str_replace("\r", '', $io->getOutput()))); } - public function ignoredIdsProvider(): \Generator { + public function ignoredIdsProvider(): \Generator + { yield 'ignore by CVE' => [ [ new Package('vendor1/package1', '3.0.0.0', '3.0.0'), @@ -178,7 +177,7 @@ Found 2 abandoned packages: ['text' => 'URL: https://advisory.example.com/advisory1'], ['text' => 'Affected versions: >=3,<3.4.3|>=1,<2.5.6'], ['text' => 'Reported at: 2022-05-25T13:21:00+00:00'], - ] + ], ]; yield 'ignore by CVE with reasoning' => [ [ @@ -196,7 +195,7 @@ Found 2 abandoned packages: ['text' => 'Affected versions: >=3,<3.4.3|>=1,<2.5.6'], ['text' => 'Reported at: 2022-05-25T13:21:00+00:00'], ['text' => 'Ignore reason: A good reason'], - ] + ], ]; yield 'ignore by advisory id' => [ [ @@ -213,7 +212,7 @@ Found 2 abandoned packages: ['text' => 'URL: https://advisory.example.com/advisory2'], ['text' => 'Affected versions: >=3,<3.4.3|>=1,<2.5.6'], ['text' => 'Reported at: 2022-05-25T13:21:00+00:00'], - ] + ], ]; yield 'ignore by remote id' => [ [ @@ -230,7 +229,7 @@ Found 2 abandoned packages: ['text' => 'URL: https://advisory.example.com/advisory17'], ['text' => 'Affected versions: >=3,<3.4.3|>=1,<2.5.6'], ['text' => 'Reported at: 2015-05-25T13:21:00+00:00'], - ] + ], ]; yield '1 vulnerability, 0 ignored' => [ [ @@ -247,7 +246,7 @@ Found 2 abandoned packages: ['text' => 'URL: https://advisory.example.com/advisory1'], ['text' => 'Affected versions: >=3,<3.4.3|>=1,<2.5.6'], ['text' => 'Reported at: 2022-05-25T13:21:00+00:00'], - ] + ], ]; yield '1 vulnerability, 3 ignored affecting 2 packages' => [ [ @@ -295,7 +294,7 @@ Found 2 abandoned packages: ['text' => 'URL: https://advisory.example.com/advisory7'], ['text' => 'Affected versions: >=3,<3.4.3|>=1,<2.5.6'], ['text' => 'Reported at: 2015-05-25T13:21:00+00:00'], - ] + ], ]; } @@ -314,6 +313,55 @@ Found 2 abandoned packages: self::assertSame($exitCode, $result); } + public function ignoreSeverityProvider(): \Generator + { + yield 'ignore medium' => [ + [ + new Package('vendor1/package1', '2.0.0.0', '2.0.0'), + ], + ['medium'], + 1, + [ + ['text' => 'Found 2 ignored security vulnerability advisories affecting 1 package:'], + ], + ]; + yield 'ignore high' => [ + [ + new Package('vendor1/package1', '2.0.0.0', '2.0.0'), + ], + ['high'], + 1, + [ + ['text' => 'Found 1 ignored security vulnerability advisory affecting 1 package:'], + ], + ]; + yield 'ignore high and medium' => [ + [ + new Package('vendor1/package1', '2.0.0.0', '2.0.0'), + ], + ['high', 'medium'], + 0, + [ + ['text' => 'Found 3 ignored security vulnerability advisories affecting 1 package:'], + ], + ]; + } + + /** + * @dataProvider ignoreSeverityProvider + * @phpstan-param array<\Composer\Package\Package> $packages + * @phpstan-param array $ignoredSeverities + * @phpstan-param 0|positive-int $exitCode + * @phpstan-param list $expectedOutput + */ + public function testAuditWithIgnoreSeverity($packages, $ignoredSeverities, $exitCode, $expectedOutput): void + { + $auditor = new Auditor(); + $result = $auditor->audit($io = $this->getIOMock(), $this->getRepoSet(), $packages, Auditor::FORMAT_PLAIN, false, [], Auditor::ABANDONED_IGNORE, $ignoredSeverities); + $io->expects($expectedOutput, true); + self::assertSame($exitCode, $result); + } + private function getRepoSet(): RepositorySet { $repo = $this