8000 feat: Added --ignore-unreachable flag to audit command for private/unreachable repositories by sschueller · Pull Request #12470 · composer/composer · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

feat: Added --ignore-unreachable flag to audit command for private/unreachable repositories #12470

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

Open
wants to merge 4 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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions src/Composer/Advisory/Auditor.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,18 @@ class Auditor
* @return int-mask<self::STATUS_*> A bitmask of STATUS_* constants or 0 on success
* @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, array $ignoredSeverities = []): int
public function audit(IOInterface $io, RepositorySet $repoSet, array $packages, string $format, bool $warningOnly = true, array $ignoreList = [], string $abandoned = self::ABANDONED_FAIL, array $ignoredSeverities = [], bool $ignoreUnreachable = false): int
{
$allAdvisories = $repoSet->getMatchingSecurityAdvisories($packages, $format === self::FORMAT_SUMMARY);
$result = $repoSet->getMatchingSecurityAdvisories($packages, $format === self::FORMAT_SUMMARY, $ignoreUnreachable);
$allAdvisories = $result['advisories'];
$unreachableRepos = $result['unreachableRepos'];

// 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);
$result = $repoSet->getMatchingSecurityAdvisories($packages, false, $ignoreUnreachable);
$allAdvisories = $result['advisories'];
$unreachableRepos = array_merge($unreachableRepos, $result['unreachableRepos']);
}
['advisories' => $advisories, 'ignoredAdvisories' => $ignoredAdvisories] = $this->processAdvisories($allAdvisories, $ignoreList, $ignoredSeverities);

Expand All @@ -97,6 +102,9 @@ public function audit(IOInterface $io, RepositorySet $repoSet, array $packages,
if ($ignoredAdvisories !== []) {
$json['ignored-advisories'] = $ignoredAdvisories;
}
if ($unreachableRepos !== []) {
$json['unreachable-repositories'] = $unreachableRepos;
}
$json['abandoned'] = array_reduce($abandonedPackages, static function (array $carry, CompletePackageInterface $package): array {
$carry[$package->getPrettyName()] = $package->getReplacementPackage();

Expand Down Expand Up @@ -132,6 +140,13 @@ public function audit(IOInterface $io, RepositorySet $repoSet, array $packages,
$io->writeError('<info>No security vulnerability advisories found.</info>');
}

if (count($unreachableRepos) > 0) {
$io->writeError('<warning>The following repositories were unreachable:</warning>');
foreach ($unreachableRepos as $repo) {
$io->writeError(' - ' . $repo);
}
}

if (count($abandonedPackages) > 0 && $format !== self::FORMAT_SUMMARY) {
$this->outputAbandonedPackages($io, $abandonedPackages, $format);
}
Expand Down
7 changes: 6 additions & 1 deletion src/Composer/Command/AuditCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,16 @@ protected function configure(): void
new InputOption('locked', null, InputOption::VALUE_NONE, 'Audit based on the lock file instead of the installed packages.'),
new InputOption('abandoned', null, InputOption::VALUE_REQUIRED, 'Behavior on abandoned packages. Must be "ignore", "report", or "fail".', null, Auditor::ABANDONEDS),
new InputOption('ignore-severity', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Ignore advisories of a certain severity level.', [], ['low', 'medium', 'high', 'critical']),
new InputOption('ignore-unreachable', null, InputOption::VALUE_NONE, 'Ignore repositories that are unreachable or return a non-200 status code.'),
])
->setHelp(
<<<EOT
The <info>audit</info> command checks for security vulnerability advisories for installed packages.
If you do not want to include dev dependencies in the audit you can omit them with --no-dev
If you want to ignore repositories that are unreachable or return a non-200 status code, use --ignore-unreachable
Read more at https://getcomposer.org/doc/03-cli.md#audit
EOT
)
Expand Down Expand Up @@ -75,6 +78,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$abandoned = $abandoned ?? $auditConfig['abandoned'] ?? Auditor::ABANDONED_FAIL;

$ignoreSeverities = $input->getOption('ignore-severity') ?? [];
$ignoreUnreachable = $input->getOption('ignore-unreachable');

return min(255, $auditor->audit(
$this->getIO(),
Expand All @@ -84,7 +88,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int
false,
$auditConfig['ignore'] ?? [],
$abandoned,
$ignoreSeverities
$ignoreSeverities,
$ignoreUnreachable
));

}
Expand Down
38 changes: 26 additions & 12 deletions src/Composer/Repository/RepositorySet.php
Original file line number Diff line number Diff line change
Expand Up @@ -227,23 +227,26 @@ public function findPackages(string $name, ?ConstraintInterface $constraint = nu

/**
* @param string[] $packageNames
* @return ($allowPartialAdvisories is true ? array<string, array<PartialSecurityAdvisory|SecurityAdvisory>> : array<string, array<SecurityAdvisory>>)
* @return ($allowPartialAdvisories is true ? array{advisories: array<string, array<PartialSecurityAdvisory|SecurityAdvisory>>, unreachableRepos: array<string>} : array{advisories: array<string, array<SecurityAdvisory>>, unreachableRepos: array<string>})
*/
public function getSecurityAdvisories(array $packageNames, bool $allowPartialAdvisories = false): array
public function getSecurityAdvisories(array $packageNames, bool $allowPartialAdvisories, bool $ignoreUnreachable = false): array
{
$map = [];
foreach ($packageNames as $name) {
$map[$name] = new MatchAllConstraint();
}

return $this->getSecurityAdvisoriesForConstraints($map, $allowPartialAdvisories);
$unreachableRepos = [];
$advisories = $this->getSecurityAdvisoriesForConstraints($map, $allowPartialAdvisories, $ignoreUnreachable, $unreachableRepos);

return ['advisories' => $advisories, 'unreachableRepos' => $unreachableRepos];
}

/**
* @param PackageInterface[] $packages
* @return ($allowPartialAdvisories is true ? array<string, array<PartialSecurityAdvisory|SecurityAdvisory>> : array<string, array<SecurityAdvisory>>)
* @return ($allowPartialAdvisories is true ? array{advisories: array<string, array<PartialSecurityAdvisory|SecurityAdvisory>>, unreachableRepos: array<string>} : array{advisories: array<string, array<SecurityAdvisory>>, unreachableRepos: array<string>})
*/
public function getMatchingSecurityAdvisories(array $packages, bool $allowPartialAdvisories = false): array
public function getMatchingSecurityAdvisories(array $packages, bool $allowPartialAdvisories = false, bool $ignoreUnreachable = false): array
{
$map = [];
foreach ($packages as $package) {
Expand All @@ -258,25 +261,36 @@ public function getMatchingSecurityAdvisories(array $packages, bool $allowPartia
}
}

return $this->getSecurityAdvisoriesForConstraints($map, $allowPartialAdvisories);
$unreachableRepos = [];
$advisories = $this->getSecurityAdvisoriesForConstraints($map, $allowPartialAdvisories, $ignoreUnreachable, $unreachableRepos);

return ['advisories' => $advisories, 'unreachableRepos' => $unreachableRepos];
}

/**
* @param array<string, ConstraintInterface> $packageConstraintMap
* @param array<string> &$unreachableRepos Array to store messages about unreachable repositories
* @return ($allowPartialAdvisories is true ? array<string, array<PartialSecurityAdvisory|SecurityAdvisory>> : array<string, array<SecurityAdvisory>>)
*/
private function getSecurityAdvisoriesForConstraints(array $packageConstraintMap, bool $allowPartialAdvisories): array
private function getSecurityAdvisoriesForConstraints(array $packageConstraintMap, bool $allowPartialAdvisories, bool $ignoreUnreachable = false, array &$unreachableRepos = []): array
{
$repoAdvisories = [];
foreach ($this->repositories as $repository) {
if (!$repository instanceof AdvisoryProviderInterface || !$repository->hasSecurityAdvisories()) {
continue;
}
try {
if (!$repository instanceof AdvisoryProviderInterface || !$repository->hasSecurityAdvisories()) {
continue;
}

$repoAdvisories[] = $repository->getSecurityAdvisories($packageConstraintMap, $allowPartialAdvisories)['advisories'];
$repoAdvisories[] = $repository->getSecurityAdvisories($packageConstraintMap, $allowPartialAdvisories)['advisories'];
} catch (\Composer\Downloader\TransportException $e) {
if (!$ignoreUnreachable) {
throw $e;
}
$unreachableRepos[] = $e->getMessage();
}
}

$advisories = array_merge_recursive([], ...$repoAdvisories);
$advisories = count($repoAdvisories) > 0 ? array_merge_recursive([], ...$repoAdvisories) : [];
ksort($advisories);

return $advisories;
Expand Down
104 changes: 104 additions & 0 deletions tests/Composer/Test/Advisory/AuditorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,110 @@ public function ignoreSeverityProvider(): \Generator
];
}

public function testAuditWithIgnoreUnreachable(): void
{
$packages = [
new Package('vendor1/package1', '3.0.0.0', '3.0.0'),
];

$errorMessage = 'The "https://example.org/packages.json" file could not be downloaded: HTTP/1.1 404 Not Found';

// Create a mock RepositorySet that simulates multiple repositories with the middle one being unreachable
$repoSet = $this->getMockBuilder(RepositorySet::class)
->disableOriginalConstructor()
->onlyMethods(['getMatchingSecurityAdvisories'])
->getMock();

$repoSet->method('getMatchingSecurityAdvisories')
->willReturnCallback(function ($packages, $allowPartialAdvisories, $ignoreUnreachable) use ($errorMessage) {
if (!$ignoreUnreachable) {
throw new \Composer\Downloader\TransportException($errorMessage, 404);
}

// Simulate multiple repositories with the middle one being unreachable
// First and third repositories have advisories, middle one is unreachable
return [
'advisories' => [
'vendor1/package1' => [
new SecurityAdvisory(
'vendor1/package1',
'CVE-2023-12345',
new \Composer\Semver\Constraint\Constraint('=', '3.0.0.0'),
'First repo advisory',
[['name' => 'test', 'remoteId' => '1']],
new \DateTimeImmutable('2023-01-01', new \DateTimeZone('UTC')),
'CVE-2023-12345',
'https://example.com/advisory/1',
'medium'
),
new SecurityAdvisory(
'vendor1/package1',
'CVE-2023-67890',
new \Composer\Semver\Constraint\Constraint('=', '3.0.0.0'),
'Third repo advisory',
[['name' => 'test', 'remoteId' => '3']],
new \DateTimeImmutable('2023-01-01', new \DateTimeZone('UTC')),
'CVE-2023-67890',
'https://example.com/advisory/3',
'high'
)
]
],
'unreachableRepos' => [$errorMessage]
];
});

$auditor = new Auditor();

// Test without ignoreUnreachable flag
try {
$auditor->audit(new BufferIO(), $repoSet, $packages, Auditor::FORMAT_PLAIN, false);
self::fail('Expected TransportException was not thrown');
} catch (\Composer\Downloader\TransportException $e) {
self::assertStringContainsString('HTTP/1.1 404 Not Found', $e->getMessage());
}

// Test with ignoreUnreachable flag
$io = new BufferIO();
$result = $auditor->audit($io, $repoSet, $packages, Auditor::FORMAT_PLAIN, false, [], Auditor::ABANDONED_IGNORE, [], true);

// Should find advisories from the reachable repositories
self::assertSame(Auditor::STATUS_VULNERABLE, $result);

$output = $io->getOutput();
self::assertStringContainsString('The following repositories were unreachable:', $output);
self::assertStringContainsString('HTTP/1.1 404 Not Found', $output);

// Verify that advisories from reachable repositories were found
self::assertStringContainsString('First repo advisory', $output);
self::assertStringContainsString('Third repo advisory', $output);
self::assertStringContainsString('CVE-2023-12345', $output);
self::assertStringContainsString('CVE-2023-67890', $output);

// Test with JSON format
$io = new BufferIO();
$result = $auditor->audit($io, $repoSet, $packages, Auditor::FORMAT_JSON, false, [], Auditor::ABANDONED_IGNORE, [], true);
self::assertSame(Auditor::STATUS_VULNERABLE, $result);

$json = json_decode($io->getOutput(), true);
self::assertArrayHasKey('unreachable-repositories', $json);
self::assertCount(1, $json['unreachable-repositories']);
self::assertStringContainsString('HTTP/1.1 404 Not Found', $json['unreachable-repositories'][0]);

// Verify that advisories from reachable repositories were included in JSON output
self::assertArrayHasKey('advisories', $json);
self::assertArrayHasKey('vendor1/package1', $json['advisories']);
self::assertCount(2, $json['advisories']['vendor1/package1']);

// Check first advisory
self::assertSame('CVE-2023-12345', $json['advisories']['vendor1/package1'][0]['cve']);
self::assertSame('First repo advisory', $json['advisories']['vendor1/package1'][0]['title']);

// Check second advisory
self::assertSame('CVE-2023-67890', $json['advisories']['vendor1/package1'][1]['cve']);
self::assertSame('Third repo advisory', $json['advisories']['vendor1/package1'][1]['title']);
}

/**
* @dataProvider ignoreSeverityProvider
* @phpstan-param array<\Composer\Package\Package> $packages
Expand Down
0