From 0ab4dfba7cd15bf37edce6162f677bb598618655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dezs=C5=91=20BICZ=C3=93?= Date: Fri, 1 Sep 2023 10:04:31 +0200 Subject: [PATCH] Change audit.ignore behavior before 2.6.0 (#11605) * Still report ignored security advisories Co-authored-by: Jordi Boggiano --- doc/06-config.md | 22 ++- phpstan/baseline.neon | 9 +- res/composer-schema.json | 23 ++- src/Composer/Advisory/Auditor.php | 152 ++++++++++++----- .../Advisory/IgnoredSecurityAdvisory.php | 50 ++++++ src/Composer/Advisory/SecurityAdvisory.php | 21 ++- src/Composer/Command/AuditCommand.php | 2 +- src/Composer/Config.php | 6 +- src/Composer/Installer.php | 2 +- tests/Composer/Test/Advisory/AuditorTest.php | 161 +++++++++++++++--- 10 files changed, 359 insertions(+), 89 deletions(-) create mode 100644 src/Composer/Advisory/IgnoredSecurityAdvisory.php diff --git a/doc/06-config.md b/doc/06-config.md index 615fd0d28..61391e276 100644 --- a/doc/06-config.md +++ b/doc/06-config.md @@ -105,15 +105,31 @@ optionally be an object with package name patterns for keys for more granular in Security audit configuration options -### ignored +### ignore -A set of advisory ids, remote ids or CVE ids that should be ignored and not reported as part of an audit. +A list of advisory ids, remote ids or CVE ids that are reported but let the audit command pass. ```json { "config": { "audit": { - "ignored": ["CVE-1234", "GHSA-xx", "PKSA-yy"] + "ignore": { + "CVE-1234": "The affected component is not in use.", + "GHSA-xx": "The security fix was applied as a patch.", + "PKSA-yy": "Due to mitigations in place the update can be delayed." + } + } + } +} +``` + +or + +```json +{ + "config": { + "audit": { + "ignore": ["CVE-1234", "GHSA-xx", "PKSA-yy"] } } } diff --git a/phpstan/baseline.neon b/phpstan/baseline.neon index cad1d17ff..73f87c388 100644 --- a/phpstan/baseline.neon +++ b/phpstan/baseline.neon @@ -5,6 +5,11 @@ parameters: count: 1 path: ../src/Composer/Advisory/Auditor.php + - + message: "#^Variable \\$affectedPackagesCount might not be defined\\.$#" + count: 1 + path: ../src/Composer/Advisory/Auditor.php + - message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" count: 10 @@ -3575,7 +3580,7 @@ parameters: - message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" - count: 6 + count: 5 path: ../src/Composer/Repository/Vcs/GitHubDriver.php - @@ -3610,7 +3615,7 @@ parameters: - message: "#^Only booleans are allowed in a negated boolean, string given\\.$#" - count: 2 + count: 1 path: ../src/Composer/Repository/Vcs/GitHubDriver.php - diff --git a/res/composer-schema.json b/res/composer-schema.json index 9757c8950..fd469d51c 100644 --- a/res/composer-schema.json +++ b/res/composer-schema.json @@ -329,12 +329,23 @@ "type": "object", "description": "Security audit configuration options", "properties": { - "ignored": { - "type": "array", - "description": "A set of advisory ids, remote ids or CVE ids that should be ignored and not reported as part of an audit.", - "items": { - "type": "string" - } + "ignore": { + "anyOf": [ + { + "type": "object", + "description": "A list of advisory ids, remote ids or CVE ids (keys) and the explanations (values) for why they're being ignored. The listed items are reported but let the audit command pass.", + "additionalProperties": { + "type": ["string", "string"] + } + }, + { + "type": "array", + "description": "A set of advisory ids, remote ids or CVE ids that are reported but let the audit command pass.", + "items": { + "type": "string" + } + } + ] } } }, diff --git a/src/Composer/Advisory/Auditor.php b/src/Composer/Advisory/Auditor.php index c48f54d47..5a8397c64 100644 --- a/src/Composer/Advisory/Auditor.php +++ b/src/Composer/Advisory/Auditor.php @@ -44,34 +44,55 @@ class Auditor * @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. - * @param string[] $ignoredIds Ignored advisory IDs, remote IDs or CVE IDs + * @param string[] $ignoreList List of advisory IDs, remote IDs or CVE IDs that reported but not listed as vulnerabilities. + * * @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 $ignoredIds = []): int + public function audit(IOInterface $io, RepositorySet $repoSet, array $packages, string $format, bool $warningOnly = true, array $ignoreList = []): int { - $advisories = $repoSet->getMatchingSecurityAdvisories($packages, $format === self::FORMAT_SUMMARY); - - if (\count($ignoredIds) > 0) { - $advisories = $this->filterIgnoredAdvisories($advisories, $ignoredIds); + $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 + // and ignores are set then we need to query again the full data to make sure it can be filtered + if (count($allAdvisories) > 0 && $ignoreList !== [] && $format === self::FORMAT_SUMMARY) { + $allAdvisories = $repoSet->getMatchingSecurityAdvisories($packages, false); } + ['advisories' => $advisories, 'ignoredAdvisories' => $ignoredAdvisories] = $this->processAdvisories($allAdvisories, $ignoreList); if (self::FORMAT_JSON === $format) { - $io->write(JsonFile::encode(['advisories' => $advisories])); + $json = ['advisories' => $advisories]; + if ($ignoredAdvisories !== []) { + $json['ignored-advisories'] = $ignoredAdvisories; + } + + $io->write(JsonFile::encode($json)); return count($advisories); } $errorOrWarn = $warningOnly ? 'warning' : 'error'; - if (count($advisories) > 0) { - [$affectedPackages, $totalAdvisories] = $this->countAdvisories($advisories); - $plurality = $totalAdvisories === 1 ? 'y' : 'ies'; - $pkgPlurality = $affectedPackages === 1 ? '' : 's'; - $punctuation = $format === 'summary' ? '.' : ':'; - $io->writeError("<$errorOrWarn>Found $totalAdvisories security vulnerability advisor{$plurality} affecting $affectedPackages package{$pkgPlurality}{$punctuation}"); - $this->outputAdvisories($io, $advisories, $format); + if (count($advisories) > 0 || count($ignoredAdvisories) > 0) { + $passes = [ + [$ignoredAdvisories, "Found %d ignored security vulnerability advisor%s affecting %d package%s%s"], + // this has to run last to allow $affectedPackagesCount in the return statement to be correct + [$advisories, "<$errorOrWarn>Found %d security vulnerability advisor%s affecting %d package%s%s"], + ]; + foreach ($passes as [$advisoriesToOutput, $message]) { + [$affectedPackagesCount, $totalAdvisoryCount] = $this->countAdvisories($advisoriesToOutput); + if ($affectedPackagesCount > 0) { + $plurality = $totalAdvisoryCount === 1 ? 'y' : 'ies'; + $pkgPlurality = $affectedPackagesCount === 1 ? '' : 's'; + $punctuation = $format === 'summary' ? '.' : ':'; + $io->writeError(sprintf($message, $totalAdvisoryCount, $plurality, $affectedPackagesCount, $pkgPlurality, $punctuation)); + $this->outputAdvisories($io, $advisoriesToOutput, $format); + } + } - return $affectedPackages; + if ($format === self::FORMAT_SUMMARY) { + $io->writeError('Run "composer audit" for a full list of advisories.'); + } + + return $affectedPackagesCount; } $io->writeError('No security vulnerability advisories found'); @@ -80,37 +101,66 @@ class Auditor } /** - * @phpstan-param array> $advisories - * @param array $ignoredIds - * @phpstan-return array> + * @phpstan-param array> $allAdvisories + * @param array|array $ignoreList List of advisory IDs, remote IDs or CVE IDs that reported but not listed as vulnerabilities. + * @phpstan-return array{advisories: array>, ignoredAdvisories: array>} */ - private function filterIgnoredAdvisories(array $advisories, array $ignoredIds): array + private function processAdvisories(array $allAdvisories, array $ignoreList): array { - foreach ($advisories as $package => $pkgAdvisories) { - $advisories[$package] = array_filter($pkgAdvisories, static function (PartialSecurityAdvisory $advisory) use ($ignoredIds) { + if ($ignoreList === []) { + return ['advisories' => $allAdvisories, 'ignoredAdvisories' => []]; + } + + if (\count($ignoreList) > 0 && !\array_is_list($ignoreList)) { + $ignoredIds = array_keys($ignoreList); + } else { + $ignoredIds = $ignoreList; + } + + $advisories = []; + $ignored = []; + $ignoreReason = null; + + foreach ($allAdvisories as $package => $pkgAdvisories) { + foreach ($pkgAdvisories as $advisory) { + $isActive = true; + if (in_array($advisory->advisoryId, $ignoredIds, true)) { - return false; + $isActive = false; + $ignoreReason = $ignoreList[$advisory->advisoryId] ?? null; } + if ($advisory instanceof SecurityAdvisory) { if (in_array($advisory->cve, $ignoredIds, true)) { - return false; + $isActive = false; + $ignoreReason = $ignoreList[$advisory->cve] ?? null; } foreach ($advisory->sources as $source) { if (in_array($source['remoteId'], $ignoredIds, true)) { - return false; + $isActive = false; + $ignoreReason = $ignoreList[$source['remoteId']] ?? null; + break; } } } - return true; - }); - if (\count($advisories[$package]) === 0) { - unset($advisories[$package]); + if ($isActive) { + $advisories[$package][] = $advisory; + continue; + } + + // Partial security advisories only used in summary mode + // and in that case we do not need to cast the object. + if ($advisory instanceof SecurityAdvisory) { + $advisory = $advisory->toIgnoredAdvisory($ignoreReason); + } + + $ignored[$package][] = $advisory; } } - return $advisories; + return ['advisories' => $advisories, 'ignoredAdvisories' => $ignored]; } /** @@ -146,8 +196,6 @@ class Auditor 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.'); return; default: @@ -162,24 +210,30 @@ class Auditor { foreach ($advisories as $packageAdvisories) { foreach ($packageAdvisories as $advisory) { + $headers = [ + 'Package', + 'CVE', + 'Title', + 'URL', + 'Affected versions', + 'Reported at', + ]; + $row = [ + $advisory->packageName, + $this->getCVE($advisory), + $advisory->title, + $this->getURL($advisory), + $advisory->affectedVersions->getPrettyString(), + $advisory->reportedAt->format(DATE_ATOM), + ]; + if ($advisory instanceof IgnoredSecurityAdvisory) { + $headers[] = 'Ignore reason'; + $row[] = $advisory->ignoreReason ?? 'None specified'; + } $io->getTable() ->setHorizontal() - ->setHeaders([ - 'Package', - 'CVE', - 'Title', - 'URL', - 'Affected versions', - 'Reported at', - ]) - ->addRow([ - $advisory->packageName, - $this->getCVE($advisory), - $advisory->title, - $this->getURL($advisory), - $advisory->affectedVersions->getPrettyString(), - $advisory->reportedAt->format(DATE_ATOM), - ]) + ->setHeaders($headers) + ->addRow($row) ->setColumnWidth(1, 80) ->setColumnMaxWidth(1, 80) ->render(); @@ -205,6 +259,9 @@ class Auditor $error[] = "URL: ".$this->getURL($advisory); $error[] = "Affected versions: ".OutputFormatter::escape($advisory->affectedVersions->getPrettyString()); $error[] = "Reported at: ".$advisory->reportedAt->format(DATE_ATOM); + if ($advisory instanceof IgnoredSecurityAdvisory) { + $error[] = "Ignore reason: ".($advisory->ignoreReason ?? 'None specified'); + } $firstAdvisory = false; } } @@ -228,4 +285,5 @@ class Auditor return 'link).'>'.OutputFormatter::escape($advisory->link).''; } + } diff --git a/src/Composer/Advisory/IgnoredSecurityAdvisory.php b/src/Composer/Advisory/IgnoredSecurityAdvisory.php new file mode 100644 index 000000000..ba9079287 --- /dev/null +++ b/src/Composer/Advisory/IgnoredSecurityAdvisory.php @@ -0,0 +1,50 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Advisory; + +use Composer\Semver\Constraint\ConstraintInterface; +use DateTimeImmutable; + +class IgnoredSecurityAdvisory extends SecurityAdvisory +{ + /** + * @var string|null + * @readonly + */ + public $ignoreReason; + + /** + * @param non-empty-array $sources + */ + public function __construct(string $packageName, string $advisoryId, ConstraintInterface $affectedVersions, string $title, array $sources, DateTimeImmutable $reportedAt, ?string $cve = null, ?string $link = null, ?string $ignoreReason = null) + { + parent::__construct($packageName, $advisoryId, $affectedVersions, $title, $sources, $reportedAt, $cve, $link); + + $this->ignoreReason = $ignoreReason; + } + + /** + * @return mixed + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + $data = parent::jsonSerialize(); + if ($this->ignoreReason === NULL) { + unset($data['ignoreReason']); + } + + return $data; + } + +} diff --git a/src/Composer/Advisory/SecurityAdvisory.php b/src/Composer/Advisory/SecurityAdvisory.php index 8fdf4dd55..e88228d60 100644 --- a/src/Composer/Advisory/SecurityAdvisory.php +++ b/src/Composer/Advisory/SecurityAdvisory.php @@ -42,14 +42,13 @@ class SecurityAdvisory extends PartialSecurityAdvisory public $reportedAt; /** - * @var array + * @var non-empty-array * @readonly */ public $sources; /** * @param non-empty-array $sources - * @readonly */ public function __construct(string $packageName, string $advisoryId, ConstraintInterface $affectedVersions, string $title, array $sources, DateTimeImmutable $reportedAt, ?string $cve = null, ?string $link = null) { @@ -62,6 +61,24 @@ class SecurityAdvisory extends PartialSecurityAdvisory $this->link = $link; } + /** + * @internal + */ + public function toIgnoredAdvisory(?string $ignoreReason): IgnoredSecurityAdvisory + { + return new IgnoredSecurityAdvisory( + $this->packageName, + $this->advisoryId, + $this->affectedVersions, + $this->title, + $this->sources, + $this->reportedAt, + $this->cve, + $this->link, + $ignoreReason + ); + } + /** * @return mixed */ diff --git a/src/Composer/Command/AuditCommand.php b/src/Composer/Command/AuditCommand.php index ed97bbc94..3c58d7feb 100644 --- a/src/Composer/Command/AuditCommand.php +++ b/src/Composer/Command/AuditCommand.php @@ -63,7 +63,7 @@ EOT $repoSet->addRepository($repo); } - return min(255, $auditor->audit($this->getIO(), $repoSet, $packages, $this->getAuditFormat($input, 'format'), false, $composer->getConfig()->get('audit')['ignored'] ?? [])); + return min(255, $auditor->audit($this->getIO(), $repoSet, $packages, $this->getAuditFormat($input, 'format'), false, $composer->getConfig()->get('audit')['ignore'] ?? [])); } /** diff --git a/src/Composer/Config.php b/src/Composer/Config.php index 0523bbb2a..0c11ab505 100644 --- a/src/Composer/Config.php +++ b/src/Composer/Config.php @@ -37,7 +37,7 @@ class Config 'allow-plugins' => [], 'use-parent-dir' => 'prompt', 'preferred-install' => 'dist', - 'audit' => ['ignored' => []], + 'audit' => ['ignore' => []], 'notify-on-install' => true, 'github-protocols' => ['https', 'ssh', 'git'], 'gitlab-protocol' => null, @@ -209,10 +209,10 @@ class Config $this->setSourceOfConfigValue($val, $key, $source); } } elseif ('audit' === $key) { - $currentIgnores = $this->config['audit']['ignored']; + $currentIgnores = $this->config['audit']['ignore']; $this->config[$key] = $val; $this->setSourceOfConfigValue($val, $key, $source); - $this->config['audit']['ignored'] = array_merge($currentIgnores, $val['ignored'] ?? []); + $this->config['audit']['ignore'] = array_merge($currentIgnores, $val['ignore'] ?? []); } else { $this->config[$key] = $val; $this->setSourceOfConfigValue($val, $key, $source); diff --git a/src/Composer/Installer.php b/src/Composer/Installer.php index 8f40cfb06..eaee35f9d 100644 --- a/src/Composer/Installer.php +++ b/src/Composer/Installer.php @@ -402,7 +402,7 @@ class Installer $repoSet->addRepository($repo); } - return $auditor->audit($this->io, $repoSet, $packages, $this->auditFormat, true, $this->config->get('audit')['ignored'] ?? []) > 0 ? self::ERROR_AUDIT_FAILED : 0; + return $auditor->audit($this->io, $repoSet, $packages, $this->auditFormat, true, $this->config->get('audit')['ignore'] ?? []) > 0 ? self::ERROR_AUDIT_FAILED : 0; } catch (TransportException $e) { $this->io->error('Failed to audit '.$target.' packages.'); if ($this->io->isVerbose()) { diff --git a/tests/Composer/Test/Advisory/AuditorTest.php b/tests/Composer/Test/Advisory/AuditorTest.php index ca87e8e9a..84b8693cf 100644 --- a/tests/Composer/Test/Advisory/AuditorTest.php +++ b/tests/Composer/Test/Advisory/AuditorTest.php @@ -14,7 +14,6 @@ namespace Composer\Test\Advisory; use Composer\Advisory\PartialSecurityAdvisory; use Composer\Advisory\SecurityAdvisory; -use Composer\IO\BufferIO; use Composer\IO\NullIO; use Composer\Package\Package; use Composer\Package\Version\VersionParser; @@ -72,33 +71,147 @@ class AuditorTest extends TestCase $this->assertSame($expected, $result, $message); } - public function testAuditIgnoredIDs(): void - { - $packages = [ - new Package('vendor1/package1', '3.0.0.0', '3.0.0'), - new Package('vendor1/package2', '3.0.0.0', '3.0.0'), - new Package('vendorx/packagex', '3.0.0.0', '3.0.0'), - new Package('vendor3/package1', '3.0.0.0', '3.0.0'), + public function ignoredIdsProvider(): \Generator { + yield 'ignore by CVE' => [ + [ + new Package('vendor1/package1', '3.0.0.0', '3.0.0'), + ], + ['CVE1'], + 0, + [ + ['text' => 'Found 1 ignored security vulnerability advisory affecting 1 package:'], + ['text' => 'Package: vendor1/package1'], + ['text' => 'CVE: CVE1'], + ['text' => 'Title: advisory1'], + ['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' => [ + [ + new Package('vendor1/package1', '3.0.0.0', '3.0.0'), + ], + ['CVE1' => 'A good reason'], + 0, + [ + ['text' => 'Found 1 ignored security vulnerability advisory affecting 1 package:'], + ['text' => 'Package: vendor1/package1'], + ['text' => 'CVE: CVE1'], + ['text' => 'Title: advisory1'], + ['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'], + ['text' => 'Ignore reason: A good reason'], + ] + ]; + yield 'ignore by advisory id' => [ + [ + new Package('vendor1/package2', '3.0.0.0', '3.0.0'), + ], + ['ID2'], + 0, + [ + ['text' => 'Found 1 ignored security vulnerability advisory affecting 1 package:'], + ['text' => 'Package: vendor1/package2'], + ['text' => 'CVE: '], + ['text' => 'Title: advisory2'], + ['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' => [ + [ + new Package('vendorx/packagex', '3.0.0.0', '3.0.0'), + ], + ['RemoteIDx'], + 0, + [ + ['text' => 'Found 1 ignored security vulnerability advisory affecting 1 package:'], + ['text' => 'Package: vendorx/packagex'], + ['text' => 'CVE: CVE5'], + ['text' => 'Title: advisory17'], + ['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' => [ + [ + new Package('vendor1/package1', '3.0.0.0', '3.0.0'), + ], + [], + 1, + [ + ['text' => 'Found 1 security vulnerability advisory affecting 1 package:'], + ['text' => 'Package: vendor1/package1'], + ['text' => 'CVE: CVE1'], + ['text' => 'Title: advisory1'], + ['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' => [ + [ + new Package('vendor3/package1', '3.0.0.0', '3.0.0'), + // RemoteIDx + new Package('vendorx/packagex', '3.0.0.0', '3.0.0'), + // ID3, ID6 + new Package('vendor2/package1', '3.0.0.0', '3.0.0'), + ], + ['RemoteIDx', 'ID3', 'ID6'], + 1, + [ + ['text' => 'Found 3 ignored security vulnerability advisories affecting 2 packages:'], + ['text' => 'Package: vendor2/package1'], + ['text' => 'CVE: CVE2'], + ['text' => 'Title: advisory3'], + ['text' => 'URL: https://advisory.example.com/advisory3'], + ['text' => 'Affected versions: >=3,<3.4.3|>=1,<2.5.6'], + ['text' => 'Reported at: 2022-05-25T13:21:00+00:00'], + ['text' => 'Ignore reason: None specified'], + ['text' => '--------'], + ['text' => 'Package: vendor2/package1'], + ['text' => 'CVE: CVE4'], + ['text' => 'Title: advisory6'], + ['text' => 'URL: https://advisory.example.com/advisory6'], + ['text' => 'Affected versions: >=3,<3.4.3|>=1,<2.5.6'], + ['text' => 'Reported at: 2015-05-25T13:21:00+00:00'], + ['text' => 'Ignore reason: None specified'], + ['text' => '--------'], + ['text' => 'Package: vendorx/packagex'], + ['text' => 'CVE: CVE5'], + ['text' => 'Title: advisory17'], + ['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'], + ['text' => 'Ignore reason: None specified'], + ['text' => 'Found 1 security vulnerability advisory affecting 1 package:'], + ['text' => 'Package: vendor3/package1'], + ['text' => 'CVE: CVE5'], + ['text' => 'Title: advisory7'], + ['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'], + ] + ]; + } - $ignoredIds = ['CVE1', 'ID2', 'RemoteIDx']; - + /** + * @dataProvider ignoredIdsProvider + * @phpstan-param array<\Composer\Package\Package> $packages + * @phpstan-param array|array $ignoredIds + * @phpstan-param 0|positive-int $exitCode + * @phpstan-param list $expectedOutput + */ + public function testAuditWithIgnore($packages, $ignoredIds, $exitCode, $expectedOutput): void + { $auditor = new Auditor(); $result = $auditor->audit($io = $this->getIOMock(), $this->getRepoSet(), $packages, Auditor::FORMAT_PLAIN, false, $ignoredIds); - $io->expects([ - ['text' => 'Found 1 security vulnerability advisory affecting 1 package:'], - ['text' => 'Package: vendor3/package1'], - ['text' => 'CVE: CVE5'], - ['text' => 'Title: advisory7'], - ['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'], - ], true); - $this->assertSame(1, $result); - - // without ignored IDs, we should get all 4 - $result = $auditor->audit($io, $this->getRepoSet(), $packages, Auditor::FORMAT_PLAIN, false); - $this->assertSame(4, $result); + $io->expects($expectedOutput, true); + $this->assertSame($exitCode, $result); } private function getRepoSet(): RepositorySet