parent
3bc72f75cb
commit
e3484c8581
|
@ -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
|
||||
|
|
|
@ -346,6 +346,10 @@
|
|||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"abandoned": {
|
||||
"enum": ["ignore", "report", "fail"],
|
||||
"description": "Whether abandoned packages should be ignored, reported as problems or cause an audit failure."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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('<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);
|
||||
// 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('<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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
{
|
||||
if ($advisory->cve === null) {
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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' => [
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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,9 +29,7 @@ class AuditorTest extends TestCase
|
|||
{
|
||||
public static function auditProvider()
|
||||
{
|
||||
return [
|
||||
// Test no advisories returns 0
|
||||
[
|
||||
yield 'Test no advisories returns 0' => [
|
||||
'data' => [
|
||||
'packages' => [
|
||||
new Package('vendor1/package2', '9.0.0', '9.0.0'),
|
||||
|
@ -39,10 +39,10 @@ class AuditorTest extends TestCase
|
|||
'warningOnly' => true,
|
||||
],
|
||||
'expected' => 0,
|
||||
'message' => 'Test no advisories returns 0',
|
||||
],
|
||||
// Test with advisories returns 1
|
||||
[
|
||||
'output' => 'No security vulnerability advisories found.',
|
||||
];
|
||||
|
||||
yield 'Test with advisories returns 1' => [
|
||||
'data' => [
|
||||
'packages' => [
|
||||
new Package('vendor1/package2', '9.0.0', '9.0.0'),
|
||||
|
@ -52,8 +52,75 @@ class AuditorTest extends TestCase
|
|||
'warningOnly' => true,
|
||||
],
|
||||
'expected' => 1,
|
||||
'message' => 'Test with advisories returns 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
|
||||
* @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) {
|
||||
$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',
|
||||
],
|
||||
],
|
||||
|
|
Loading…
Reference in New Issue