Skip to content

Commit f149841

Browse files
committed
feature #51650 [AssetMapper] Add audit command (Jean-Beru)
This PR was squashed before being merged into the 6.4 branch. Discussion ---------- [AssetMapper] Add audit command | Q | A | ------------- | --- | Branch? | 6.4 | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | | License | MIT | Doc PR | TODO After discussing with `@WebMamba`, I made this PR to introduce the `importmap:audit` command inspired from `yarn audit` and `npm audit`. It reads the `importmap.php` and extract packages version to query the `https://registry.npmjs.org/-/npm/v1/security/audits` API. ![image](https://github.com/symfony/symfony/assets/6114779/6fd7676e-3808-4ca1-98ca-a4d73b7e7753) ![image](https://github.com/symfony/symfony/assets/6114779/be0ffc7f-c62f-4e37-bdec-5c119a119a8d) Commits ------- 6d150fc [AssetMapper] Add audit command
2 parents 0c8cb7e + 6d150fc commit f149841

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)
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