* Jordi Boggiano * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ 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 Composer\Util\Platform; use InvalidArgumentException; use Symfony\Component\Console\Formatter\OutputFormatter; /** * @internal */ class Auditor { public const FORMAT_TABLE = 'table'; public const FORMAT_PLAIN = 'plain'; public const FORMAT_JSON = 'json'; public const FORMAT_SUMMARY = 'summary'; public const FORMATS = [ self::FORMAT_TABLE, self::FORMAT_PLAIN, self::FORMAT_JSON, 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 = [], string $abandoned = self::ABANDONED_FAIL): int { $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 if (count($allAdvisories) > 0 && $ignoreList !== [] && $format === self::FORMAT_SUMMARY) { $allAdvisories = $repoSet->getMatchingSecurityAdvisories($packages, false); } ['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'] = array_reduce($abandonedPackages, static function(array $carry, CompletePackageInterface $package): array { $carry[$package->getPrettyName()] = $package->getReplacementPackage(); return $carry; }, []); $io->write(JsonFile::encode($json)); return count($advisories) + $abandonedCount; } $errorOrWarn = $warningOnly ? 'warning' : 'error'; if (count($advisories) > 0 || count($ignoredAdvisories) > 0) { $passes = [ [$ignoredAdvisories, "Found %d ignored security vulnerability advisor%s affecting %d package%s%s"], // this has to run last to allow $affectedPackagesCount in the return statement to be correct [$advisories, "<$errorOrWarn>Found %d security vulnerability advisor%s affecting %d package%s%s"], ]; foreach ($passes as [$advisoriesToOutput, $message]) { [$affectedPackagesCount, $totalAdvisoryCount] = $this->countAdvisories($advisoriesToOutput); if ($affectedPackagesCount > 0) { $plurality = $totalAdvisoryCount === 1 ? 'y' : 'ies'; $pkgPlurality = $affectedPackagesCount === 1 ? '' : 's'; $punctuation = $format === 'summary' ? '.' : ':'; $io->writeError(sprintf($message, $totalAdvisoryCount, $plurality, $affectedPackagesCount, $pkgPlurality, $punctuation)); $this->outputAdvisories($io, $advisoriesToOutput, $format); } } if ($format === self::FORMAT_SUMMARY) { $io->writeError('Run "composer audit" for a full list of advisories.'); } } else { $io->writeError('No security vulnerability advisories found.'); } if (count($abandonedPackages) > 0 && $format !== self::FORMAT_SUMMARY) { $this->outputAbandonedPackages($io, $abandonedPackages, $format); } return $affectedPackagesCount + $abandonedCount; } /** * @param array $packages * @return array */ private function filterAbandonedPackages(array $packages): array { return array_filter($packages, static function (PackageInterface $pkg) { return $pkg instanceof CompletePackageInterface && $pkg->isAbandoned(); }); } /** * @phpstan-param array> $allAdvisories * @param array|array $ignoreList List of advisory IDs, remote IDs or CVE IDs that reported but not listed as vulnerabilities. * @phpstan-return array{advisories: array>, ignoredAdvisories: array>} */ private function processAdvisories(array $allAdvisories, array $ignoreList): array { if ($ignoreList === []) { return ['advisories' => $allAdvisories, 'ignoredAdvisories' => []]; } if (\count($ignoreList) > 0 && !\array_is_list($ignoreList)) { $ignoredIds = array_keys($ignoreList); } else { $ignoredIds = $ignoreList; } $advisories = []; $ignored = []; $ignoreReason = null; foreach ($allAdvisories as $package => $pkgAdvisories) { foreach ($pkgAdvisories as $advisory) { $isActive = true; if (in_array($advisory->advisoryId, $ignoredIds, true)) { $isActive = false; $ignoreReason = $ignoreList[$advisory->advisoryId] ?? null; } if ($advisory instanceof SecurityAdvisory) { if (in_array($advisory->cve, $ignoredIds, true)) { $isActive = false; $ignoreReason = $ignoreList[$advisory->cve] ?? null; } foreach ($advisory->sources as $source) { if (in_array($source['remoteId'], $ignoredIds, true)) { $isActive = false; $ignoreReason = $ignoreList[$source['remoteId']] ?? null; break; } } } if ($isActive) { $advisories[$package][] = $advisory; continue; } // Partial security advisories only used in summary mode // and in that case we do not need to cast the object. if ($advisory instanceof SecurityAdvisory) { $advisory = $advisory->toIgnoredAdvisory($ignoreReason); } $ignored[$package][] = $advisory; } } return ['advisories' => $advisories, 'ignoredAdvisories' => $ignored]; } /** * @param array> $advisories * @return array{int, int} Count of affected packages and total count of advisories */ private function countAdvisories(array $advisories): array { $count = 0; foreach ($advisories as $packageAdvisories) { $count += count($packageAdvisories); } return [count($advisories), $count]; } /** * @param array> $advisories * @param self::FORMAT_* $format The format that will be used to output audit results. */ private function outputAdvisories(IOInterface $io, array $advisories, string $format): void { switch ($format) { case self::FORMAT_TABLE: if (!($io instanceof ConsoleIO)) { throw new InvalidArgumentException('Cannot use table format with ' . get_class($io)); } $this->outputAdvisoriesTable($io, $advisories); return; case self::FORMAT_PLAIN: $this->outputAdvisoriesPlain($io, $advisories); return; case self::FORMAT_SUMMARY: return; default: throw new InvalidArgumentException('Invalid format "'.$format.'".'); } } /** * @param array> $advisories */ private function outputAdvisoriesTable(ConsoleIO $io, array $advisories): void { foreach ($advisories as $packageAdvisories) { foreach ($packageAdvisories as $advisory) { $headers = [ 'Package', 'Severity', 'CVE', 'Title', 'URL', 'Affected versions', 'Reported at', ]; $row = [ $advisory->packageName, $this->getSeverity($advisory), $this->getCVE($advisory), $advisory->title, $this->getURL($advisory), $advisory->affectedVersions->getPrettyString(), $advisory->reportedAt->format(DATE_ATOM), ]; if ($advisory instanceof IgnoredSecurityAdvisory) { $headers[] = 'Ignore reason'; $row[] = $advisory->ignoreReason ?? 'None specified'; } $io->getTable() ->setHorizontal() ->setHeaders($headers) ->addRow($row) ->setColumnWidth(1, 80) ->setColumnMaxWidth(1, 80) ->render(); } } } /** * @param array> $advisories */ private function outputAdvisoriesPlain(IOInterface $io, array $advisories): void { $error = []; $firstAdvisory = true; foreach ($advisories as $packageAdvisories) { foreach ($packageAdvisories as $advisory) { if (!$firstAdvisory) { $error[] = '--------'; } $error[] = "Package: ".$advisory->packageName; $error[] = "Severity: ".$this->getSeverity($advisory); $error[] = "CVE: ".$this->getCVE($advisory); $error[] = "Title: ".OutputFormatter::escape($advisory->title); $error[] = "URL: ".$this->getURL($advisory); $error[] = "Affected versions: ".OutputFormatter::escape($advisory->affectedVersions->getPrettyString()); $error[] = "Reported at: ".$advisory->reportedAt->format(DATE_ATOM); if ($advisory instanceof IgnoredSecurityAdvisory) { $error[] = "Ignore reason: ".($advisory->ignoreReason ?? 'None specified'); } $firstAdvisory = false; } } $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) { $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) { $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 getSeverity(SecurityAdvisory $advisory): string { if ($advisory->severity === null) { return ''; } return $advisory->severity; } private function getCVE(SecurityAdvisory $advisory): string { if ($advisory->cve === null) { return 'NO CVE'; } return ''.$advisory->cve.''; } private function getURL(SecurityAdvisory $advisory): string { if ($advisory->link === null) { return ''; } return 'link).'>'.OutputFormatter::escape($advisory->link).''; } }