diff --git a/doc/06-config.md b/doc/06-config.md index 70caf4432..615fd0d28 100644 --- a/doc/06-config.md +++ b/doc/06-config.md @@ -101,6 +101,24 @@ optionally be an object with package name patterns for keys for more granular in > configuration in global and package configurations the string notation > is translated to a `*` package pattern. +## audit + +Security audit configuration options + +### ignored + +A set of advisory ids, remote ids or CVE ids that should be ignored and not reported as part of an audit. + +```json +{ + "config": { + "audit": { + "ignored": ["CVE-1234", "GHSA-xx", "PKSA-yy"] + } + } +} +``` + ## use-parent-dir When running Composer in a directory where there is no composer.json, if there diff --git a/res/composer-schema.json b/res/composer-schema.json index 8e8c65691..9757c8950 100644 --- a/res/composer-schema.json +++ b/res/composer-schema.json @@ -325,6 +325,19 @@ "type": ["string"] } }, + "audit": { + "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" + } + } + } + }, "notify-on-install": { "type": "boolean", "description": "Composer allows repositories to define a notification URL, so that they get notified whenever a package from that repository is installed. This option allows you to disable that behaviour, defaults to true." diff --git a/src/Composer/Advisory/Auditor.php b/src/Composer/Advisory/Auditor.php index 9ddb5b04b..c48f54d47 100644 --- a/src/Composer/Advisory/Auditor.php +++ b/src/Composer/Advisory/Auditor.php @@ -44,12 +44,18 @@ 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 * @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): int + public function audit(IOInterface $io, RepositorySet $repoSet, array $packages, string $format, bool $warningOnly = true, array $ignoredIds = []): int { $advisories = $repoSet->getMatchingSecurityAdvisories($packages, $format === self::FORMAT_SUMMARY); + + if (\count($ignoredIds) > 0) { + $advisories = $this->filterIgnoredAdvisories($advisories, $ignoredIds); + } + if (self::FORMAT_JSON === $format) { $io->write(JsonFile::encode(['advisories' => $advisories])); @@ -73,6 +79,40 @@ class Auditor return 0; } + /** + * @phpstan-param array> $advisories + * @param array $ignoredIds + * @phpstan-return array> + */ + private function filterIgnoredAdvisories(array $advisories, array $ignoredIds): array + { + foreach ($advisories as $package => $pkgAdvisories) { + $advisories[$package] = array_filter($pkgAdvisories, static function (PartialSecurityAdvisory $advisory) use ($ignoredIds) { + if (in_array($advisory->advisoryId, $ignoredIds, true)) { + return false; + } + if ($advisory instanceof SecurityAdvisory) { + if (in_array($advisory->cve, $ignoredIds, true)) { + return false; + } + + foreach ($advisory->sources as $source) { + if (in_array($source['remoteId'], $ignoredIds, true)) { + return false; + } + } + } + + return true; + }); + if (\count($advisories[$package]) === 0) { + unset($advisories[$package]); + } + } + + return $advisories; + } + /** * @param array> $advisories * @return array{int, int} Count of affected packages and total count of advisories diff --git a/src/Composer/Command/AuditCommand.php b/src/Composer/Command/AuditCommand.php index 5a91a1743..ed97bbc94 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)); + return min(255, $auditor->audit($this->getIO(), $repoSet, $packages, $this->getAuditFormat($input, 'format'), false, $composer->getConfig()->get('audit')['ignored'] ?? [])); } /** diff --git a/src/Composer/Command/ConfigCommand.php b/src/Composer/Command/ConfigCommand.php index bef163b3d..1a1e0bb48 100644 --- a/src/Composer/Command/ConfigCommand.php +++ b/src/Composer/Command/ConfigCommand.php @@ -556,8 +556,27 @@ EOT return $vals; }, ], + 'audit.ignore' => [ + static function ($vals) { + if (!is_array($vals)) { + return 'array expected'; + } + + return true; + }, + static function ($vals) { + return $vals; + }, + ], ]; + // allow unsetting audit config entirely + if ($input->getOption('unset') && $settingKey === 'audit') { + $this->configSource->removeConfigSetting($settingKey); + + return 0; + } + if ($input->getOption('unset') && (isset($uniqueConfigValues[$settingKey]) || isset($multiConfigValues[$settingKey]))) { if ($settingKey === 'disable-tls' && $this->config->get('disable-tls')) { $this->getIO()->writeError('You are now running Composer with SSL/TLS protection enabled.'); diff --git a/src/Composer/Config.php b/src/Composer/Config.php index 4555203f1..c5e1f355d 100644 --- a/src/Composer/Config.php +++ b/src/Composer/Config.php @@ -37,6 +37,7 @@ class Config 'allow-plugins' => [], 'use-parent-dir' => 'prompt', 'preferred-install' => 'dist', + 'audit' => ['ignored' => []], 'notify-on-install' => true, 'github-protocols' => ['https', 'ssh', 'git'], 'gitlab-protocol' => null, @@ -207,6 +208,11 @@ class Config $this->config[$key] = $val; $this->setSourceOfConfigValue($val, $key, $source); } + } elseif ('audit' === $key) { + $currentIgnores = $this->config['audit']['ignored']; + $this->config[$key] = $val; + $this->setSourceOfConfigValue($val, $key, $source); + $this->config['audit']['ignored'] = array_merge($currentIgnores, $val['ignored']); } else { $this->config[$key] = $val; $this->setSourceOfConfigValue($val, $key, $source); diff --git a/src/Composer/Installer.php b/src/Composer/Installer.php index 205648f03..310507a53 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) > 0 ? self::ERROR_AUDIT_FAILED : 0; + return $auditor->audit($this->io, $repoSet, $packages, $this->auditFormat, true, $this->config->get('audit')['ignored'] ?? []) > 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 b4a229e9a..ca87e8e9a 100644 --- a/tests/Composer/Test/Advisory/AuditorTest.php +++ b/tests/Composer/Test/Advisory/AuditorTest.php @@ -14,6 +14,7 @@ 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; @@ -71,6 +72,35 @@ 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'), + ]; + + $ignoredIds = ['CVE1', 'ID2', 'RemoteIDx']; + + $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); + } + private function getRepoSet(): RepositorySet { $repo = $this @@ -160,7 +190,7 @@ class AuditorTest extends TestCase 'sources' => [ [ 'name' => 'source2', - 'remoteId' => 'RemoteID2', + 'remoteId' => 'RemoteID4', ], ], 'reportedAt' => '2022-05-25 13:21:00', @@ -205,14 +235,14 @@ class AuditorTest extends TestCase [ 'advisoryId' => 'IDx', 'packageName' => 'vendorx/packagex', - 'title' => 'advisory7', - 'link' => 'https://advisory.example.com/advisory7', + 'title' => 'advisory17', + 'link' => 'https://advisory.example.com/advisory17', 'cve' => 'CVE5', 'affectedVersions' => '>=3,<3.4.3|>=1,<2.5.6', 'sources' => [ [ 'name' => 'source2', - 'remoteId' => 'RemoteID4', + 'remoteId' => 'RemoteIDx', ], ], 'reportedAt' => '2015-05-25 13:21:00',