From 81fe534e5eb967e6149f9ebb7e6d7c7e403e50e5 Mon Sep 17 00:00:00 2001 From: Marcel Romeike Date: Wed, 19 Aug 2026 00:39:13 +0200 Subject: [PATCH 1/3] Add experimental composer-graph endpoint to composer audit module **Details**: The `/api/_action/frosh-tools/composer-graph` endpoint provides an SVG dependency graph of shopware core packages and any additionally provided `packages`. The dev-dependencies are not included by default. Caches are used if possible, SVG results are cached for `3800s`, refresh may be forced. Standard cache compression is supported. Any vulnerable packages are marked red in the dependency graph generated through `dot` (graphviz). A new configuration parameter `composer.graphviz_path` can be used to set a custom dist path (useful for non-PATH setups). A Shopware 6.6 compatible Dockerfile with `graphwiz` support is provided. --- Dockerfile | 9 + composer.json | 14 +- .../ComposerAudit/ComposerGraphService.php | 156 ++++++++++++++++++ src/Controller/ComposerAuditController.php | 16 ++ src/DependencyInjection/Configuration.php | 8 + 5 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 Dockerfile create mode 100644 src/Components/ComposerAudit/ComposerGraphService.php diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..fcbf641c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +ARG PHP_VERSION=8.3 +ARG NODE_VERSION=24 +ARG WEBSERVER=caddy + +FROM ghcr.io/shopware/docker-dev:php${PHP_VERSION}-node${NODE_VERSION}-${WEBSERVER} AS base-image + +USER root +RUN apk add graphviz +USER www-data diff --git a/composer.json b/composer.json index 2df86e88..2645d43f 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "frosh/tools", - "version": "3.12.0", + "version": "dev-feat/composer-graph", "description": "Provides some basic things for managing the Shopware Installation", "type": "shopware-platform-plugin", "license": "MIT", @@ -41,7 +41,8 @@ }, "require": { "shopware/core": "~6.6.0 || ~6.7.0", - "symfony/var-dumper": "^6.0 || ^7.0 || ^8.0" + "symfony/var-dumper": "^6.0 || ^7.0 || ^8.0", + "clue/graph-composer": "dev-feature/filter" }, "require-dev": { "shopware/elasticsearch": "~6.6.0 || ~6.7.0" @@ -49,11 +50,20 @@ "config": { "allow-plugins": { "symfony/runtime": true + }, + "platform": { + "php": "8.3" } }, "scripts": { "format": "docker run --rm -v $(pwd):/ext shopware/shopware-cli:latest extension format /ext", "check": "docker run --rm -v $(pwd):/ext shopware/shopware-cli:latest extension validate --full /ext", "phpunit": "../../../vendor/bin/phpunit -c phpunit.xml" + }, + "repositories": { + "mromeike/graph-composer": { + "type": "git", + "url": "https://github.com/mromeike/graph-composer.git" + } } } diff --git a/src/Components/ComposerAudit/ComposerGraphService.php b/src/Components/ComposerAudit/ComposerGraphService.php new file mode 100644 index 00000000..b0fd0c23 --- /dev/null +++ b/src/Components/ComposerAudit/ComposerGraphService.php @@ -0,0 +1,156 @@ + $plugins + */ + public function __construct( + #[Autowire(param: 'kernel.project_dir')] + private readonly string $projectDir, + + private readonly CacheInterface $cacheObject, + private readonly ComposerAuditService $composerAuditService, + + #[Autowire(param: 'kernel.plugin_infos')] + private readonly array $plugins, + + #[Autowire(param: 'frosh_tools.composer.graphviz_path')] + private readonly ?string $graphvizExecutablePath = null, + ) { + } + + /** + * Return a file path to the SVG graph. + * + * @param array $packages + */ + public function graph( + array $packages = [], + bool $withDevPackages = false, + bool $strict = false, + bool $forceRefresh = false, + ): string + { + $plugins = \array_filter($this->plugins, + static fn ($plugin) => !$plugin['active'] || !$plugin['managedByComposer']); + $audit = $this->composerAuditService->audit($forceRefresh); + + \array_push($packages, + 'store.shopware.com/*', + 'shopware/*', + 'frosh/*', + ...\array_column($plugins, 'composerName'), + ...\array_column($audit['advisories'], 'packageName'), + ); + + $packages = \array_unique($packages); + + \sort($packages); + $cacheKey = self::CACHE_KEY + . '_(' . \md5(\implode(',', $packages) . ($withDevPackages ? ')_dev' : ')')); + + if ($forceRefresh) { + $this->cacheObject->delete($cacheKey); + } + + $data = $this->cacheObject->get($cacheKey, function (ItemInterface $cacheItem) use ($audit, $packages, $withDevPackages, $strict, $forceRefresh): string { + $cacheItem->expiresAfter(self::CACHE_TTL_SECONDS); + + // This pretty much does what `$this->graphviz->createImageData($graph)` does, while caching the graph SVG data. + $file = $this->createGraph($audit, $packages, $withDevPackages, $strict); + $data = \file_get_contents($file); + \unlink($file); + + // Use compression, if enabled. + return CacheValueCompressor::compress($data); + }); + + return CacheValueCompressor::uncompress($data); + } + + private function createGraph( + array $audit, + array $packages, + bool $withDevPackages, + bool $strict, + ): string + { + $graphviz = new GraphViz(); + $graphviz->setFormat('svg'); + + if (\is_string($this->graphvizExecutablePath) + && \is_executable($this->graphvizExecutablePath) + ) { + $graphviz->setExecutable($this->graphvizExecutablePath); + } + + $graphComposer = new GraphComposer($this->projectDir, $graphviz); + $graph = $graphComposer->createGraph( + GraphComposerFilter::createFilter($packages, 0, $withDevPackages, $strict) + ); + + if (0 < $audit['vulnerable'] && !isset($audit['error'])) { + $severityLimit = 'high'; + $severityOrder = ['critical' => 0, 'high' => 1, 'medium' => 2, 'moderate' => 2, 'low' => 3, '' => 4]; + + // Style definition for red fill in vulnerable packages. + $layout = [ + 'style' => 'filled, rounded', + 'fillcolor' => '#ffcccc', + 'fontcolor' => '#314B5F' + ]; + + foreach ($audit['advisories'] as $advisory) { + // Skip on `severity > 1` or if package name is not mentioned in list of packages to show. + if (($severityOrder[$severityLimit] < $severityOrder[$advisory['severity']] ?? 4) + || !\in_array($advisory['packageName'], $packages, true) + ) { + continue; + } + + $this->setGraphLayout($graph, $advisory, $layout); + } + } + + return $graphviz->createImageFile($graph); + } + + /** + * @param array{'packageName': string, 'cve': ?string, 'advisoryId': ?string} $advisory + */ + private function setGraphLayout(Graph $graph, array $advisory, array $layout): void + { + $packageName = $advisory['packageName']; + $vertex = $graph->getVertex($packageName); + $bag = new AttributeBagNamespaced($vertex->getAttributeBag(), 'graphviz.'); + $identifier = $advisory['cve'] ?: $advisory['advisoryId']; + + if ($identifier) { + // Label definition, containing EOL and CVE identifier. + $label = [ + 'label' => $bag->getAttribute('label', $packageName) + . \PHP_EOL . '(' . $identifier . ')', + ]; + $layout = $layout + $label; + } + $bag->setAttributes($layout); + } +} diff --git a/src/Controller/ComposerAuditController.php b/src/Controller/ComposerAuditController.php index 156100b6..5e84b57e 100644 --- a/src/Controller/ComposerAuditController.php +++ b/src/Controller/ComposerAuditController.php @@ -5,9 +5,11 @@ namespace Frosh\Tools\Controller; use Frosh\Tools\Components\ComposerAudit\ComposerAuditService; +use Frosh\Tools\Components\ComposerAudit\ComposerGraphService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; #[Route(path: '/api/_action/frosh-tools', defaults: ['_routeScope' => ['api'], '_acl' => ['frosh_tools:read']])] @@ -15,6 +17,7 @@ class ComposerAuditController extends AbstractController { public function __construct( private readonly ComposerAuditService $composerAuditService, + private readonly ComposerGraphService $composerGraphService, ) { } @@ -25,4 +28,17 @@ public function audit(Request $request): JsonResponse return new JsonResponse($this->composerAuditService->audit($forceRefresh)); } + + #[Route(path: '/composer-graph', name: 'api.frosh.tools.composer-graph', methods: ['GET'])] + public function graph(Request $request): Response + { + $packages = \array_filter((array)$request->query->all('packages'), 'is_string'); + $withDevPackages = $request->query->getBoolean('withDevDependencies'); + $strict = $request->query->getBoolean('strict', true); + $forceRefresh = $request->query->getBoolean('refresh'); + + return new Response($this->composerGraphService->graph($packages, $withDevPackages, $strict, $forceRefresh), headers: [ + 'Content-Type' => 'image/svg+xml', + ]); + } } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 0a815168..86371b43 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -42,6 +42,14 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() + ->arrayNode('composer') + ->addDefaultsIfNotSet() + ->children() + ->stringNode('graphviz_path') + ->defaultNull() + ->end() + ->end() + ->end() ->end() ; From 112e87dc4594f9e5e82fde1aefe063f4c27e1776 Mon Sep 17 00:00:00 2001 From: Marcel Romeike Date: Thu, 20 Aug 2026 22:17:54 +0200 Subject: [PATCH 2/3] Add experimental composer graph in admin UI --- .../app/administration/src/api/frosh-tools.js | 12 +++++++ .../index.js | 25 +++++++++++++++ .../style.scss | 17 ++++++++++ .../template.twig | 31 ++++++++++++++++++- .../src/module/frosh-tools/snippet/de-DE.json | 6 ++++ .../src/module/frosh-tools/snippet/en-GB.json | 6 ++++ 6 files changed, 96 insertions(+), 1 deletion(-) diff --git a/src/Resources/app/administration/src/api/frosh-tools.js b/src/Resources/app/administration/src/api/frosh-tools.js index b2a60939..f0212278 100644 --- a/src/Resources/app/administration/src/api/frosh-tools.js +++ b/src/Resources/app/administration/src/api/frosh-tools.js @@ -291,6 +291,18 @@ class FroshTools extends ApiService { }); } + getComposerGraph(packages = [], withDevDependencies = false, strict = true, forceRefresh = false) { + const apiRoute = `${this.getApiBasePath()}/composer-graph`; + return this.httpClient + .get(apiRoute, { + headers: this.getBasicHeaders(), + params: { packages, withDevDependencies, strict, forceRefresh }, + }) + .then((response) => { + return ApiService.handleResponse(response); + }); + } + getSecurityStatus() { const apiRoute = `${this.getApiBasePath()}/security/status`; return this.httpClient diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-security-dependencies/index.js b/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-security-dependencies/index.js index 0abfb769..70217f23 100644 --- a/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-security-dependencies/index.js +++ b/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-security-dependencies/index.js @@ -20,6 +20,8 @@ Component.register('frosh-tools-security-dependencies', { error: null, cachedAt: null, }, + isLoadingGraph: false, + graphData: null, }; }, @@ -97,6 +99,9 @@ Component.register('frosh-tools-security-dependencies', { cachedAt: null, }; } finally { + this.isLoadingGraph = false; + this.graphData = null; + this.isLoading = false; } }, @@ -167,5 +172,25 @@ Component.register('frosh-tools-security-dependencies', { }); } }, + + async loadGraph(forceRefresh = false) { + this.isLoadingGraph = true; + try { + const packages = this.groupedAdvisories.map((advisory) => advisory.packageName); + + // TODO: Remove base64 encoding, use `data:image/svg+xml;charset=utf-8,<%3Fxml%20version%3D...` instead. + this.graphData = 'data:image/svg+xml;base64,' + window.btoa( + await this.froshToolsService.getComposerGraph([], true, true, forceRefresh) + ); + } catch { + this.createNotificationError({ + message: 'Graph', + }); + + this.graphData = null; + } finally { + this.isLoadingGraph = false; + } + }, }, }); diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-security-dependencies/style.scss b/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-security-dependencies/style.scss index 896bade7..deed48df 100644 --- a/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-security-dependencies/style.scss +++ b/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-security-dependencies/style.scss @@ -284,3 +284,20 @@ color: var(--ft-text-muted); } } + +.frosh-security-dependencies__graph { + &-frame { + display: flex; + align-items: center; + gap: 10px; + flex-direction: column; + margin-bottom: 10px; + } + + &-image { + height: 250px; + width: 100%; + display: block; + object-fit: contain; + } +} diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-security-dependencies/template.twig b/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-security-dependencies/template.twig index e603bba0..70a9322e 100644 --- a/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-security-dependencies/template.twig +++ b/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-security-dependencies/template.twig @@ -163,6 +163,35 @@ v-if="hasAdvisories" class="frosh-security-dependencies__list" > +
+ + +
+ + + + + {{ $t('frosh-tools.tabs.composerAudit.graph.unload') }} + + +
+
+
- \ No newline at end of file + diff --git a/src/Resources/app/administration/src/module/frosh-tools/snippet/de-DE.json b/src/Resources/app/administration/src/module/frosh-tools/snippet/de-DE.json index edaa981c..a86a5638 100644 --- a/src/Resources/app/administration/src/module/frosh-tools/snippet/de-DE.json +++ b/src/Resources/app/administration/src/module/frosh-tools/snippet/de-DE.json @@ -256,6 +256,12 @@ "step4": "Committen Sie die aktualisierte composer.lock und deployen Sie. Die obige Prüfung läuft automatisch erneut (bis zu 1 Stunde zwischengespeichert — nutzen Sie Aktualisieren, um sie zu erzwingen).", "copy": "In die Zwischenablage kopieren", "note": "Manche Advisories werden erst in einer neueren Major-Version behoben. Falls eine Einschränkung in Ihrer composer.json das Update blockiert, müssen Sie diese ggf. anheben (z. B. \"^2.0\"), bevor Composer das gepatchte Release installieren kann." + }, + "graph": { + "load": "Abhängigkeits-Diagramm laden", + "reload": "Abhängigkeits-Diagramm neu laden", + "unload": "Abhängigkeits-Diagramm verbergen", + "imageAlt": "Abhängigkeits-Diagramm" } }, "state-machines": { diff --git a/src/Resources/app/administration/src/module/frosh-tools/snippet/en-GB.json b/src/Resources/app/administration/src/module/frosh-tools/snippet/en-GB.json index bc2ca433..aaa06e0c 100644 --- a/src/Resources/app/administration/src/module/frosh-tools/snippet/en-GB.json +++ b/src/Resources/app/administration/src/module/frosh-tools/snippet/en-GB.json @@ -256,6 +256,12 @@ "step4": "Commit the updated composer.lock and deploy. The check above re-runs automatically (cached up to 1 hour — use Refresh to force it).", "copy": "Copy to clipboard", "note": "Some advisories are fixed only in a newer major version. If a constraint in your composer.json blocks the update, you may need to raise it (e.g. \"^2.0\") before Composer can install the patched release." + }, + "graph": { + "load": "Load dependency graph", + "reload": "Reload dependency graph", + "unload": "Hide dependency graph", + "imageAlt": "Dependency graph" } }, "state-machines": { From 9d8f21ac4063f47a79e61f0992dd36cb339d1bcc Mon Sep 17 00:00:00 2001 From: Marcel Romeike Date: Sat, 22 Aug 2026 15:28:36 +0200 Subject: [PATCH 3/3] Fix cache refresh of graphs and unchecked error --- src/Components/ComposerAudit/ComposerGraphService.php | 5 +++++ src/Resources/app/administration/src/api/frosh-tools.js | 2 +- .../frosh-tools-security-dependencies/template.twig | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Components/ComposerAudit/ComposerGraphService.php b/src/Components/ComposerAudit/ComposerGraphService.php index b0fd0c23..625e81df 100644 --- a/src/Components/ComposerAudit/ComposerGraphService.php +++ b/src/Components/ComposerAudit/ComposerGraphService.php @@ -139,6 +139,11 @@ private function createGraph( private function setGraphLayout(Graph $graph, array $advisory, array $layout): void { $packageName = $advisory['packageName']; + + if (!$graph->hasVertex($packageName)) { + return; + } + $vertex = $graph->getVertex($packageName); $bag = new AttributeBagNamespaced($vertex->getAttributeBag(), 'graphviz.'); $identifier = $advisory['cve'] ?: $advisory['advisoryId']; diff --git a/src/Resources/app/administration/src/api/frosh-tools.js b/src/Resources/app/administration/src/api/frosh-tools.js index f0212278..0767a3dc 100644 --- a/src/Resources/app/administration/src/api/frosh-tools.js +++ b/src/Resources/app/administration/src/api/frosh-tools.js @@ -296,7 +296,7 @@ class FroshTools extends ApiService { return this.httpClient .get(apiRoute, { headers: this.getBasicHeaders(), - params: { packages, withDevDependencies, strict, forceRefresh }, + params: { packages, withDevDependencies, strict, refresh: forceRefresh }, }) .then((response) => { return ApiService.handleResponse(response); diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-security-dependencies/template.twig b/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-security-dependencies/template.twig index 70a9322e..935bace6 100644 --- a/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-security-dependencies/template.twig +++ b/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-security-dependencies/template.twig @@ -177,7 +177,7 @@ ? $t('frosh-tools.tabs.composerAudit.graph.reload') : $t('frosh-tools.tabs.composerAudit.graph.load') " - @click="loadGraph()" + @click="loadGraph(!!graphData)" />