Skip to content

Commit 6d150fc

Browse files
Jean-Berufabpot
authored andcommitted
[AssetMapper] Add audit command
1 parent 0b78897 commit 6d150fc

15 files changed

+643
-1
lines changed

src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Symfony\Component\AssetMapper\AssetMapperInterface;
1818
use Symfony\Component\AssetMapper\AssetMapperRepository;
1919
use Symfony\Component\AssetMapper\Command\AssetMapperCompileCommand;
20+
use Symfony\Component\AssetMapper\Command\ImportMapAuditCommand;
2021
use Symfony\Component\AssetMapper\Command\DebugAssetMapperCommand;
2122
use Symfony\Component\AssetMapper\Command\ImportMapInstallCommand;
2223
use Symfony\Component\AssetMapper\Command\ImportMapRemoveCommand;
@@ -27,6 +28,7 @@
2728
use Symfony\Component\AssetMapper\Compiler\SourceMappingUrlsCompiler;
2829
use Symfony\Component\AssetMapper\Factory\CachedMappedAssetFactory;
2930
use Symfony\Component\AssetMapper\Factory\MappedAssetFactory;
31+
use Symfony\Component\AssetMapper\ImportMap\ImportMapAuditor;
3032
use Symfony\Component\AssetMapper\ImportMap\ImportMapConfigReader;
3133
use Symfony\Component\AssetMapper\ImportMap\ImportMapManager;
3234
use Symfony\Component\AssetMapper\ImportMap\ImportMapRenderer;
@@ -193,6 +195,13 @@
193195
abstract_arg('script HTML attributes'),
194196
])
195197

198+
->set('asset_mapper.importmap.auditor', ImportMapAuditor::class)
199+
->args([
200+
service('asset_mapper.importmap.config_reader'),
201+
service('asset_mapper.importmap.resolver'),
202+
service('http_client'),
203+
])
204+
196205
->set('asset_mapper.importmap.command.require', ImportMapRequireCommand::class)
197206
->args([
198207
service('asset_mapper.importmap.manager'),
@@ -212,5 +221,9 @@
212221
->set('asset_mapper.importmap.command.install', ImportMapInstallCommand::class)
213222
->args([service('asset_mapper.importmap.manager')])
214223
->tag('console.command')
224+
225+
->set('asset_mapper.importmap.command.audit', ImportMapAuditCommand::class)
226+
->args([service('asset_mapper.importmap.auditor')])
227+
->tag('console.command')
215228
;
216229
};
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\AssetMapper\Command;
13+
14+
use Symfony\Component\AssetMapper\ImportMap\ImportMapAuditor;
15+
use Symfony\Component\AssetMapper\ImportMap\ImportMapPackageAuditVulnerability;
16+
use Symfony\Component\Console\Attribute\AsCommand;
17+
use Symfony\Component\Console\Command\Command;
18+
use Symfony\Component\Console\Input\InputInterface;
19+
use Symfony\Component\Console\Input\InputOption;
20+
use Symfony\Component\Console\Output\OutputInterface;
21+
use Symfony\Component\Console\Style\SymfonyStyle;
22+
23+
#[AsCommand(name: 'importmap:audit', description: 'Checks for security vulnerability advisories for dependencies.')]
24+
class ImportMapAuditCommand extends Command
25+
{
26+
private const SEVERITY_COLORS = [
27+
'critical' => 'red',
28+
'high' => 'red',
29+
'medium' => 'yellow',
30+
'low' => 'default',
31+
'unknown' => 'default',
32+
];
33+
34+
private SymfonyStyle $io;
35+
36+
public function __construct(
37+
private readonly ImportMapAuditor $importMapAuditor,
38+
) {
39+
parent::__construct();
40+
}
41+
42+
protected function configure(): void
43+
{
44+
$this->addOption(
45+
name: 'format',
46+
mode: InputOption::VALUE_REQUIRED,
47+
description: sprintf('The output format ("%s")', implode(', ', $this->getAvailableFormatOptions())),
48+
default: 'txt',
49+
);
50+
}
51+
52+
protected function initialize(InputInterface $input, OutputInterface $output): void
53+
{
54+
$this->io = new SymfonyStyle($input, $output);
55+
}
56+
57+
protected function execute(InputInterface $input, OutputInterface $output): int
58+
{
59+
$format = $input->getOption('format');
60+
61+
$audit = $this->importMapAuditor->audit();
62+
63+
return match ($format) {
64+
'txt' => $this->displayTxt($audit),
65+
'json' => $this->displayJson($audit),
66+
default => throw new \InvalidArgumentException(sprintf('Supported formats are "%s".', implode('", "', $this->getAvailableFormatOptions()))),
67+
};
68+
}
69+
70+
private function displayTxt(array $audit): int
71+
{
72+
$rows = [];
73+
74+
$packagesWithoutVersion = [];
75+
$vulnerabilitiesCount = array_map(fn() => 0, self::SEVERITY_COLORS);
76+
foreach ($audit as $packageAudit) {
77+
if (!$packageAudit->version) {
78+
$packagesWithoutVersion[] = $packageAudit->package;
79+
}
80+
foreach($packageAudit->vulnerabilities as $vulnerability) {
81+
$rows[] = [
82+
sprintf('<fg=%s>%s</>', self::SEVERITY_COLORS[$vulnerability->severity] ?? 'default', ucfirst($vulnerability->severity)),
83+
$vulnerability->summary,
84+
$packageAudit->package,
85+
$packageAudit->version ?? 'n/a',
86+
$vulnerability->firstPatchedVersion ?? 'n/a',
87+
$vulnerability->url,
88+
];
89+
++$vulnerabilitiesCount[$vulnerability->severity];
90+
}
91+
}
92+
$packagesCount = count($audit);
93+
$packagesWithoutVersionCount = count($packagesWithoutVersion);
94+
95+
if ([] === $rows && 0 === $packagesWithoutVersionCount) {
96+
$this->io->info('No vulnerabilities found.');
97+
98+
return self::SUCCESS;
99+
}
100+
101+
if ([] !== $rows) {
102+
$table = $this->io->createTable();
103+
$table->setHeaders([
104+
'Severity',
105+
'Title',
106+
'Package',
107+
'Version',
108+
'Patched in',
109+
'More info',
110+
]);
111+
$table->addRows($rows);
112+
$table->render();
113+
$this->io->newLine();
114+
}
115+
116+
$this->io->text(sprintf('%d package%s found: %d audited / %d skipped',
117+
$packagesCount,
118+
1 === $packagesCount ? '' : 's',
119+
$packagesCount - $packagesWithoutVersionCount,
120+
$packagesWithoutVersionCount,
121+
));
122+
123+
if (0 < $packagesWithoutVersionCount) {
124+
$this->io->warning(sprintf('Unable to retrieve versions for package%s: %s',
125+
1 === $packagesWithoutVersionCount ? '' : 's',
126+
implode(', ', $packagesWithoutVersion)
127+
));
128+
}
129+
130+
if ([] !== $rows) {
131+
$vulnerabilityCount = 0;
132+
$vulnerabilitySummary = [];
133+
foreach ($vulnerabilitiesCount as $severity => $count) {
134+
if (0 === $count) {
135+
continue;
136+
}
137+
$vulnerabilitySummary[] = sprintf( '%d %s', $count, ucfirst($severity));
138+
$vulnerabilityCount += $count;
139+
}
140+
$this->io->text(sprintf('%d vulnerabilit%s found: %s',
141+
$vulnerabilityCount,
142+
1 === $vulnerabilityCount ? 'y' : 'ies',
143+
implode(' / ', $vulnerabilitySummary),
144+
));
145+
}
146+
147+
return self::FAILURE;
148+
}
149+
150+
private function displayJson(array $audit): int
151+
{
152+
$vulnerabilitiesCount = array_map(fn() => 0, self::SEVERITY_COLORS);
153+
154+
$json = [
155+
'packages' => [],
156+
'summary' => $vulnerabilitiesCount,
157+
];
158+
159+
foreach ($audit as $packageAudit) {
160+
$json['packages'][] = [
161+
'package' => $packageAudit->package,
162+
'version' => $packageAudit->version,
163+
'vulnerabilities' => array_map(fn (ImportMapPackageAuditVulnerability $v) => [
164+
'ghsa_id' => $v->ghsaId,
165+
'cve_id' => $v->cveId,
166+
'url' => $v->url,
167+
'summary' => $v->summary,
168+
'severity' => $v->severity,
169+
'vulnerable_version_range' => $v->vulnerableVersionRange,
170+
'first_patched_version' => $v->firstPatchedVersion,
171+
], $packageAudit->vulnerabilities),
172+
];
173+
foreach ($packageAudit->vulnerabilities as $vulnerability) {
174+
++$json['summary'][$vulnerability->severity];
175+
}
176+
}
177+
178+
$this->io->write(json_encode($json));
179+
180+
return 0 < array_sum($json['summary']) ? self::FAILURE : self::SUCCESS;
181+
}
182+
183+
private function getAvailableFormatOptions(): array
184+
{
185+
return ['txt', 'json'];
186+
}
187+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\AssetMapper\ImportMap;
13+
14+
use Symfony\Component\AssetMapper\Exception\RuntimeException;
15+
use Symfony\Component\AssetMapper\ImportMap\Resolver\PackageResolverInterface;
16+
use Symfony\Component\HttpClient\HttpClient;
17+
use Symfony\Contracts\HttpClient\HttpClientInterface;
18+
19+
class ImportMapAuditor
20+
{
21+
private const AUDIT_URL = 'https://api.github.com/advisories';
22+
23+
private readonly HttpClientInterface $httpClient;
24+
25+
public function __construct(
26+
private readonly ImportMapConfigReader $configReader,
27+
private readonly PackageResolverInterface $packageResolver,
28+
HttpClientInterface $httpClient = null,
29+
) {
30+
$this->httpClient = $httpClient ?? HttpClient::create();
31+
}
32+
33+
/**
34+
* @return list<ImportMapPackageAudit>
35+
*/
36+
public function audit(): array
37+
{
38+
$entries = $this->configReader->getEntries();
39+
40+
if ([] === $entries) {
41+
return [];
42+
}
43+
44+
/** @var array<string, array<string, ImportMapPackageAudit>> $installed */
45+
$packageAudits = [];
46+
47+
/** @var array<string, list<string>> $installed */
48+
$installed = [];
49+
$affectsQuery = [];
50+
foreach ($entries as $entry) {
51+
if (null === $entry->url) {
52+
continue;
53+
}
54+
$version = $entry->version ?? $this->packageResolver->getPackageVersion($entry->url);
55+
56+
$installed[$entry->importName] ??= [];
57+
$installed[$entry->importName][] = $version;
58+
59+
$packageVersion = $entry->importName.($version ? '@'.$version : '');
60+
$packageAudits[$packageVersion] ??= new ImportMapPackageAudit($entry->importName, $version);
61+
$affectsQuery[] = $packageVersion;
62+
}
63+
64+
// @see https://docs.github.com/en/rest/security-advisories/global-advisories?apiVersion=2022-11-28#list-global-security-advisories
65+
$response = $this->httpClient->request('GET', self::AUDIT_URL, [
66+
'query' => ['affects' => implode(',', $affectsQuery)],
67+
]);
68+
69+
if (200 !== $response->getStatusCode()) {
70+
throw new RuntimeException(sprintf('Error %d auditing packages. Response: %s', $response->getStatusCode(), $response->getContent(false)));
71+
}
72+
73+
foreach ($response->toArray() as $advisory) {
74+
foreach ($advisory['vulnerabilities'] ?? [] as $vulnerability) {
75+
if (
76+
null === $vulnerability['package']
77+
|| 'npm' !== $vulnerability['package']['ecosystem']
78+
|| !array_key_exists($package = $vulnerability['package']['name'], $installed)
79+
) {
80+
continue;
81+
}
82+
foreach ($installed[$package] as $version) {
83+
if (!$version || !$this->versionMatches($version, $vulnerability['vulnerable_version_range'] ?? '>= *')) {
84+
continue;
85+
}
86+
$packageAudits[$package.($version ? '@'.$version : '')] = $packageAudits[$package.($version ? '@'.$version : '')]->withVulnerability(
87+
new ImportMapPackageAuditVulnerability(
88+
$advisory['ghsa_id'],
89+
$advisory['cve_id'],
90+
$advisory['url'],
91+
$advisory['summary'],
92+
$advisory['severity'],
93+
$vulnerability['vulnerable_version_range'],
94+
$vulnerability['first_patched_version'],
95+
)
96+
);
97+
}
98+
}
99+
}
100+
101+
return array_values($packageAudits);
102+
}
103+
104+
private function versionMatches(string $version, string $ranges): bool
105+
{
106+
foreach (explode(',', $ranges) as $rangeString) {
107+
$range = explode(' ', trim($rangeString));
108+
if (1 === count($range)) {
109+
$range = ['=', $range[0]];
110+
}
111+
112+
if (!version_compare($version, $range[1], $range[0])) {
113+
return false;
114+
}
115+
}
116+
117+
return true;
118+
}
119+
}

src/Symfony/Component/AssetMapper/ImportMap/ImportMapConfigReader.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public function getEntries(): ImportMapEntries
3838

3939
$entries = new ImportMapEntries();
4040
foreach ($importMapConfig ?? [] as $importName => $data) {
41-
$validKeys = ['path', 'url', 'downloaded_to', 'type', 'entrypoint'];
41+
$validKeys = ['path', 'url', 'downloaded_to', 'type', 'entrypoint', 'version'];
4242
if ($invalidKeys = array_diff(array_keys($data), $validKeys)) {
4343
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)));
4444
}
@@ -57,6 +57,7 @@ public function getEntries(): ImportMapEntries
5757
isDownloaded: isset($data['downloaded_to']),
5858
type: $type,
5959
isEntrypoint: $isEntry,
60+
version: $data['version'] ?? null,
6061
));
6162
}
6263

@@ -83,6 +84,9 @@ public function writeEntries(ImportMapEntries $entries): void
8384
if ($entry->isEntrypoint) {
8485
$config['entrypoint'] = true;
8586
}
87+
if ($entry->version) {
88+
$config['version'] = $entry->version;
89+
}
8690
$importMapConfig[$entry->importName] = $config;
8791
}
8892

src/Symfony/Component/AssetMapper/ImportMap/ImportMapEntry.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public function __construct(
2828
public readonly bool $isDownloaded = false,
2929
public readonly ImportMapType $type = ImportMapType::JS,
3030
public readonly bool $isEntrypoint = false,
31+
public readonly ?string $version = null,
3132
) {
3233
}
3334

0 commit comments

Comments
 (0)