From 6d150fcf1b4d5372498965e44e6c53131e84280b Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Wed, 13 Sep 2023 18:17:02 +0200 Subject: [PATCH] [AssetMapper] Add audit command --- .../Resources/config/asset_mapper.php | 13 ++ .../Command/ImportMapAuditCommand.php | 187 +++++++++++++++++ .../ImportMap/ImportMapAuditor.php | 119 +++++++++++ .../ImportMap/ImportMapConfigReader.php | 6 +- .../AssetMapper/ImportMap/ImportMapEntry.php | 1 + .../ImportMap/ImportMapPackageAudit.php | 32 +++ .../ImportMapPackageAuditVulnerability.php | 26 +++ .../Resolver/JsDelivrEsmResolver.php | 9 + .../ImportMap/Resolver/JspmResolver.php | 9 + .../ImportMap/Resolver/PackageResolver.php | 5 + .../Resolver/PackageResolverInterface.php | 5 + .../Resolver/ResolvedImportMapPackage.php | 1 + .../Tests/ImportMap/ImportMapAuditorTest.php | 197 ++++++++++++++++++ .../Resolver/JsDelivrEsmResolverTest.php | 17 ++ .../ImportMap/Resolver/JspmResolverTest.php | 17 ++ 15 files changed, 643 insertions(+), 1 deletion(-) create mode 100644 src/Symfony/Component/AssetMapper/Command/ImportMapAuditCommand.php create mode 100644 src/Symfony/Component/AssetMapper/ImportMap/ImportMapAuditor.php create mode 100644 src/Symfony/Component/AssetMapper/ImportMap/ImportMapPackageAudit.php create mode 100644 src/Symfony/Component/AssetMapper/ImportMap/ImportMapPackageAuditVulnerability.php create mode 100644 src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapAuditorTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php index eccf206f6a42a..f4185476ff368 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php @@ -17,6 +17,7 @@ use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\AssetMapper\AssetMapperRepository; use Symfony\Component\AssetMapper\Command\AssetMapperCompileCommand; +use Symfony\Component\AssetMapper\Command\ImportMapAuditCommand; use Symfony\Component\AssetMapper\Command\DebugAssetMapperCommand; use Symfony\Component\AssetMapper\Command\ImportMapInstallCommand; use Symfony\Component\AssetMapper\Command\ImportMapRemoveCommand; @@ -27,6 +28,7 @@ use Symfony\Component\AssetMapper\Compiler\SourceMappingUrlsCompiler; use Symfony\Component\AssetMapper\Factory\CachedMappedAssetFactory; use Symfony\Component\AssetMapper\Factory\MappedAssetFactory; +use Symfony\Component\AssetMapper\ImportMap\ImportMapAuditor; use Symfony\Component\AssetMapper\ImportMap\ImportMapConfigReader; use Symfony\Component\AssetMapper\ImportMap\ImportMapManager; use Symfony\Component\AssetMapper\ImportMap\ImportMapRenderer; @@ -193,6 +195,13 @@ abstract_arg('script HTML attributes'), ]) + ->set('asset_mapper.importmap.auditor', ImportMapAuditor::class) + ->args([ + service('asset_mapper.importmap.config_reader'), + service('asset_mapper.importmap.resolver'), + service('http_client'), + ]) + ->set('asset_mapper.importmap.command.require', ImportMapRequireCommand::class) ->args([ service('asset_mapper.importmap.manager'), @@ -212,5 +221,9 @@ ->set('asset_mapper.importmap.command.install', ImportMapInstallCommand::class) ->args([service('asset_mapper.importmap.manager')]) ->tag('console.command') + + ->set('asset_mapper.importmap.command.audit', ImportMapAuditCommand::class) + ->args([service('asset_mapper.importmap.auditor')]) + ->tag('console.command') ; }; diff --git a/src/Symfony/Component/AssetMapper/Command/ImportMapAuditCommand.php b/src/Symfony/Component/AssetMapper/Command/ImportMapAuditCommand.php new file mode 100644 index 0000000000000..136422ee34110 --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Command/ImportMapAuditCommand.php @@ -0,0 +1,187 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\Command; + +use Symfony\Component\AssetMapper\ImportMap\ImportMapAuditor; +use Symfony\Component\AssetMapper\ImportMap\ImportMapPackageAuditVulnerability; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +#[AsCommand(name: 'importmap:audit', description: 'Checks for security vulnerability advisories for dependencies.')] +class ImportMapAuditCommand extends Command +{ + private const SEVERITY_COLORS = [ + 'critical' => 'red', + 'high' => 'red', + 'medium' => 'yellow', + 'low' => 'default', + 'unknown' => 'default', + ]; + + private SymfonyStyle $io; + + public function __construct( + private readonly ImportMapAuditor $importMapAuditor, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this->addOption( + name: 'format', + mode: InputOption::VALUE_REQUIRED, + description: sprintf('The output format ("%s")', implode(', ', $this->getAvailableFormatOptions())), + default: 'txt', + ); + } + + protected function initialize(InputInterface $input, OutputInterface $output): void + { + $this->io = new SymfonyStyle($input, $output); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $format = $input->getOption('format'); + + $audit = $this->importMapAuditor->audit(); + + return match ($format) { + 'txt' => $this->displayTxt($audit), + 'json' => $this->displayJson($audit), + default => throw new \InvalidArgumentException(sprintf('Supported formats are "%s".', implode('", "', $this->getAvailableFormatOptions()))), + }; + } + + private function displayTxt(array $audit): int + { + $rows = []; + + $packagesWithoutVersion = []; + $vulnerabilitiesCount = array_map(fn() => 0, self::SEVERITY_COLORS); + foreach ($audit as $packageAudit) { + if (!$packageAudit->version) { + $packagesWithoutVersion[] = $packageAudit->package; + } + foreach($packageAudit->vulnerabilities as $vulnerability) { + $rows[] = [ + sprintf('%s', self::SEVERITY_COLORS[$vulnerability->severity] ?? 'default', ucfirst($vulnerability->severity)), + $vulnerability->summary, + $packageAudit->package, + $packageAudit->version ?? 'n/a', + $vulnerability->firstPatchedVersion ?? 'n/a', + $vulnerability->url, + ]; + ++$vulnerabilitiesCount[$vulnerability->severity]; + } + } + $packagesCount = count($audit); + $packagesWithoutVersionCount = count($packagesWithoutVersion); + + if ([] === $rows && 0 === $packagesWithoutVersionCount) { + $this->io->info('No vulnerabilities found.'); + + return self::SUCCESS; + } + + if ([] !== $rows) { + $table = $this->io->createTable(); + $table->setHeaders([ + 'Severity', + 'Title', + 'Package', + 'Version', + 'Patched in', + 'More info', + ]); + $table->addRows($rows); + $table->render(); + $this->io->newLine(); + } + + $this->io->text(sprintf('%d package%s found: %d audited / %d skipped', + $packagesCount, + 1 === $packagesCount ? '' : 's', + $packagesCount - $packagesWithoutVersionCount, + $packagesWithoutVersionCount, + )); + + if (0 < $packagesWithoutVersionCount) { + $this->io->warning(sprintf('Unable to retrieve versions for package%s: %s', + 1 === $packagesWithoutVersionCount ? '' : 's', + implode(', ', $packagesWithoutVersion) + )); + } + + if ([] !== $rows) { + $vulnerabilityCount = 0; + $vulnerabilitySummary = []; + foreach ($vulnerabilitiesCount as $severity => $count) { + if (0 === $count) { + continue; + } + $vulnerabilitySummary[] = sprintf( '%d %s', $count, ucfirst($severity)); + $vulnerabilityCount += $count; + } + $this->io->text(sprintf('%d vulnerabilit%s found: %s', + $vulnerabilityCount, + 1 === $vulnerabilityCount ? 'y' : 'ies', + implode(' / ', $vulnerabilitySummary), + )); + } + + return self::FAILURE; + } + + private function displayJson(array $audit): int + { + $vulnerabilitiesCount = array_map(fn() => 0, self::SEVERITY_COLORS); + + $json = [ + 'packages' => [], + 'summary' => $vulnerabilitiesCount, + ]; + + foreach ($audit as $packageAudit) { + $json['packages'][] = [ + 'package' => $packageAudit->package, + 'version' => $packageAudit->version, + 'vulnerabilities' => array_map(fn (ImportMapPackageAuditVulnerability $v) => [ + 'ghsa_id' => $v->ghsaId, + 'cve_id' => $v->cveId, + 'url' => $v->url, + 'summary' => $v->summary, + 'severity' => $v->severity, + 'vulnerable_version_range' => $v->vulnerableVersionRange, + 'first_patched_version' => $v->firstPatchedVersion, + ], $packageAudit->vulnerabilities), + ]; + foreach ($packageAudit->vulnerabilities as $vulnerability) { + ++$json['summary'][$vulnerability->severity]; + } + } + + $this->io->write(json_encode($json)); + + return 0 < array_sum($json['summary']) ? self::FAILURE : self::SUCCESS; + } + + private function getAvailableFormatOptions(): array + { + return ['txt', 'json']; + } +} diff --git a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapAuditor.php b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapAuditor.php new file mode 100644 index 0000000000000..b3c8b0549d7cd --- /dev/null +++ b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapAuditor.php @@ -0,0 +1,119 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\ImportMap; + +use Symfony\Component\AssetMapper\Exception\RuntimeException; +use Symfony\Component\AssetMapper\ImportMap\Resolver\PackageResolverInterface; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +class ImportMapAuditor +{ + private const AUDIT_URL = 'https://api.github.com/advisories'; + + private readonly HttpClientInterface $httpClient; + + public function __construct( + private readonly ImportMapConfigReader $configReader, + private readonly PackageResolverInterface $packageResolver, + HttpClientInterface $httpClient = null, + ) { + $this->httpClient = $httpClient ?? HttpClient::create(); + } + + /** + * @return list + */ + public function audit(): array + { + $entries = $this->configReader->getEntries(); + + if ([] === $entries) { + return []; + } + + /** @var array> $installed */ + $packageAudits = []; + + /** @var array> $installed */ + $installed = []; + $affectsQuery = []; + foreach ($entries as $entry) { + if (null === $entry->url) { + continue; + } + $version = $entry->version ?? $this->packageResolver->getPackageVersion($entry->url); + + $installed[$entry->importName] ??= []; + $installed[$entry->importName][] = $version; + + $packageVersion = $entry->importName.($version ? '@'.$version : ''); + $packageAudits[$packageVersion] ??= new ImportMapPackageAudit($entry->importName, $version); + $affectsQuery[] = $packageVersion; + } + + // @see https://docs.github.com/en/rest/security-advisories/global-advisories?apiVersion=2022-11-28#list-global-security-advisories + $response = $this->httpClient->request('GET', self::AUDIT_URL, [ + 'query' => ['affects' => implode(',', $affectsQuery)], + ]); + + if (200 !== $response->getStatusCode()) { + throw new RuntimeException(sprintf('Error %d auditing packages. Response: %s', $response->getStatusCode(), $response->getContent(false))); + } + + foreach ($response->toArray() as $advisory) { + foreach ($advisory['vulnerabilities'] ?? [] as $vulnerability) { + if ( + null === $vulnerability['package'] + || 'npm' !== $vulnerability['package']['ecosystem'] + || !array_key_exists($package = $vulnerability['package']['name'], $installed) + ) { + continue; + } + foreach ($installed[$package] as $version) { + if (!$version || !$this->versionMatches($version, $vulnerability['vulnerable_version_range'] ?? '>= *')) { + continue; + } + $packageAudits[$package.($version ? '@'.$version : '')] = $packageAudits[$package.($version ? '@'.$version : '')]->withVulnerability( + new ImportMapPackageAuditVulnerability( + $advisory['ghsa_id'], + $advisory['cve_id'], + $advisory['url'], + $advisory['summary'], + $advisory['severity'], + $vulnerability['vulnerable_version_range'], + $vulnerability['first_patched_version'], + ) + ); + } + } + } + + return array_values($packageAudits); + } + + private function versionMatches(string $version, string $ranges): bool + { + foreach (explode(',', $ranges) as $rangeString) { + $range = explode(' ', trim($rangeString)); + if (1 === count($range)) { + $range = ['=', $range[0]]; + } + + if (!version_compare($version, $range[1], $range[0])) { + return false; + } + } + + return true; + } +} diff --git a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapConfigReader.php b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapConfigReader.php index 482e5f9cce7e0..880e3c5381827 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapConfigReader.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapConfigReader.php @@ -38,7 +38,7 @@ public function getEntries(): ImportMapEntries $entries = new ImportMapEntries(); foreach ($importMapConfig ?? [] as $importName => $data) { - $validKeys = ['path', 'url', 'downloaded_to', 'type', 'entrypoint']; + $validKeys = ['path', 'url', 'downloaded_to', 'type', 'entrypoint', 'version']; if ($invalidKeys = array_diff(array_keys($data), $validKeys)) { throw new \InvalidArgumentException(sprintf('The following keys are not valid for the importmap entry "%s": "%s". Valid keys are: "%s".', $importName, implode('", "', $invalidKeys), implode('", "', $validKeys))); } @@ -57,6 +57,7 @@ public function getEntries(): ImportMapEntries isDownloaded: isset($data['downloaded_to']), type: $type, isEntrypoint: $isEntry, + version: $data['version'] ?? null, )); } @@ -83,6 +84,9 @@ public function writeEntries(ImportMapEntries $entries): void if ($entry->isEntrypoint) { $config['entrypoint'] = true; } + if ($entry->version) { + $config['version'] = $entry->version; + } $importMapConfig[$entry->importName] = $config; } diff --git a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapEntry.php b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapEntry.php index 3c651289a7a01..ee201585f5063 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapEntry.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapEntry.php @@ -28,6 +28,7 @@ public function __construct( public readonly bool $isDownloaded = false, public readonly ImportMapType $type = ImportMapType::JS, public readonly bool $isEntrypoint = false, + public readonly ?string $version = null, ) { } diff --git a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapPackageAudit.php b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapPackageAudit.php new file mode 100644 index 0000000000000..4b6aaf4f01f4f --- /dev/null +++ b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapPackageAudit.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\ImportMap; + +final class ImportMapPackageAudit +{ + public function __construct( + public readonly string $package, + public readonly ?string $version, + /** @var array */ + public readonly array $vulnerabilities = [], + ) { + } + + public function withVulnerability(ImportMapPackageAuditVulnerability $vulnerability): self + { + return new self( + $this->package, + $this->version, + [...$this->vulnerabilities, $vulnerability], + ); + } +} diff --git a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapPackageAuditVulnerability.php b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapPackageAuditVulnerability.php new file mode 100644 index 0000000000000..facbf1124d490 --- /dev/null +++ b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapPackageAuditVulnerability.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\ImportMap; + +final class ImportMapPackageAuditVulnerability +{ + public function __construct( + public readonly string $ghsaId, + public readonly ?string $cveId, + public readonly string $url, + public readonly string $summary, + public readonly string $severity, + public readonly ?string $vulnerableVersionRange, + public readonly ?string $firstPatchedVersion, + ) { + } +} diff --git a/src/Symfony/Component/AssetMapper/ImportMap/Resolver/JsDelivrEsmResolver.php b/src/Symfony/Component/AssetMapper/ImportMap/Resolver/JsDelivrEsmResolver.php index 2836a1c595e6b..b3911878ab7fa 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/Resolver/JsDelivrEsmResolver.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/Resolver/JsDelivrEsmResolver.php @@ -158,6 +158,15 @@ public function resolvePackages(array $packagesToRequire): array return array_values($resolvedPackages); } + public function getPackageVersion(string $url): ?string + { + if (1 === preg_match("#^https://cdn.jsdelivr.net/npm/(?(?:@[a-z0-9-~][a-z0-9-._~]*/)?[a-z0-9-~][a-z0-9-._~]*)@(?[\w\._-]+)(?/.*)?$#", $url, $matches)) { + return $matches['version']; + } + + return null; + } + /** * Parses the very specific import syntax used by jsDelivr. * diff --git a/src/Symfony/Component/AssetMapper/ImportMap/Resolver/JspmResolver.php b/src/Symfony/Component/AssetMapper/ImportMap/Resolver/JspmResolver.php index 0882c373fff06..80e0c4d35bd4f 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/Resolver/JspmResolver.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/Resolver/JspmResolver.php @@ -96,4 +96,13 @@ public function resolvePackages(array $packagesToRequire): array throw $e; } } + + public function getPackageVersion(string $url): ?string + { + if (1 === preg_match("#^https://ga.jspm.io/npm:(?(?:@[a-z0-9-~][a-z0-9-._~]*/)?[a-z0-9-~][a-z0-9-._~]*)@(?[\w\._-]+)(?/.*)?$#", $url, $matches)) { + return $matches['version']; + } + + return null; + } } diff --git a/src/Symfony/Component/AssetMapper/ImportMap/Resolver/PackageResolver.php b/src/Symfony/Component/AssetMapper/ImportMap/Resolver/PackageResolver.php index d4ec8a10029ad..b2757c005e8dd 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/Resolver/PackageResolver.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/Resolver/PackageResolver.php @@ -26,4 +26,9 @@ public function resolvePackages(array $packagesToRequire): array return $this->locator->get($this->provider) ->resolvePackages($packagesToRequire); } + + public function getPackageVersion(string $url): ?string + { + return $this->locator->get($this->provider)->getPackageVersion($url); + } } diff --git a/src/Symfony/Component/AssetMapper/ImportMap/Resolver/PackageResolverInterface.php b/src/Symfony/Component/AssetMapper/ImportMap/Resolver/PackageResolverInterface.php index 1698913ca5449..2613c13008d92 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/Resolver/PackageResolverInterface.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/Resolver/PackageResolverInterface.php @@ -26,4 +26,9 @@ interface PackageResolverInterface * @return ResolvedImportMapPackage[] The import map entries that should be added */ public function resolvePackages(array $packagesToRequire): array; + + /** + * Tries to extract the package's version from its URL. + */ + public function getPackageVersion(string $url): ?string; } diff --git a/src/Symfony/Component/AssetMapper/ImportMap/Resolver/ResolvedImportMapPackage.php b/src/Symfony/Component/AssetMapper/ImportMap/Resolver/ResolvedImportMapPackage.php index 27ee5741e67b2..ed8a6cb854727 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/Resolver/ResolvedImportMapPackage.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/Resolver/ResolvedImportMapPackage.php @@ -19,6 +19,7 @@ public function __construct( public readonly PackageRequireOptions $requireOptions, public readonly string $url, public readonly ?string $content = null, + public readonly ?string $version = null, ) { } } diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapAuditorTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapAuditorTest.php new file mode 100644 index 0000000000000..40d541559b11b --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapAuditorTest.php @@ -0,0 +1,197 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\Tests\ImportMap; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\AssetMapper\Exception\RuntimeException; +use Symfony\Component\AssetMapper\ImportMap\ImportMapAuditor; +use Symfony\Component\AssetMapper\ImportMap\ImportMapConfigReader; +use Symfony\Component\AssetMapper\ImportMap\ImportMapEntries; +use Symfony\Component\AssetMapper\ImportMap\ImportMapEntry; +use Symfony\Component\AssetMapper\ImportMap\ImportMapManager; +use Symfony\Component\AssetMapper\ImportMap\ImportMapPackageAudit; +use Symfony\Component\AssetMapper\ImportMap\ImportMapPackageAuditVulnerability; +use Symfony\Component\AssetMapper\ImportMap\Resolver\PackageResolver; +use Symfony\Component\AssetMapper\ImportMap\Resolver\PackageResolverInterface; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +class ImportMapAuditorTest extends TestCase +{ + private ImportMapConfigReader $importMapConfigReader; + private PackageResolverInterface $packageResolver; + private HttpClientInterface $httpClient; + private ImportMapAuditor $importMapAuditor; + + protected function setUp(): void + { + $this->importMapConfigReader = $this->createMock(ImportMapConfigReader::class); + $this->packageResolver = $this->createMock(PackageResolverInterface::class); + $this->httpClient = new MockHttpClient(); + $this->importMapAuditor = new ImportMapAuditor($this->importMapConfigReader, $this->packageResolver, $this->httpClient); + } + + public function testAudit() + { + $this->httpClient->setResponseFactory(new MockResponse(json_encode([ + [ + "ghsa_id" => "GHSA-abcd-1234-efgh", + "cve_id" => "CVE-2050-00000", + "url" => "https =>//api.github.com/repos/repo/a-package/security-advisories/GHSA-abcd-1234-efgh", + "summary" => "A short summary of the advisory.", + "severity" => "critical", + "vulnerabilities" => [ + [ + "package" => ["ecosystem" => "pip", "name" => "json5"], + "vulnerable_version_range" => ">= 1.0.0, < 1.0.1", + "first_patched_version" => "1.0.1", + ], + [ + "package" => ["ecosystem" => "npm", "name" => "json5"], + "vulnerable_version_range" => ">= 1.0.0, < 1.0.1", + "first_patched_version" => "1.0.1", + ], + [ + "package" => ["ecosystem" => "npm", "name" => "another-package"], + "vulnerable_version_range" => ">= 1.0.0, < 1.0.1", + "first_patched_version" => "1.0.2", + ], + ], + ], + ]))); + $this->importMapConfigReader->method('getEntries')->willReturn(new ImportMapEntries([ + '@hotwired/stimulus' => new ImportMapEntry( + importName: '@hotwired/stimulus', + url: 'https://unpkg.com/@hotwired/stimulus@3.2.1/dist/stimulus.js', + version: '3.2.1', + ), + 'json5' => new ImportMapEntry( + importName: 'json5', + url: 'https://cdn.jsdelivr.net/npm/json5@1.0.0/+esm', + version: '1.0.0', + ), + 'lodash' => new ImportMapEntry( + importName: 'lodash', + url: 'https://ga.jspm.io/npm:lodash@4.17.21/lodash.js', + version: '4.17.21', + ), + ])); + + $audit = $this->importMapAuditor->audit(); + + $this->assertEquals([ + new ImportMapPackageAudit('@hotwired/stimulus', '3.2.1'), + new ImportMapPackageAudit('json5', '1.0.0', [new ImportMapPackageAuditVulnerability( + 'GHSA-abcd-1234-efgh', + 'CVE-2050-00000', + 'https =>//api.github.com/repos/repo/a-package/security-advisories/GHSA-abcd-1234-efgh', + 'A short summary of the advisory.', + 'critical', + '>= 1.0.0, < 1.0.1', + '1.0.1', + )]), + new ImportMapPackageAudit('lodash', '4.17.21'), + ], $audit); + } + + /** + * @dataProvider provideAuditWithVersionRange + */ + public function testAuditWithVersionRange(bool $expectMatch, string $version, ?string $versionRange) + { + $this->httpClient->setResponseFactory(new MockResponse(json_encode([ + [ + "ghsa_id" => "GHSA-abcd-1234-efgh", + "cve_id" => "CVE-2050-00000", + "url" => "https =>//api.github.com/repos/repo/a-package/security-advisories/GHSA-abcd-1234-efgh", + "summary" => "A short summary of the advisory.", + "severity" => "critical", + "vulnerabilities" => [ + [ + "package" => ["ecosystem" => "npm", "name" => "json5"], + "vulnerable_version_range" => $versionRange, + "first_patched_version" => "1.0.1", + ], + ], + ], + ]))); + $this->importMapConfigReader->method('getEntries')->willReturn(new ImportMapEntries([ + 'json5' => new ImportMapEntry( + importName: 'json5', + url: "https://cdn.jsdelivr.net/npm/json5@$version/+esm", + version: $version, + ), + ])); + + $audit = $this->importMapAuditor->audit(); + + $this->assertSame($expectMatch, 0 < count($audit[0]->vulnerabilities)); + } + + public function provideAuditWithVersionRange(): iterable + { + yield [true, '1.0.0', null]; + yield [true, '1.0.0', '>= *']; + yield [true, '1.0.0', '< 1.0.1']; + yield [true, '1.0.0', '<= 1.0.0']; + yield [false, '1.0.0', '< 1.0.0']; + yield [true, '1.0.0', '= 1.0.0']; + yield [false, '1.0.0', '> 1.0.0, < 1.2.0']; + yield [true, '1.1.0', '> 1.0.0, < 1.2.0']; + yield [false, '1.2.0', '> 1.0.0, < 1.2.0']; + } + + public function testAuditWithVersionResolving() + { + $this->httpClient->setResponseFactory(new MockResponse('[]')); + $this->importMapConfigReader->method('getEntries')->willReturn(new ImportMapEntries([ + '@hotwired/stimulus' => new ImportMapEntry( + importName: '@hotwired/stimulus', + url: 'https://unpkg.com/@hotwired/stimulus/dist/stimulus.js', + version: '3.2.1', + ), + 'json5' => new ImportMapEntry( + importName: 'json5', + url: 'https://cdn.jsdelivr.net/npm/json5/+esm', + ), + 'lodash' => new ImportMapEntry( + importName: 'lodash', + url: 'https://ga.jspm.io/npm:lodash@4.17.21/lodash.js', + ), + ])); + $this->packageResolver->method('getPackageVersion')->willReturn('1.2.3'); + + $audit = $this->importMapAuditor->audit(); + + $this->assertSame('3.2.1', $audit[0]->version); + $this->assertSame('1.2.3', $audit[1]->version); + $this->assertSame('1.2.3', $audit[2]->version); + } + + public function testAuditError() + { + $this->httpClient->setResponseFactory(new MockResponse('Server error', ['http_code' => 500])); + $this->importMapConfigReader->method('getEntries')->willReturn(new ImportMapEntries([ + 'json5' => new ImportMapEntry( + importName: 'json5', + url: 'https://cdn.jsdelivr.net/npm/json5@1.0.0/+esm', + version: '1.0.0', + ), + ])); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Error 500 auditing packages. Response: Server error'); + + $this->importMapAuditor->audit(); + } +} diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/Resolver/JsDelivrEsmResolverTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/Resolver/JsDelivrEsmResolverTest.php index 6d1439cddc52b..220107953c0b3 100644 --- a/src/Symfony/Component/AssetMapper/Tests/ImportMap/Resolver/JsDelivrEsmResolverTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/Resolver/JsDelivrEsmResolverTest.php @@ -392,4 +392,21 @@ public static function provideImportRegex(): iterable ], ]; } + + /** + * @dataProvider provideGetPackageVersion + */ + public function testGetPackageVersion(string $url, ?string $expected) + { + $resolver = new JsDelivrEsmResolver(); + + $this->assertSame($expected, $resolver->getPackageVersion($url)); + } + + public static function provideGetPackageVersion(): iterable + { + yield 'with no result' => ['https://cdn.jsdelivr.net/npm/lodash.js/+esm', null]; + yield 'with a package name' => ['https://cdn.jsdelivr.net/npm/lodash.js@1.2.3/+esm', '1.2.3']; + yield 'with a dash in the package_name' => ['https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.7/+esm', '2.11.7']; + } } diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/Resolver/JspmResolverTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/Resolver/JspmResolverTest.php index f70e4e148c916..aa90991141454 100644 --- a/src/Symfony/Component/AssetMapper/Tests/ImportMap/Resolver/JspmResolverTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/Resolver/JspmResolverTest.php @@ -158,4 +158,21 @@ public static function provideResolvePackagesTests(): iterable 'expectedDownloadedFiles' => [], ]; } + + /** + * @dataProvider provideGetPackageVersion + */ + public function testGetPackageVersion(string $url, ?string $expected) + { + $resolver = new JspmResolver(); + + $this->assertSame($expected, $resolver->getPackageVersion($url)); + } + + public static function provideGetPackageVersion(): iterable + { + yield 'with no result' => ['https://ga.jspm.io/npm:lodash/lodash.js', null]; + yield 'with a package name' => ['https://ga.jspm.io/npm:lodash@1.2.3/lodash.js', '1.2.3']; + yield 'with a dash in the package_name' => ['https://ga.jspm.io/npm:lodash-dependency@9.8.7/lodash-dependency.js', '9.8.7']; + } }