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']; + } } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy