Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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
14 changes: 12 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -41,19 +41,29 @@
},
"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"
},
"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"
}
}
}
161 changes: 161 additions & 0 deletions src/Components/ComposerAudit/ComposerGraphService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php

declare(strict_types=1);

namespace Frosh\Tools\Components\ComposerAudit;

use Clue\GraphComposer\Graph\Filter as GraphComposerFilter;
use Clue\GraphComposer\Graph\GraphComposer;
use Fhaculty\Graph\Attribute\AttributeBagNamespaced;
use Fhaculty\Graph\Graph;
use Graphp\GraphViz\GraphViz;
use Shopware\Core\Framework\Adapter\Cache\CacheValueCompressor;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

class ComposerGraphService
{
public const CACHE_KEY = 'frosh-tools-composer-graph';
public const CACHE_TTL_SECONDS = 3600;

/**
* @param array<array{'active': string, 'composerName': string}> $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<string> $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'];

if (!$graph->hasVertex($packageName)) {
return;
}

$vertex = $graph->getVertex($packageName);
Comment thread
mromeike marked this conversation as resolved.
$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);
}
}
16 changes: 16 additions & 0 deletions src/Controller/ComposerAuditController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,19 @@
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']])]
class ComposerAuditController extends AbstractController
{
public function __construct(
private readonly ComposerAuditService $composerAuditService,
private readonly ComposerGraphService $composerGraphService,
) {
}

Expand All @@ -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',
]);
}
}
8 changes: 8 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ public function getConfigTreeBuilder(): TreeBuilder
->end()
->end()
->end()
->arrayNode('composer')
->addDefaultsIfNotSet()
->children()
->stringNode('graphviz_path')
->defaultNull()
->end()
->end()
->end()
->end()
;

Expand Down
12 changes: 12 additions & 0 deletions src/Resources/app/administration/src/api/frosh-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, refresh: forceRefresh },
})
.then((response) => {
return ApiService.handleResponse(response);
});
}

getSecurityStatus() {
const apiRoute = `${this.getApiBasePath()}/security/status`;
return this.httpClient
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ Component.register('frosh-tools-security-dependencies', {
error: null,
cachedAt: null,
},
isLoadingGraph: false,
graphData: null,
};
},

Expand Down Expand Up @@ -97,6 +99,9 @@ Component.register('frosh-tools-security-dependencies', {
cachedAt: null,
};
} finally {
this.isLoadingGraph = false;
this.graphData = null;

this.isLoading = false;
}
},
Expand Down Expand Up @@ -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;
}
},
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,35 @@
v-if="hasAdvisories"
class="frosh-security-dependencies__list"
>
<div class="frosh-security-dependencies__graph-frame">
<img v-if="!!graphData"
class="frosh-security-dependencies__graph-image"
:src="graphData"
:alt="$t('frosh-tools.tabs.composerAudit.graph.imageAlt')"
/>

<div>
<ft-refresh-button
:loading="isLoadingGraph"
:label="!!graphData
? $t('frosh-tools.tabs.composerAudit.graph.reload')
: $t('frosh-tools.tabs.composerAudit.graph.load')
"
@click="loadGraph(!!graphData)"
/>

<ft-button
v-if="!!graphData"
icon="trash"
@click="graphData = null"
>
<span>
{{ $t('frosh-tools.tabs.composerAudit.graph.unload') }}
</span>
</ft-button>
</div>
</div>

<div
v-for="group in groupedAdvisories"
:key="group.packageName"
Expand Down Expand Up @@ -243,4 +272,4 @@
</div>
</div>
</ft-panel>
</div>
</div>
Loading