diff --git a/doc/06-config.md b/doc/06-config.md index 61391e276..ac0cdc0e6 100644 --- a/doc/06-config.md +++ b/doc/06-config.md @@ -135,6 +135,14 @@ or } ``` +### abandoned + +Defaults to `report` in Composer 2.6, and defaults to `fail` from Composer 2.7 on. Defines whether the audit command reports abandoned packages or not, this has three possible values: + +- `ignore` means the audit command does not consider abandoned packages at all. +- `report` means abandoned packages are reported as an error but do not cause the command to exit with a non-zero code. +- `fail` means abandoned packages will cause audits to fail with a non-zero code. + ## 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 fd469d51c..47c087b3e 100644 --- a/res/composer-schema.json +++ b/res/composer-schema.json @@ -346,6 +346,10 @@ } } ] + }, + "abandoned": { + "enum": ["ignore", "report", "fail"], + "description": "Whether abandoned packages should be ignored, reported as problems or cause an audit failure." } } }, diff --git a/src/Composer/Advisory/Auditor.php b/src/Composer/Advisory/Auditor.php index 5a8397c64..7d55be5dc 100644 --- a/src/Composer/Advisory/Auditor.php +++ b/src/Composer/Advisory/Auditor.php @@ -15,8 +15,10 @@ namespace Composer\Advisory; use Composer\IO\ConsoleIO; use Composer\IO\IOInterface; use Composer\Json\JsonFile; +use Composer\Package\CompletePackageInterface; use Composer\Package\PackageInterface; use Composer\Repository\RepositorySet; +use Composer\Util\PackageInfo; use InvalidArgumentException; use Symfony\Component\Console\Formatter\OutputFormatter; @@ -40,17 +42,26 @@ class Auditor self::FORMAT_SUMMARY, ]; + public const ABANDONED_IGNORE = 'ignore'; + public const ABANDONED_REPORT = 'report'; + public const ABANDONED_FAIL = 'fail'; + /** * @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[] $ignoreList List of advisory IDs, remote IDs or CVE IDs that reported but not listed as vulnerabilities. + * @param self::ABANDONED_* $abandoned * * @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 = []): int + public function audit(IOInterface $io, RepositorySet $repoSet, array $packages, string $format, bool $warningOnly = true, array $ignoreList = [], string $abandoned = self::ABANDONED_REPORT): int { + if ($abandoned === 'default' && $format !== self::FORMAT_SUMMARY) { + $io->writeError('The new audit.abandoned setting (currently defaulting to "report" will default to "fail" in Composer 2.7, make sure to set it to "report" or "ignore" explicitly by then if you do not want this.'); + } + $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 @@ -59,15 +70,27 @@ class Auditor } ['advisories' => $advisories, 'ignoredAdvisories' => $ignoredAdvisories] = $this->processAdvisories($allAdvisories, $ignoreList); + $abandonedCount = 0; + $affectedPackagesCount = 0; + if ($abandoned === self::ABANDONED_IGNORE) { + $abandonedPackages = []; + } else { + $abandonedPackages = $this->filterAbandonedPackages($packages); + if ($abandoned === self::ABANDONED_FAIL) { + $abandonedCount = count($abandonedPackages); + } + } + if (self::FORMAT_JSON === $format) { $json = ['advisories' => $advisories]; if ($ignoredAdvisories !== []) { $json['ignored-advisories'] = $ignoredAdvisories; } + $json['abandoned'] = $abandonedPackages; $io->write(JsonFile::encode($json)); - return count($advisories); + return count($advisories) + $abandonedCount; } $errorOrWarn = $warningOnly ? 'warning' : 'error'; @@ -91,13 +114,26 @@ class Auditor if ($format === self::FORMAT_SUMMARY) { $io->writeError('Run "composer audit" for a full list of advisories.'); } - - return $affectedPackagesCount; + } else { + $io->writeError('No security vulnerability advisories found.'); } - $io->writeError('No security vulnerability advisories found'); + if (count($abandonedPackages) > 0 && $format !== self::FORMAT_SUMMARY) { + $this->outputAbandonedPackages($io, $abandonedPackages, $format); + } - return 0; + return $affectedPackagesCount + $abandonedCount; + } + + /** + * @param array $packages + * @return array + */ + private function filterAbandonedPackages(array $packages): array + { + return array_filter($packages, function (PackageInterface $pkg) { + return $pkg instanceof CompletePackageInterface && $pkg->isAbandoned(); + }); } /** @@ -268,6 +304,61 @@ class Auditor $io->writeError($error); } + /** + * @param array $packages + * @param self::FORMAT_PLAIN|self::FORMAT_TABLE $format + */ + private function outputAbandonedPackages(IOInterface $io, array $packages, string $format): void + { + $io->writeError(sprintf('Found %d abandoned package%s:', count($packages), count($packages) > 1 ? 's' : '')); + + if ($format === self::FORMAT_PLAIN) { + foreach ($packages as $pkg) { + if (!$pkg instanceof CompletePackageInterface) { + continue; + } + + $replacement = $pkg->getReplacementPackage() !== null + ? 'Use '.$pkg->getReplacementPackage().' instead' + : 'No replacement was suggested'; + $io->writeError(sprintf( + '%s is abandoned. %s.', + $this->getPackageNameWithLink($pkg), + $replacement + )); + } + + return; + } + + if (!($io instanceof ConsoleIO)) { + throw new InvalidArgumentException('Cannot use table format with ' . get_class($io)); + } + + $table = $io->getTable() + ->setHeaders(['Abandoned Package', 'Suggested Replacement']) + ->setColumnWidth(1, 80) + ->setColumnMaxWidth(1, 80); + + foreach ($packages as $pkg) { + if (!$pkg instanceof CompletePackageInterface) { + continue; + } + + $replacement = $pkg->getReplacementPackage() !== null ? $pkg->getReplacementPackage() : 'none'; + $table->addRow([$this->getPackageNameWithLink($pkg), $replacement]); + } + + $table->render(); + } + + private function getPackageNameWithLink(PackageInterface $package): string + { + $packageUrl = PackageInfo::getViewSourceOrHomepageUrl($package); + + return $packageUrl !== null ? '' . $package->getPrettyName() . '' : $package->getPrettyName(); + } + private function getCVE(SecurityAdvisory $advisory): string { if ($advisory->cve === null) { diff --git a/src/Composer/Command/AuditCommand.php b/src/Composer/Command/AuditCommand.php index 3c58d7feb..3788b8e37 100644 --- a/src/Composer/Command/AuditCommand.php +++ b/src/Composer/Command/AuditCommand.php @@ -63,7 +63,9 @@ EOT $repoSet->addRepository($repo); } - return min(255, $auditor->audit($this->getIO(), $repoSet, $packages, $this->getAuditFormat($input, 'format'), false, $composer->getConfig()->get('audit')['ignore'] ?? [])); + $auditConfig = $composer->getConfig()->get('audit'); + + return min(255, $auditor->audit($this->getIO(), $repoSet, $packages, $this->getAuditFormat($input, 'format'), false, $auditConfig['ignore'] ?? [], $auditConfig['abandoned'] ?? Auditor::ABANDONED_REPORT)); } /** diff --git a/src/Composer/Command/ConfigCommand.php b/src/Composer/Command/ConfigCommand.php index 1a1e0bb48..cbdc174bc 100644 --- a/src/Composer/Command/ConfigCommand.php +++ b/src/Composer/Command/ConfigCommand.php @@ -12,6 +12,7 @@ namespace Composer\Command; +use Composer\Advisory\Auditor; use Composer\Pcre\Preg; use Composer\Util\Filesystem; use Composer\Util\Platform; @@ -512,6 +513,14 @@ EOT return $val !== 'false' && (bool) $val; }, ], + 'audit.abandoned' => [ + static function ($val): bool { + return in_array($val, [Auditor::ABANDONED_IGNORE, Auditor::ABANDONED_REPORT, Auditor::ABANDONED_FAIL], true); + }, + static function ($val) { + return $val; + }, + ], ]; $multiConfigValues = [ 'github-protocols' => [ diff --git a/src/Composer/Config.php b/src/Composer/Config.php index 0c11ab505..1ad622692 100644 --- a/src/Composer/Config.php +++ b/src/Composer/Config.php @@ -12,6 +12,7 @@ namespace Composer; +use Composer\Advisory\Auditor; use Composer\Config\ConfigSourceInterface; use Composer\Downloader\TransportException; use Composer\IO\IOInterface; @@ -37,7 +38,7 @@ class Config 'allow-plugins' => [], 'use-parent-dir' => 'prompt', 'preferred-install' => 'dist', - 'audit' => ['ignore' => []], + 'audit' => ['ignore' => [], 'abandoned' => 'default'], // TODO in 2.7 switch to ABANDONED_FAIL 'notify-on-install' => true, 'github-protocols' => ['https', 'ssh', 'git'], 'gitlab-protocol' => null, @@ -210,7 +211,7 @@ class Config } } elseif ('audit' === $key) { $currentIgnores = $this->config['audit']['ignore']; - $this->config[$key] = $val; + $this->config[$key] = array_merge($this->config['audit'], $val); $this->setSourceOfConfigValue($val, $key, $source); $this->config['audit']['ignore'] = array_merge($currentIgnores, $val['ignore'] ?? []); } else { diff --git a/src/Composer/Installer.php b/src/Composer/Installer.php index 3fc3bcf83..ebf8c76bf 100644 --- a/src/Composer/Installer.php +++ b/src/Composer/Installer.php @@ -404,7 +404,9 @@ class Installer $repoSet->addRepository($repo); } - return $auditor->audit($this->io, $repoSet, $packages, $this->auditFormat, true, $this->config->get('audit')['ignore'] ?? []) > 0 && $this->errorOnAudit ? self::ERROR_AUDIT_FAILED : 0; + $auditConfig = $this->config->get('audit'); + + return $auditor->audit($this->io, $repoSet, $packages, $this->auditFormat, true, $auditConfig['ignore'] ?? [], $auditConfig['abandoned'] ?? Auditor::ABANDONED_REPORT) > 0 && $this->errorOnAudit ? 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 84b8693cf..e42c95e68 100644 --- a/tests/Composer/Test/Advisory/AuditorTest.php +++ b/tests/Composer/Test/Advisory/AuditorTest.php @@ -14,7 +14,9 @@ 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; use Composer\Repository\ComposerRepository; @@ -27,33 +29,98 @@ class AuditorTest extends TestCase { public static function auditProvider() { - return [ - // Test no advisories returns 0 - [ - 'data' => [ - 'packages' => [ - new Package('vendor1/package2', '9.0.0', '9.0.0'), - new Package('vendor1/package1', '9.0.0', '9.0.0'), - new Package('vendor3/package1', '9.0.0', '9.0.0'), - ], - 'warningOnly' => true, + yield 'Test no advisories returns 0' => [ + 'data' => [ + 'packages' => [ + new Package('vendor1/package2', '9.0.0', '9.0.0'), + new Package('vendor1/package1', '9.0.0', '9.0.0'), + new Package('vendor3/package1', '9.0.0', '9.0.0'), ], - 'expected' => 0, - 'message' => 'Test no advisories returns 0', + 'warningOnly' => true, ], - // Test with advisories returns 1 - [ - 'data' => [ - 'packages' => [ - new Package('vendor1/package2', '9.0.0', '9.0.0'), - new Package('vendor1/package1', '8.2.1', '8.2.1'), - new Package('vendor3/package1', '9.0.0', '9.0.0'), - ], - 'warningOnly' => true, + 'expected' => 0, + 'output' => 'No security vulnerability advisories found.', + ]; + + yield 'Test with advisories returns 1' => [ + 'data' => [ + 'packages' => [ + new Package('vendor1/package2', '9.0.0', '9.0.0'), + new Package('vendor1/package1', '8.2.1', '8.2.1'), + new Package('vendor3/package1', '9.0.0', '9.0.0'), ], - 'expected' => 1, - 'message' => 'Test with advisories returns 1', + 'warningOnly' => true, ], + 'expected' => 1, + 'output' => 'Found 2 security vulnerability advisories affecting 1 package: +Package: vendor1/package1 +CVE: CVE3 +Title: advisory4 +URL: https://advisory.example.com/advisory4 +Affected versions: >=8,<8.2.2|>=1,<2.5.6 +Reported at: 2022-05-25T13:21:00+00:00 +-------- +Package: vendor1/package1 +CVE: '.' +Title: advisory5 +URL: https://advisory.example.com/advisory5 +Affected versions: >=8,<8.2.2|>=1,<2.5.6 +Reported at: 2022-05-25T13:21:00+00:00', + ]; + + $abandonedWithReplacement = new CompletePackage('vendor/abandoned', '1.0.0', '1.0.0'); + $abandonedWithReplacement->setAbandoned('foo/bar'); + $abandonedNoReplacement = new CompletePackage('vendor/abandoned2', '1.0.0', '1.0.0'); + $abandonedNoReplacement->setAbandoned(true); + + yield 'abandoned packages ignored' => [ + 'data' => [ + 'packages' => [ + $abandonedWithReplacement, + $abandonedNoReplacement, + ], + 'warningOnly' => false, + 'abandoned' => Auditor::ABANDONED_IGNORE, + ], + 'expected' => 0, + 'output' => 'No security vulnerability advisories found.', + ]; + + yield 'abandoned packages reported only' => [ + 'data' => [ + 'packages' => [ + $abandonedWithReplacement, + $abandonedNoReplacement, + ], + 'warningOnly' => true, + 'abandoned' => Auditor::ABANDONED_REPORT, + ], + 'expected' => 0, + 'output' => 'No security vulnerability advisories found. +Found 2 abandoned packages: +vendor/abandoned is abandoned. Use foo/bar instead. +vendor/abandoned2 is abandoned. No replacement was suggested.', + ]; + + yield 'abandoned packages fails' => [ + 'data' => [ + 'packages' => [ + $abandonedWithReplacement, + $abandonedNoReplacement, + ], + 'warningOnly' => false, + 'abandoned' => Auditor::ABANDONED_FAIL, + 'format' => Auditor::FORMAT_TABLE, + ], + 'expected' => 2, + 'output' => 'No security vulnerability advisories found. +Found 2 abandoned packages: ++-------------------+----------------------------------------------------------------------------------+ +| Abandoned Package | Suggested Replacement | ++-------------------+----------------------------------------------------------------------------------+ +| vendor/abandoned | foo/bar | +| vendor/abandoned2 | none | ++-------------------+----------------------------------------------------------------------------------+', ]; } @@ -61,14 +128,15 @@ class AuditorTest extends TestCase * @dataProvider auditProvider * @phpstan-param array $data */ - public function testAudit(array $data, int $expected, string $message): void + public function testAudit(array $data, int $expected, string $output): void { if (count($data['packages']) === 0) { $this->expectException(InvalidArgumentException::class); } $auditor = new Auditor(); - $result = $auditor->audit(new NullIO(), $this->getRepoSet(), $data['packages'], Auditor::FORMAT_PLAIN, $data['warningOnly']); - $this->assertSame($expected, $result, $message); + $result = $auditor->audit($io = new BufferIO(), $this->getRepoSet(), $data['packages'], $data['format'] ?? Auditor::FORMAT_PLAIN, $data['warningOnly'], [], $data['abandoned'] ?? Auditor::ABANDONED_IGNORE); + $this->assertSame($expected, $result); + $this->assertSame($output, trim(str_replace("\r", '', $io->getOutput()))); } public function ignoredIdsProvider(): \Generator { @@ -322,7 +390,7 @@ class AuditorTest extends TestCase 'remoteId' => 'RemoteID3', ], ], - 'reportedAt' => '', + 'reportedAt' => '2022-05-25 13:21:00', 'composerRepository' => 'https://packagist.org', ], ],