1
0
Fork 0

Add audit.abandoned warnings for abandoned packages, fixes #11623 (#11639)

pull/11652/head
Jordi Boggiano 2023-09-14 11:30:09 +02:00 committed by GitHub
parent 3bc72f75cb
commit e3484c8581
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 222 additions and 37 deletions

View File

@ -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 ## use-parent-dir
When running Composer in a directory where there is no composer.json, if there When running Composer in a directory where there is no composer.json, if there

View File

@ -346,6 +346,10 @@
} }
} }
] ]
},
"abandoned": {
"enum": ["ignore", "report", "fail"],
"description": "Whether abandoned packages should be ignored, reported as problems or cause an audit failure."
} }
} }
}, },

View File

@ -15,8 +15,10 @@ namespace Composer\Advisory;
use Composer\IO\ConsoleIO; use Composer\IO\ConsoleIO;
use Composer\IO\IOInterface; use Composer\IO\IOInterface;
use Composer\Json\JsonFile; use Composer\Json\JsonFile;
use Composer\Package\CompletePackageInterface;
use Composer\Package\PackageInterface; use Composer\Package\PackageInterface;
use Composer\Repository\RepositorySet; use Composer\Repository\RepositorySet;
use Composer\Util\PackageInfo;
use InvalidArgumentException; use InvalidArgumentException;
use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Formatter\OutputFormatter;
@ -40,17 +42,26 @@ class Auditor
self::FORMAT_SUMMARY, self::FORMAT_SUMMARY,
]; ];
public const ABANDONED_IGNORE = 'ignore';
public const ABANDONED_REPORT = 'report';
public const ABANDONED_FAIL = 'fail';
/** /**
* @param PackageInterface[] $packages * @param PackageInterface[] $packages
* @param self::FORMAT_* $format The format that will be used to output audit results. * @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 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 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 * @return int Amount of packages with vulnerabilities found
* @throws InvalidArgumentException If no packages are passed in * @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('<warning>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.</warning>');
}
$allAdvisories = $repoSet->getMatchingSecurityAdvisories($packages, $format === self::FORMAT_SUMMARY); $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 // 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 // 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); ['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) { if (self::FORMAT_JSON === $format) {
$json = ['advisories' => $advisories]; $json = ['advisories' => $advisories];
if ($ignoredAdvisories !== []) { if ($ignoredAdvisories !== []) {
$json['ignored-advisories'] = $ignoredAdvisories; $json['ignored-advisories'] = $ignoredAdvisories;
} }
$json['abandoned'] = $abandonedPackages;
$io->write(JsonFile::encode($json)); $io->write(JsonFile::encode($json));
return count($advisories); return count($advisories) + $abandonedCount;
} }
$errorOrWarn = $warningOnly ? 'warning' : 'error'; $errorOrWarn = $warningOnly ? 'warning' : 'error';
@ -91,13 +114,26 @@ class Auditor
if ($format === self::FORMAT_SUMMARY) { if ($format === self::FORMAT_SUMMARY) {
$io->writeError('Run "composer audit" for a full list of advisories.'); $io->writeError('Run "composer audit" for a full list of advisories.');
} }
} else {
return $affectedPackagesCount; $io->writeError('<info>No security vulnerability advisories found.</info>');
} }
$io->writeError('<info>No security vulnerability advisories found</info>'); if (count($abandonedPackages) > 0 && $format !== self::FORMAT_SUMMARY) {
$this->outputAbandonedPackages($io, $abandonedPackages, $format);
}
return 0; return $affectedPackagesCount + $abandonedCount;
}
/**
* @param array<PackageInterface> $packages
* @return array<PackageInterface>
*/
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); $io->writeError($error);
} }
/**
* @param array<PackageInterface> $packages
* @param self::FORMAT_PLAIN|self::FORMAT_TABLE $format
*/
private function outputAbandonedPackages(IOInterface $io, array $packages, string $format): void
{
$io->writeError(sprintf('<error>Found %d abandoned package%s:</error>', 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 ? '<href=' . OutputFormatter::escape($packageUrl) . '>' . $package->getPrettyName() . '</>' : $package->getPrettyName();
}
private function getCVE(SecurityAdvisory $advisory): string private function getCVE(SecurityAdvisory $advisory): string
{ {
if ($advisory->cve === null) { if ($advisory->cve === null) {

View File

@ -63,7 +63,9 @@ EOT
$repoSet->addRepository($repo); $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));
} }
/** /**

View File

@ -12,6 +12,7 @@
namespace Composer\Command; namespace Composer\Command;
use Composer\Advisory\Auditor;
use Composer\Pcre\Preg; use Composer\Pcre\Preg;
use Composer\Util\Filesystem; use Composer\Util\Filesystem;
use Composer\Util\Platform; use Composer\Util\Platform;
@ -512,6 +513,14 @@ EOT
return $val !== 'false' && (bool) $val; 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 = [ $multiConfigValues = [
'github-protocols' => [ 'github-protocols' => [

View File

@ -12,6 +12,7 @@
namespace Composer; namespace Composer;
use Composer\Advisory\Auditor;
use Composer\Config\ConfigSourceInterface; use Composer\Config\ConfigSourceInterface;
use Composer\Downloader\TransportException; use Composer\Downloader\TransportException;
use Composer\IO\IOInterface; use Composer\IO\IOInterface;
@ -37,7 +38,7 @@ class Config
'allow-plugins' => [], 'allow-plugins' => [],
'use-parent-dir' => 'prompt', 'use-parent-dir' => 'prompt',
'preferred-install' => 'dist', 'preferred-install' => 'dist',
'audit' => ['ignore' => []], 'audit' => ['ignore' => [], 'abandoned' => 'default'], // TODO in 2.7 switch to ABANDONED_FAIL
'notify-on-install' => true, 'notify-on-install' => true,
'github-protocols' => ['https', 'ssh', 'git'], 'github-protocols' => ['https', 'ssh', 'git'],
'gitlab-protocol' => null, 'gitlab-protocol' => null,
@ -210,7 +211,7 @@ class Config
} }
} elseif ('audit' === $key) { } elseif ('audit' === $key) {
$currentIgnores = $this->config['audit']['ignore']; $currentIgnores = $this->config['audit']['ignore'];
$this->config[$key] = $val; $this->config[$key] = array_merge($this->config['audit'], $val);
$this->setSourceOfConfigValue($val, $key, $source); $this->setSourceOfConfigValue($val, $key, $source);
$this->config['audit']['ignore'] = array_merge($currentIgnores, $val['ignore'] ?? []); $this->config['audit']['ignore'] = array_merge($currentIgnores, $val['ignore'] ?? []);
} else { } else {

View File

@ -404,7 +404,9 @@ class Installer
$repoSet->addRepository($repo); $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) { } catch (TransportException $e) {
$this->io->error('Failed to audit '.$target.' packages.'); $this->io->error('Failed to audit '.$target.' packages.');
if ($this->io->isVerbose()) { if ($this->io->isVerbose()) {

View File

@ -14,7 +14,9 @@ namespace Composer\Test\Advisory;
use Composer\Advisory\PartialSecurityAdvisory; use Composer\Advisory\PartialSecurityAdvisory;
use Composer\Advisory\SecurityAdvisory; use Composer\Advisory\SecurityAdvisory;
use Composer\IO\BufferIO;
use Composer\IO\NullIO; use Composer\IO\NullIO;
use Composer\Package\CompletePackage;
use Composer\Package\Package; use Composer\Package\Package;
use Composer\Package\Version\VersionParser; use Composer\Package\Version\VersionParser;
use Composer\Repository\ComposerRepository; use Composer\Repository\ComposerRepository;
@ -27,33 +29,98 @@ class AuditorTest extends TestCase
{ {
public static function auditProvider() public static function auditProvider()
{ {
return [ yield 'Test no advisories returns 0' => [
// Test no advisories returns 0 'data' => [
[ 'packages' => [
'data' => [ new Package('vendor1/package2', '9.0.0', '9.0.0'),
'packages' => [ new Package('vendor1/package1', '9.0.0', '9.0.0'),
new Package('vendor1/package2', '9.0.0', '9.0.0'), new Package('vendor3/package1', '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,
], ],
'expected' => 0, 'warningOnly' => true,
'message' => 'Test no advisories returns 0',
], ],
// Test with advisories returns 1 'expected' => 0,
[ 'output' => 'No security vulnerability advisories found.',
'data' => [ ];
'packages' => [
new Package('vendor1/package2', '9.0.0', '9.0.0'), yield 'Test with advisories returns 1' => [
new Package('vendor1/package1', '8.2.1', '8.2.1'), 'data' => [
new Package('vendor3/package1', '9.0.0', '9.0.0'), 'packages' => [
], new Package('vendor1/package2', '9.0.0', '9.0.0'),
'warningOnly' => true, new Package('vendor1/package1', '8.2.1', '8.2.1'),
new Package('vendor3/package1', '9.0.0', '9.0.0'),
], ],
'expected' => 1, 'warningOnly' => true,
'message' => 'Test with advisories returns 1',
], ],
'expected' => 1,
'output' => '<warning>Found 2 security vulnerability advisories affecting 1 package:</warning>
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 * @dataProvider auditProvider
* @phpstan-param array<string, mixed> $data * @phpstan-param array<string, mixed> $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) { if (count($data['packages']) === 0) {
$this->expectException(InvalidArgumentException::class); $this->expectException(InvalidArgumentException::class);
} }
$auditor = new Auditor(); $auditor = new Auditor();
$result = $auditor->audit(new NullIO(), $this->getRepoSet(), $data['packages'], Auditor::FORMAT_PLAIN, $data['warningOnly']); $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, $message); $this->assertSame($expected, $result);
$this->assertSame($output, trim(str_replace("\r", '', $io->getOutput())));
} }
public function ignoredIdsProvider(): \Generator { public function ignoredIdsProvider(): \Generator {
@ -322,7 +390,7 @@ class AuditorTest extends TestCase
'remoteId' => 'RemoteID3', 'remoteId' => 'RemoteID3',
], ],
], ],
'reportedAt' => '', 'reportedAt' => '2022-05-25 13:21:00',
'composerRepository' => 'https://packagist.org', 'composerRepository' => 'https://packagist.org',
], ],
], ],