Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Composer update: filter packages with security advisories from pool #11956

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/Composer/Advisory/AuditConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php declare(strict_types=1);

/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Composer\Advisory;

use Composer\Config;

/**
* @readonly
*/
class AuditConfig
{
/**
* @var array<string>|array<string,string> List of advisory IDs, remote IDs or CVE IDs that reported but not listed as vulnerabilities.
*/
public $ignoreList;

/**
* @var Auditor::ABANDONED_*
*/
public $abandoned;

/**
* @var bool Should insecure versions be blocked during a composer update/required command
*/
public $blockInsecure;

/**
* @var bool Should abandoned packages be blocked during a composer update/required command
*/
public $blockAbandoned;

/**
* @var bool Should blocking flags also block a composer install command
*/
public $blockInstall;

/**
* @param array<string>|array<string,string> $ignoreList
* @param Auditor::ABANDONED_* $abandoned
*/
public function __construct(array $ignoreList, string $abandoned, bool $blockInsecure, bool $blockAbandoned, bool $blockInstall)
{
$this->ignoreList = $ignoreList;
$this->abandoned = $abandoned;
$this->blockInsecure = $blockInsecure;
$this->blockAbandoned = $blockAbandoned;
$this->blockInstall = $blockInstall;
}

public static function fromConfig(Config $config): self
{
$auditConfig = $config->get('audit');

return new self(
$auditConfig['ignore'] ?? [],
$auditConfig['abandoned'] ?? Auditor::ABANDONED_FAIL,
(bool) ($auditConfig['block-insecure'] ?? false),
(bool) ($auditConfig['block-abandoned'] ?? false),
(bool) ($auditConfig['block-install'] ?? false)
);
}
}
2 changes: 1 addition & 1 deletion src/Composer/Advisory/Auditor.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ private function filterAbandonedPackages(array $packages): array
* @param array<string>|array<string,string> $ignoreList List of advisory IDs, remote IDs or CVE IDs that reported but not listed as vulnerabilities.
* @phpstan-return array{advisories: array<string, array<PartialSecurityAdvisory|SecurityAdvisory>>, ignoredAdvisories: array<string, array<PartialSecurityAdvisory|SecurityAdvisory>>}
*/
private function processAdvisories(array $allAdvisories, array $ignoreList): array
public function processAdvisories(array $allAdvisories, array $ignoreList): array
{
if ($ignoreList === []) {
return ['advisories' => $allAdvisories, 'ignoredAdvisories' => []];
Expand Down
5 changes: 3 additions & 2 deletions src/Composer/Command/AuditCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

namespace Composer\Command;

use Composer\Advisory\AuditConfig;
use Composer\Composer;
use Composer\Repository\RepositorySet;
use Composer\Repository\RepositoryUtils;
Expand Down Expand Up @@ -63,9 +64,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$repoSet->addRepository($repo);
}

$auditConfig = $composer->getConfig()->get('audit');
$auditConfig = AuditConfig::fromConfig($composer->getConfig());

return min(255, $auditor->audit($this->getIO(), $repoSet, $packages, $this->getAuditFormat($input, 'format'), false, $auditConfig['ignore'] ?? [], $auditConfig['abandoned'] ?? Auditor::ABANDONED_FAIL));
return min(255, $auditor->audit($this->getIO(), $repoSet, $packages, $this->getAuditFormat($input, 'format'), false, $auditConfig->ignoreList, $auditConfig->abandoned));
}

/**
Expand Down
50 changes: 48 additions & 2 deletions src/Composer/DependencyResolver/Pool.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,20 +40,28 @@ class Pool implements \Countable
protected $removedVersions = [];
/** @var array<string, array<string, string>> Map of package object hash => removed normalized versions => removed pretty version */
protected $removedVersionsByPackage = [];
/** @var array<string, array<string, string>> Map of package name => normalized version => pretty version */
private $securityRemovedVersions = [];
/** @var array<string, array<string, string>> Map of package name => normalized version => pretty version */
private $abandonedRemovedVersions = [];

/**
* @param BasePackage[] $packages
* @param BasePackage[] $unacceptableFixedOrLockedPackages
* @param array<string, array<string, string>> $removedVersions
* @param array<string, array<string, string>> $removedVersionsByPackage
*/
public function __construct(array $packages = [], array $unacceptableFixedOrLockedPackages = [], array $removedVersions = [], array $removedVersionsByPackage = [])
* @param array<string, array<string, string>> $securityRemoveVersions
* @param array<string, array<string, string>> $abandonedRemovedVersions
*/
public function __construct(array $packages = [], array $unacceptableFixedOrLockedPackages = [], array $removedVersions = [], array $removedVersionsByPackage = [], array $securityRemoveVersions = [], array $abandonedRemovedVersions = [])
{
$this->versionParser = new VersionParser;
$this->setPackages($packages);
$this->unacceptableFixedOrLockedPackages = $unacceptableFixedOrLockedPackages;
$this->removedVersions = $removedVersions;
$this->removedVersionsByPackage = $removedVersionsByPackage;
$this->securityRemovedVersions = $securityRemoveVersions;
$this->abandonedRemovedVersions = $abandonedRemovedVersions;
}

/**
Expand Down Expand Up @@ -87,6 +95,44 @@ public function getRemovedVersionsByPackage(string $objectHash): array
return $this->removedVersionsByPackage[$objectHash];
}

public function isSecurityRemovedPackageVersion(string $packageName, ?ConstraintInterface $constraint): bool
{
foreach ($this->securityRemovedVersions[$packageName] ?? [] as $version => $prettyVersion) {
if ($constraint !== null && $constraint->matches(new Constraint('==', $version))) {
return true;
}
}

return false;
}

public function isAbandonedRemovedPackageVersion(string $packageName, ?ConstraintInterface $constraint): bool
{
foreach ($this->abandonedRemovedVersions[$packageName] ?? [] as $version => $prettyVersion) {
if ($constraint !== null && $constraint->matches(new Constraint('==', $version))) {
return true;
}
}

return false;
}

/**
* @return array<string, array<string, string>>
*/
public function getAllSecurityRemovedPackageVersions(): array
{
return $this->securityRemovedVersions;
}

/**
* @return array<string, array<string, string>>
*/
public function getAllAbandonedRemovedPackageVersions(): array
{
return $this->abandonedRemovedVersions;
}

/**
* @param BasePackage[] $packages
*/
Expand Down
40 changes: 39 additions & 1 deletion src/Composer/DependencyResolver/PoolBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ class PoolBuilder
/** @var int */
private $indexCounter = 0;

/** @var ?SecurityAdvisoryPoolFilter */
private $securityAdvisoryPoolFilter;

/**
* @param int[] $acceptableStabilities array of stability => BasePackage::STABILITY_* value
* @phpstan-param array<string, BasePackage::STABILITY_*> $acceptableStabilities
Expand All @@ -162,7 +165,7 @@ class PoolBuilder
* @phpstan-param array<string, string> $rootReferences
* @param array<string, ConstraintInterface> $temporaryConstraints Runtime temporary constraints that will be used to filter packages
*/
public function __construct(array $acceptableStabilities, array $stabilityFlags, array $rootAliases, array $rootReferences, IOInterface $io, ?EventDispatcher $eventDispatcher = null, ?PoolOptimizer $poolOptimizer = null, array $temporaryConstraints = [])
public function __construct(array $acceptableStabilities, array $stabilityFlags, array $rootAliases, array $rootReferences, IOInterface $io, ?EventDispatcher $eventDispatcher = null, ?PoolOptimizer $poolOptimizer = null, array $temporaryConstraints = [], ?SecurityAdvisoryPoolFilter $securityAdvisoryPoolFilter = null)
{
$this->acceptableStabilities = $acceptableStabilities;
$this->stabilityFlags = $stabilityFlags;
Expand All @@ -172,6 +175,7 @@ public function __construct(array $acceptableStabilities, array $stabilityFlags,
$this->poolOptimizer = $poolOptimizer;
$this->io = $io;
$this->temporaryConstraints = $temporaryConstraints;
$this->securityAdvisoryPoolFilter = $securityAdvisoryPoolFilter;
}

/**
Expand Down Expand Up @@ -334,6 +338,7 @@ public function buildPool(array $repositories, Request $request): Pool

$this->io->debug('Built pool.');

$pool = $this->runSecurityAdvisoryFilter($pool, $repositories);
$pool = $this->runOptimizer($request, $pool);

Intervals::clear();
Expand Down Expand Up @@ -776,4 +781,37 @@ private function runOptimizer(Request $request, Pool $pool): Pool

return $pool;
}

/**
* @param RepositoryInterface[] $repositories
*/
private function runSecurityAdvisoryFilter(Pool $pool, array $repositories): Pool
{
if (null === $this->securityAdvisoryPoolFilter) {
return $pool;
}

$this->io->debug('Running security advisory pool filter.');

$before = microtime(true);
$total = \count($pool->getPackages());

$pool = $this->securityAdvisoryPoolFilter->filter($pool, $repositories);

$filtered = $total - \count($pool->getPackages());

if (0 === $filtered) {
return $pool;
}

$this->io->write(sprintf('Security advisory pool filter completed in %.3f seconds', microtime(true) - $before), true, IOInterface::VERY_VERBOSE);
$this->io->write(sprintf(
'<info>Found %s package versions referenced in your dependency graph. %s (%d%%) were filtered away.</info>',
number_format($total),
number_format($filtered),
round(100 / $total * $filtered)
), true, IOInterface::VERY_VERBOSE);

return $pool;
}
}
2 changes: 1 addition & 1 deletion src/Composer/DependencyResolver/PoolOptimizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ private function applyRemovalsToPool(Pool $pool): Pool
}
}

$optimizedPool = new Pool($packages, $pool->getUnacceptableFixedOrLockedPackages(), $removedVersions, $this->removedVersionsByPackage);
$optimizedPool = new Pool($packages, $pool->getUnacceptableFixedOrLockedPackages(), $removedVersions, $this->removedVersionsByPackage, $pool->getAllSecurityRemovedPackageVersions(), $pool->getAllAbandonedRemovedPackageVersions());

return $optimizedPool;
}
Expand Down
8 changes: 8 additions & 0 deletions src/Composer/DependencyResolver/Problem.php
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,14 @@ public static function getMissingPackageReason(RepositorySet $repositorySet, Req
return ["- Root composer.json requires $packageName".self::constraintToText($constraint) . ', ', 'found '.self::getPackageList($packages, $isVerbose, $pool, $constraint).' in the lock file but not in remote repositories, make sure you avoid updating this package to keep the one from the lock file.'];
}

if ($pool->isAbandonedRemovedPackageVersion($packageName, $constraint)) {
return ["- Root composer.json requires $packageName".self::constraintToText($constraint) . ', ', 'found '.self::getPackageList($packages, $isVerbose, $pool, $constraint).' but these were not loaded, because they are abandoned.'];
}

if ($pool->isSecurityRemovedPackageVersion($packageName, $constraint)) {
return ["- Root composer.json requires $packageName".self::constraintToText($constraint) . ', ', 'found '.self::getPackageList($packages, $isVerbose, $pool, $constraint).' but these were not loaded, because they have security advisories.'];
}

return ["- Root composer.json requires $packageName".self::constraintToText($constraint) . ', ', 'found '.self::getPackageList($packages, $isVerbose, $pool, $constraint).' but these were not loaded, likely because '.(self::hasMultipleNames($packages) ? 'they conflict' : 'it conflicts').' with another require.'];
}

Expand Down
109 changes: 109 additions & 0 deletions src/Composer/DependencyResolver/SecurityAdvisoryPoolFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php declare(strict_types=1);

/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Composer\DependencyResolver;

use Composer\Advisory\AuditConfig;
use Composer\Advisory\Auditor;
use Composer\Advisory\PartialSecurityAdvisory;
use Composer\Advisory\SecurityAdvisory;
use Composer\Package\CompletePackage;
use Composer\Package\PackageInterface;
use Composer\Package\RootPackageInterface;
use Composer\Repository\RepositoryInterface;
use Composer\Repository\RepositorySet;
use Composer\Semver\Constraint\Constraint;

class SecurityAdvisoryPoolFilter
{
/** @var Auditor */
private $auditor;
/** @var AuditConfig $auditConfig */
private $auditConfig;

public function __construct(
Auditor $auditor,
AuditConfig $auditConfig
) {
$this->auditor = $auditor;
$this->auditConfig = $auditConfig;
}

/**
* @param array<RepositoryInterface> $repositories
*/
public function filter(Pool $pool, array $repositories): Pool
{
$advisoryMap = [];
if ($this->auditConfig->blockInsecure) {
$repoSet = new RepositorySet();
foreach ($repositories as $repo) {
$repoSet->addRepository($repo);
}

$packagesForAdvisories = [];
foreach ($pool->getPackages() as $package) {
// @todo Pool contains a list of ext-/lib-/php/composer/composer-plugin-api/composer-runtime-api that need to be filtered out before fetching security advisories. Is there a better way?
if (! $package instanceof RootPackageInterface && str_contains($package->getName(), '/')) {
$packagesForAdvisories[] = $package;
}
}

$allAdvisories = $repoSet->getMatchingSecurityAdvisories($packagesForAdvisories, true);
$advisoryMap = $this->auditor->processAdvisories($allAdvisories, $this->auditConfig->ignoreList)['advisories'];
}

$packages = [];
$securityRemovedVersions = [];
$abandonedRemovedVersions = [];
foreach ($pool->getPackages() as $package) {
if ($this->auditConfig->blockAbandoned && $package instanceof CompletePackage && $package->isAbandoned()) {
foreach ($package->getNames(false) as $packageName) {
$abandonedRemovedVersions[$packageName][$package->getVersion()] = $package->getPrettyVersion();
}
} elseif ($this->doesPackageMatchAdvisories($package, $advisoryMap)) {
foreach ($package->getNames(false) as $packageName) {
$securityRemovedVersions[$packageName][$package->getVersion()] = $package->getPrettyVersion();
}
} else {
$packages[] = $package;
}
}

return new Pool($packages, $pool->getUnacceptableFixedOrLockedPackages(), [], [], $securityRemovedVersions, $abandonedRemovedVersions);
}

/**
* @param array<string, array<PartialSecurityAdvisory|SecurityAdvisory>> $advisoryMap
*/
private function doesPackageMatchAdvisories(PackageInterface $package, array $advisoryMap): bool
{
if ($package->isDev()) {
return false;
}

foreach ($package->getNames(false) as $packageName) {
if (! isset($advisoryMap[$packageName])) {
return false;
}

$packageConstraint = new Constraint(Constraint::STR_OP_EQ, $package->getVersion());
foreach ($advisoryMap[$packageName] as $advisory) {
if ($advisory->affectedVersions->matches($packageConstraint)) {
return true;
}
}
}

return false;
}
}