From 95d629497a820caf6a4021aeefd4915d010a3cd3 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Mon, 19 Oct 2026 16:38:19 +0200 Subject: [PATCH 01/35] fix: pass SortDirection to Doctrine orderBy A string direction is deprecated in doctrine/orm 3. --- src/Admin/AdvisoryCountSort.php | 2 +- src/Admin/SemverSort.php | 4 ++-- src/Command/ReplayDetectionResultsCommand.php | 2 +- src/Repository/GitRepoRepository.php | 4 ++-- src/Repository/ProjectRepository.php | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Admin/AdvisoryCountSort.php b/src/Admin/AdvisoryCountSort.php index 3dee35b3..5812d5e5 100644 --- a/src/Admin/AdvisoryCountSort.php +++ b/src/Admin/AdvisoryCountSort.php @@ -41,6 +41,6 @@ public static function apply(QueryBuilder $qb, SearchDto $searchDto): void self::PROPERTY )); - $qb->addOrderBy(self::PROPERTY, $direction); + $qb->addOrderBy(self::PROPERTY, SortOrder::DESC === $direction ? \SortDirection::Descending : \SortDirection::Ascending); } } diff --git a/src/Admin/SemverSort.php b/src/Admin/SemverSort.php index 3276d331..93b3f1c6 100644 --- a/src/Admin/SemverSort.php +++ b/src/Admin/SemverSort.php @@ -32,9 +32,9 @@ public static function apply(QueryBuilder $qb, string ...$properties): void foreach ($orderByParts as $orderBy) { foreach ($orderBy->getParts() as $part) { if (preg_match('/^(.+?)\s+(ASC|DESC)$/i', $part, $m)) { - [$expr, $dir] = [$m[1], strtoupper($m[2])]; + [$expr, $dir] = [$m[1], 'DESC' === strtoupper($m[2]) ? \SortDirection::Descending : \SortDirection::Ascending]; } else { - [$expr, $dir] = [$part, 'ASC']; + [$expr, $dir] = [$part, \SortDirection::Ascending]; } $expr = $needles[$expr] ?? $expr; diff --git a/src/Command/ReplayDetectionResultsCommand.php b/src/Command/ReplayDetectionResultsCommand.php index 0b4cbee2..82a5aeba 100644 --- a/src/Command/ReplayDetectionResultsCommand.php +++ b/src/Command/ReplayDetectionResultsCommand.php @@ -74,7 +74,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $queryBuilder = $this->entityManager->createQueryBuilder() ->select('r.id') ->from(DetectionResult::class, 'r') - ->orderBy('r.id', 'ASC'); + ->orderBy('r.id', \SortDirection::Ascending); $criteria = []; diff --git a/src/Repository/GitRepoRepository.php b/src/Repository/GitRepoRepository.php index 58384d00..d1dae337 100644 --- a/src/Repository/GitRepoRepository.php +++ b/src/Repository/GitRepoRepository.php @@ -42,8 +42,8 @@ public function findReposWithAdvisoryCount(): array ->innerJoin('pv.advisories', 'a') ->groupBy('r.id') ->having('COUNT(DISTINCT a.id) > 0') - ->orderBy('r.organization', 'ASC') - ->addOrderBy('r.repo', 'ASC') + ->orderBy('r.organization', \SortDirection::Ascending) + ->addOrderBy('r.repo', \SortDirection::Ascending) ->getQuery() ->getResult(); diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php index 0bd869f6..fd12f06b 100644 --- a/src/Repository/ProjectRepository.php +++ b/src/Repository/ProjectRepository.php @@ -38,7 +38,7 @@ public function findByGitRepo(GitRepo $repo): array ->leftJoin('p.serviceAgreements', 'sc') ->where(':repoId MEMBER OF p.gitRepos') ->setParameter('repoId', $repo->getId(), 'ulid') - ->orderBy('p.name', 'ASC') + ->orderBy('p.name', \SortDirection::Ascending) ->getQuery() ->getResult(); From 4fe28ffddba8fa0aa30034ac5f316b0a6ccf19fd Mon Sep 17 00:00:00 2001 From: turegjorup Date: Mon, 19 Oct 2026 16:38:19 +0200 Subject: [PATCH 02/35] fix: run migrations without a transaction MariaDB commits implicitly on every schema change, so the wrapping transaction was already committed and doctrine/migrations deprecates silencing that. --- CHANGELOG.md | 4 ++++ config/packages/doctrine_migrations.yaml | 3 +++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f52e3d0d..14016969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- [#164](https://github.com/itk-dev/devops_itksites/pull/164) + Clear two Doctrine deprecations: pass `SortDirection` to `orderBy()`, and + run migrations without a transaction, which MariaDB never honoured + ## [1.18.0] - 2026-10-19 - [#162](https://github.com/itk-dev/devops_itksites/pull/162) diff --git a/config/packages/doctrine_migrations.yaml b/config/packages/doctrine_migrations.yaml index 29231d94..e8550726 100644 --- a/config/packages/doctrine_migrations.yaml +++ b/config/packages/doctrine_migrations.yaml @@ -4,3 +4,6 @@ doctrine_migrations: # as migrations classes should NOT be autoloaded 'DoctrineMigrations': '%kernel.project_dir%/migrations' enable_profiler: false + # MariaDB commits implicitly on every schema change, so a wrapping + # transaction never held; doctrine/migrations deprecates relying on it. + transactional: false From 124ea6ab3c01248b6374fbb529741477deded695 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Mon, 19 Oct 2026 17:33:57 +0200 Subject: [PATCH 03/35] fix: update composer packages --- composer.lock | 705 ++++++++++++++++++++++--------------------- config/reference.php | 2 +- 2 files changed, 355 insertions(+), 352 deletions(-) diff --git a/composer.lock b/composer.lock index d51f730d..a774edfa 100644 --- a/composer.lock +++ b/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "api-platform/core", - "version": "v5.0.1", + "version": "v5.0.4", "source": { "type": "git", "url": "https://github.com/api-platform/core.git", - "reference": "1118457c160ab827c58047850949e23c80b4895d" + "reference": "1eef9e4f1e899af2f8439ecafc3e91a705516159" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/core/zipball/1118457c160ab827c58047850949e23c80b4895d", - "reference": "1118457c160ab827c58047850949e23c80b4895d", + "url": "https://api.github.com/repos/api-platform/core/zipball/1eef9e4f1e899af2f8439ecafc3e91a705516159", + "reference": "1eef9e4f1e899af2f8439ecafc3e91a705516159", "shasum": "" }, "require": { @@ -147,6 +147,7 @@ "suggest": { "doctrine/mongodb-odm-bundle": "To support MongoDB. Only versions 4.0 and later are supported.", "elasticsearch/elasticsearch": "To support Elasticsearch.", + "ext-deepclone": "To speed up the PHP files metadata cache with symfony/var-exporter 8.1+ (install symfony/deepclone with PIE).", "opensearch-project/opensearch-php": "To support OpenSearch (^2.5).", "phpstan/phpdoc-parser": "To support extracting metadata from PHPDoc.", "psr/cache-implementation": "To use metadata caching.", @@ -221,22 +222,22 @@ ], "support": { "issues": "https://github.com/api-platform/core/issues", - "source": "https://github.com/api-platform/core/tree/v5.0.1" + "source": "https://github.com/api-platform/core/tree/v5.0.4" }, - "time": "2026-09-25T19:37:39+00:00" + "time": "2026-10-16T14:57:02+00:00" }, { "name": "composer/semver", - "version": "3.5.0", + "version": "3.5.1", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5" + "reference": "528948210cc46f967a4b98e83b19e76f2b504993" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5", - "reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5", + "url": "https://api.github.com/repos/composer/semver/zipball/528948210cc46f967a4b98e83b19e76f2b504993", + "reference": "528948210cc46f967a4b98e83b19e76f2b504993", "shasum": "" }, "require": { @@ -288,7 +289,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.5.0" + "source": "https://github.com/composer/semver/tree/3.5.1" }, "funding": [ { @@ -300,7 +301,7 @@ "type": "github" } ], - "time": "2026-09-24T14:38:51+00:00" + "time": "2026-10-05T13:12:12+00:00" }, { "name": "doctrine/collections", @@ -1004,16 +1005,16 @@ }, { "name": "doctrine/lexer", - "version": "3.0.2", + "version": "3.0.3", "source": { "type": "git", "url": "https://github.com/doctrine/lexer.git", - "reference": "e96fe45e92a54233726014a7cc7340abf29bb14c" + "reference": "71d305eaa6480d3d3460c7d053d1da7749fa9cca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/lexer/zipball/e96fe45e92a54233726014a7cc7340abf29bb14c", - "reference": "e96fe45e92a54233726014a7cc7340abf29bb14c", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/71d305eaa6480d3d3460c7d053d1da7749fa9cca", + "reference": "71d305eaa6480d3d3460c7d053d1da7749fa9cca", "shasum": "" }, "require": { @@ -1059,7 +1060,7 @@ ], "support": { "issues": "https://github.com/doctrine/lexer/issues", - "source": "https://github.com/doctrine/lexer/tree/3.0.2" + "source": "https://github.com/doctrine/lexer/tree/3.0.3" }, "funding": [ { @@ -1075,20 +1076,20 @@ "type": "tidelift" } ], - "time": "2026-06-14T20:44:06+00:00" + "time": "2026-10-01T06:59:29+00:00" }, { "name": "doctrine/migrations", - "version": "3.9.7", + "version": "3.9.8", "source": { "type": "git", "url": "https://github.com/doctrine/migrations.git", - "reference": "96cb2a89b56c9efb0bac38e606dc0b0f13e650ec" + "reference": "eb0b9ce3f999d720fa73037bbd06977ea34b76a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/migrations/zipball/96cb2a89b56c9efb0bac38e606dc0b0f13e650ec", - "reference": "96cb2a89b56c9efb0bac38e606dc0b0f13e650ec", + "url": "https://api.github.com/repos/doctrine/migrations/zipball/eb0b9ce3f999d720fa73037bbd06977ea34b76a0", + "reference": "eb0b9ce3f999d720fa73037bbd06977ea34b76a0", "shasum": "" }, "require": { @@ -1162,7 +1163,7 @@ ], "support": { "issues": "https://github.com/doctrine/migrations/issues", - "source": "https://github.com/doctrine/migrations/tree/3.9.7" + "source": "https://github.com/doctrine/migrations/tree/3.9.8" }, "funding": [ { @@ -1178,20 +1179,20 @@ "type": "tidelift" } ], - "time": "2026-04-23T19:33:20+00:00" + "time": "2026-10-04T12:35:58+00:00" }, { "name": "doctrine/orm", - "version": "3.7.2", + "version": "3.7.5", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "6b47a02cd1d7119ef4d8f90ca6d49651accef1a3" + "reference": "00c2fd70da291cca1ec75f4dae5344fe3df588fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/6b47a02cd1d7119ef4d8f90ca6d49651accef1a3", - "reference": "6b47a02cd1d7119ef4d8f90ca6d49651accef1a3", + "url": "https://api.github.com/repos/doctrine/orm/zipball/00c2fd70da291cca1ec75f4dae5344fe3df588fa", + "reference": "00c2fd70da291cca1ec75f4dae5344fe3df588fa", "shasum": "" }, "require": { @@ -1266,22 +1267,22 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/3.7.2" + "source": "https://github.com/doctrine/orm/tree/3.7.5" }, - "time": "2026-09-23T05:28:00+00:00" + "time": "2026-10-09T05:58:13+00:00" }, { "name": "doctrine/persistence", - "version": "4.2.0", + "version": "4.2.1", "source": { "type": "git", "url": "https://github.com/doctrine/persistence.git", - "reference": "49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b" + "reference": "d8293831c5b99449b2f63eb1e1bbdc202118daa6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/persistence/zipball/49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b", - "reference": "49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b", + "url": "https://api.github.com/repos/doctrine/persistence/zipball/d8293831c5b99449b2f63eb1e1bbdc202118daa6", + "reference": "d8293831c5b99449b2f63eb1e1bbdc202118daa6", "shasum": "" }, "require": { @@ -1346,7 +1347,7 @@ ], "support": { "issues": "https://github.com/doctrine/persistence/issues", - "source": "https://github.com/doctrine/persistence/tree/4.2.0" + "source": "https://github.com/doctrine/persistence/tree/4.2.1" }, "funding": [ { @@ -1362,7 +1363,7 @@ "type": "tidelift" } ], - "time": "2026-04-26T12:12:52+00:00" + "time": "2026-10-03T14:15:35+00:00" }, { "name": "doctrine/sql-formatter", @@ -1605,22 +1606,22 @@ }, { "name": "guzzlehttp/guzzle", - "version": "8.2.0", + "version": "8.3.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "93939470950a9b11e2e84204166ef5e048c55fe4" + "reference": "b920e5fcaf81d761b8fabe600ca90999beaefdb7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/93939470950a9b11e2e84204166ef5e048c55fe4", - "reference": "93939470950a9b11e2e84204166ef5e048c55fe4", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b920e5fcaf81d761b8fabe600ca90999beaefdb7", + "reference": "b920e5fcaf81d761b8fabe600ca90999beaefdb7", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^3.0.2", - "guzzlehttp/psr7": "^3.1", + "guzzlehttp/promises": "^3.1", + "guzzlehttp/psr7": "^3.2", "php": "^7.4 || ^8.0", "psr/http-client": "^1.0", "psr/http-factory": "^1.0", @@ -1634,7 +1635,7 @@ "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", "guzzle/client-integration-tests": "4.0.1", - "guzzlehttp/test-server": "^1.0.1", + "guzzlehttp/test-server": "^1.1", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -1711,7 +1712,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/8.2.0" + "source": "https://github.com/guzzle/guzzle/tree/8.3.0" }, "funding": [ { @@ -1727,24 +1728,25 @@ "type": "tidelift" } ], - "time": "2026-09-06T13:55:09+00:00" + "time": "2026-10-18T13:38:43+00:00" }, { "name": "guzzlehttp/promises", - "version": "3.0.2", + "version": "3.1.0", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "42118e66a53c492effaf92bc357e931985d5c6f9" + "reference": "094d0ec418eb3b1411ea271a180ec95cae6ae933" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/42118e66a53c492effaf92bc357e931985d5c6f9", - "reference": "42118e66a53c492effaf92bc357e931985d5c6f9", + "url": "https://api.github.com/repos/guzzle/promises/zipball/094d0ec418eb3b1411ea271a180ec95cae6ae933", + "reference": "094d0ec418eb3b1411ea271a180ec95cae6ae933", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0" + "php": "^7.4 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -1794,7 +1796,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/3.0.2" + "source": "https://github.com/guzzle/promises/tree/3.1.0" }, "funding": [ { @@ -1810,20 +1812,20 @@ "type": "tidelift" } ], - "time": "2026-08-24T10:00:26+00:00" + "time": "2026-10-18T12:48:16+00:00" }, { "name": "guzzlehttp/psr7", - "version": "3.1.0", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74" + "reference": "cdc139396207a5eba10c242d241fb014cb098390" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/a3059ba1a84c9139c4ae03cf0f45bea276c97c74", - "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/cdc139396207a5eba10c242d241fb014cb098390", + "reference": "cdc139396207a5eba10c242d241fb014cb098390", "shasum": "" }, "require": { @@ -1913,7 +1915,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/3.1.0" + "source": "https://github.com/guzzle/psr7/tree/3.2.0" }, "funding": [ { @@ -1929,7 +1931,7 @@ "type": "tidelift" } ], - "time": "2026-08-24T11:02:13+00:00" + "time": "2026-10-17T23:57:19+00:00" }, { "name": "itk-dev/openid-connect", @@ -3052,16 +3054,16 @@ }, { "name": "symfony/amqp-messenger", - "version": "v8.1.8", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/amqp-messenger.git", - "reference": "9b4ba6f93199df714e0a21e88494da5035cf3b68" + "reference": "63770f5c4ea636050685e5b68c6c20cbb7f6b173" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/amqp-messenger/zipball/9b4ba6f93199df714e0a21e88494da5035cf3b68", - "reference": "9b4ba6f93199df714e0a21e88494da5035cf3b68", + "url": "https://api.github.com/repos/symfony/amqp-messenger/zipball/63770f5c4ea636050685e5b68c6c20cbb7f6b173", + "reference": "63770f5c4ea636050685e5b68c6c20cbb7f6b173", "shasum": "" }, "require": { @@ -3101,7 +3103,7 @@ "description": "Symfony AMQP extension Messenger Bridge", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/amqp-messenger/tree/v8.1.8" + "source": "https://github.com/symfony/amqp-messenger/tree/v8.1.9" }, "funding": [ { @@ -3121,7 +3123,7 @@ "type": "tidelift" } ], - "time": "2026-09-23T13:10:29+00:00" + "time": "2026-10-13T05:48:51+00:00" }, { "name": "symfony/asset", @@ -3195,16 +3197,16 @@ }, { "name": "symfony/browser-kit", - "version": "v8.1.5", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/browser-kit.git", - "reference": "f6766c9fb232e72897d6d55cf8f6aec38db2dbbf" + "reference": "90f77a846d0772aaf3dec24456410d160d06e6d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/f6766c9fb232e72897d6d55cf8f6aec38db2dbbf", - "reference": "f6766c9fb232e72897d6d55cf8f6aec38db2dbbf", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/90f77a846d0772aaf3dec24456410d160d06e6d0", + "reference": "90f77a846d0772aaf3dec24456410d160d06e6d0", "shasum": "" }, "require": { @@ -3243,7 +3245,7 @@ "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/browser-kit/tree/v8.1.5" + "source": "https://github.com/symfony/browser-kit/tree/v8.1.9" }, "funding": [ { @@ -3263,20 +3265,20 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-10-13T12:55:44+00:00" }, { "name": "symfony/cache", - "version": "v8.1.8", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "8888daf06a2fc8440346215d3b52f3c611cc486d" + "reference": "151eb8001bb873afc9dce0fdc7cd077250f3db10" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/8888daf06a2fc8440346215d3b52f3c611cc486d", - "reference": "8888daf06a2fc8440346215d3b52f3c611cc486d", + "url": "https://api.github.com/repos/symfony/cache/zipball/151eb8001bb873afc9dce0fdc7cd077250f3db10", + "reference": "151eb8001bb873afc9dce0fdc7cd077250f3db10", "shasum": "" }, "require": { @@ -3342,7 +3344,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v8.1.8" + "source": "https://github.com/symfony/cache/tree/v8.1.9" }, "funding": [ { @@ -3362,7 +3364,7 @@ "type": "tidelift" } ], - "time": "2026-09-29T12:38:40+00:00" + "time": "2026-10-12T13:05:24+00:00" }, { "name": "symfony/cache-contracts", @@ -3446,16 +3448,16 @@ }, { "name": "symfony/clock", - "version": "v8.1.0", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" + "reference": "945ea3b64d6da9c1c2c354fe5c07b27455df6442" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", - "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", + "url": "https://api.github.com/repos/symfony/clock/zipball/945ea3b64d6da9c1c2c354fe5c07b27455df6442", + "reference": "945ea3b64d6da9c1c2c354fe5c07b27455df6442", "shasum": "" }, "require": { @@ -3499,7 +3501,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v8.1.0" + "source": "https://github.com/symfony/clock/tree/v8.1.9" }, "funding": [ { @@ -3519,7 +3521,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-10-12T14:32:04+00:00" }, { "name": "symfony/config", @@ -3601,16 +3603,16 @@ }, { "name": "symfony/console", - "version": "v8.1.8", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "4b81146e3ee248ea969186f85499b54fbc78db08" + "reference": "630e74196a2635ec0f06c12bd3fb47cfedf85157" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/4b81146e3ee248ea969186f85499b54fbc78db08", - "reference": "4b81146e3ee248ea969186f85499b54fbc78db08", + "url": "https://api.github.com/repos/symfony/console/zipball/630e74196a2635ec0f06c12bd3fb47cfedf85157", + "reference": "630e74196a2635ec0f06c12bd3fb47cfedf85157", "shasum": "" }, "require": { @@ -3677,7 +3679,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.1.8" + "source": "https://github.com/symfony/console/tree/v8.1.9" }, "funding": [ { @@ -3697,20 +3699,20 @@ "type": "tidelift" } ], - "time": "2026-09-18T16:00:27+00:00" + "time": "2026-10-12T19:11:16+00:00" }, { "name": "symfony/dependency-injection", - "version": "v8.1.8", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "e375617d3f132059bd096f41951b527c2eb60cd0" + "reference": "3356c3c6528356616db60f110481092d856691b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/e375617d3f132059bd096f41951b527c2eb60cd0", - "reference": "e375617d3f132059bd096f41951b527c2eb60cd0", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/3356c3c6528356616db60f110481092d856691b5", + "reference": "3356c3c6528356616db60f110481092d856691b5", "shasum": "" }, "require": { @@ -3758,7 +3760,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v8.1.8" + "source": "https://github.com/symfony/dependency-injection/tree/v8.1.9" }, "funding": [ { @@ -3778,7 +3780,7 @@ "type": "tidelift" } ], - "time": "2026-09-29T12:38:40+00:00" + "time": "2026-10-13T12:55:44+00:00" }, { "name": "symfony/deprecation-contracts", @@ -3853,16 +3855,16 @@ }, { "name": "symfony/doctrine-bridge", - "version": "v8.1.8", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "29ba9e0b21f3ac0f7f73d2d2b7b7755a4ff557e7" + "reference": "1ada49a9fd7484e39b6831472a62a2c3d61000dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/29ba9e0b21f3ac0f7f73d2d2b7b7755a4ff557e7", - "reference": "29ba9e0b21f3ac0f7f73d2d2b7b7755a4ff557e7", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/1ada49a9fd7484e39b6831472a62a2c3d61000dc", + "reference": "1ada49a9fd7484e39b6831472a62a2c3d61000dc", "shasum": "" }, "require": { @@ -3934,7 +3936,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v8.1.8" + "source": "https://github.com/symfony/doctrine-bridge/tree/v8.1.9" }, "funding": [ { @@ -3954,20 +3956,20 @@ "type": "tidelift" } ], - "time": "2026-09-28T19:13:15+00:00" + "time": "2026-10-13T17:38:22+00:00" }, { "name": "symfony/doctrine-messenger", - "version": "v8.1.8", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-messenger.git", - "reference": "4a4b8917fe2f7d8f982f6c1da9c4b70e2c9c9cb9" + "reference": "752f76a5c24fb783dd6be2cc9b2491a88ad19789" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/4a4b8917fe2f7d8f982f6c1da9c4b70e2c9c9cb9", - "reference": "4a4b8917fe2f7d8f982f6c1da9c4b70e2c9c9cb9", + "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/752f76a5c24fb783dd6be2cc9b2491a88ad19789", + "reference": "752f76a5c24fb783dd6be2cc9b2491a88ad19789", "shasum": "" }, "require": { @@ -4012,7 +4014,7 @@ "description": "Symfony Doctrine Messenger Bridge", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-messenger/tree/v8.1.8" + "source": "https://github.com/symfony/doctrine-messenger/tree/v8.1.9" }, "funding": [ { @@ -4032,7 +4034,7 @@ "type": "tidelift" } ], - "time": "2026-09-24T08:15:41+00:00" + "time": "2026-10-12T12:49:14+00:00" }, { "name": "symfony/dom-crawler", @@ -4427,16 +4429,16 @@ }, { "name": "symfony/expression-language", - "version": "v8.1.6", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/expression-language.git", - "reference": "bda5b15a7658d256bcfe67d23eb0e10d437dc708" + "reference": "9c2729bca940d84938dc0b435d037e4844f237c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/expression-language/zipball/bda5b15a7658d256bcfe67d23eb0e10d437dc708", - "reference": "bda5b15a7658d256bcfe67d23eb0e10d437dc708", + "url": "https://api.github.com/repos/symfony/expression-language/zipball/9c2729bca940d84938dc0b435d037e4844f237c9", + "reference": "9c2729bca940d84938dc0b435d037e4844f237c9", "shasum": "" }, "require": { @@ -4470,7 +4472,7 @@ "description": "Provides an engine that can compile and evaluate expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/expression-language/tree/v8.1.6" + "source": "https://github.com/symfony/expression-language/tree/v8.1.9" }, "funding": [ { @@ -4490,20 +4492,20 @@ "type": "tidelift" } ], - "time": "2026-08-30T01:03:44+00:00" + "time": "2026-10-09T13:58:01+00:00" }, { "name": "symfony/filesystem", - "version": "v8.1.6", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba" + "reference": "43d109c803d22c00bb820ed45df250b6a8792ca2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/7599ebb855fede59413ddb7f15198d67bfcae7ba", - "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/43d109c803d22c00bb820ed45df250b6a8792ca2", + "reference": "43d109c803d22c00bb820ed45df250b6a8792ca2", "shasum": "" }, "require": { @@ -4541,7 +4543,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.1.6" + "source": "https://github.com/symfony/filesystem/tree/v8.1.9" }, "funding": [ { @@ -4561,7 +4563,7 @@ "type": "tidelift" } ], - "time": "2026-08-23T10:06:25+00:00" + "time": "2026-10-06T20:05:50+00:00" }, { "name": "symfony/finder", @@ -4706,16 +4708,16 @@ }, { "name": "symfony/form", - "version": "v8.1.8", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/form.git", - "reference": "cd6635260d1f1b4dfc349f4a893fd0b2d1ea243b" + "reference": "999df9a88cb78ee5027f2e2cc7dc2c92aea1348e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/form/zipball/cd6635260d1f1b4dfc349f4a893fd0b2d1ea243b", - "reference": "cd6635260d1f1b4dfc349f4a893fd0b2d1ea243b", + "url": "https://api.github.com/repos/symfony/form/zipball/999df9a88cb78ee5027f2e2cc7dc2c92aea1348e", + "reference": "999df9a88cb78ee5027f2e2cc7dc2c92aea1348e", "shasum": "" }, "require": { @@ -4779,7 +4781,7 @@ "description": "Allows to easily create, process and reuse HTML forms", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/form/tree/v8.1.8" + "source": "https://github.com/symfony/form/tree/v8.1.9" }, "funding": [ { @@ -4799,20 +4801,20 @@ "type": "tidelift" } ], - "time": "2026-09-29T12:38:40+00:00" + "time": "2026-10-13T16:18:12+00:00" }, { "name": "symfony/framework-bundle", - "version": "v8.1.8", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "78c28ec21e3ef4bd8a72de949d0ea2dead1e3f95" + "reference": "57a5af035a7c2c055bf4209a820f1a3e6a5b4adb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/78c28ec21e3ef4bd8a72de949d0ea2dead1e3f95", - "reference": "78c28ec21e3ef4bd8a72de949d0ea2dead1e3f95", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/57a5af035a7c2c055bf4209a820f1a3e6a5b4adb", + "reference": "57a5af035a7c2c055bf4209a820f1a3e6a5b4adb", "shasum": "" }, "require": { @@ -4922,7 +4924,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v8.1.8" + "source": "https://github.com/symfony/framework-bundle/tree/v8.1.9" }, "funding": [ { @@ -4942,20 +4944,20 @@ "type": "tidelift" } ], - "time": "2026-09-28T19:13:15+00:00" + "time": "2026-10-12T12:37:13+00:00" }, { "name": "symfony/http-client", - "version": "v8.1.8", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "e20aca4ceaa888197de5c792ffc551d99e43d98a" + "reference": "f892301df77ac25613b19fc2450139c950d1368a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/e20aca4ceaa888197de5c792ffc551d99e43d98a", - "reference": "e20aca4ceaa888197de5c792ffc551d99e43d98a", + "url": "https://api.github.com/repos/symfony/http-client/zipball/f892301df77ac25613b19fc2450139c950d1368a", + "reference": "f892301df77ac25613b19fc2450139c950d1368a", "shasum": "" }, "require": { @@ -5019,7 +5021,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v8.1.8" + "source": "https://github.com/symfony/http-client/tree/v8.1.9" }, "funding": [ { @@ -5039,7 +5041,7 @@ "type": "tidelift" } ], - "time": "2026-09-24T11:09:32+00:00" + "time": "2026-10-13T15:31:40+00:00" }, { "name": "symfony/http-client-contracts", @@ -5125,16 +5127,16 @@ }, { "name": "symfony/http-foundation", - "version": "v8.1.8", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "662f5257658d4f60deff1bc790e9f2c68cb391bf" + "reference": "7850897fb8659f98491b03be79ac48600bdaf9ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/662f5257658d4f60deff1bc790e9f2c68cb391bf", - "reference": "662f5257658d4f60deff1bc790e9f2c68cb391bf", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/7850897fb8659f98491b03be79ac48600bdaf9ab", + "reference": "7850897fb8659f98491b03be79ac48600bdaf9ab", "shasum": "" }, "require": { @@ -5182,7 +5184,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.1.8" + "source": "https://github.com/symfony/http-foundation/tree/v8.1.9" }, "funding": [ { @@ -5202,20 +5204,20 @@ "type": "tidelift" } ], - "time": "2026-09-24T07:13:53+00:00" + "time": "2026-10-08T16:57:43+00:00" }, { "name": "symfony/http-kernel", - "version": "v8.1.8", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "51c014e881d8b4ce61e56c0d4105f65b50df786a" + "reference": "55c6935bb4c004a72b55f723e79d5404881dcad3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/51c014e881d8b4ce61e56c0d4105f65b50df786a", - "reference": "51c014e881d8b4ce61e56c0d4105f65b50df786a", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/55c6935bb4c004a72b55f723e79d5404881dcad3", + "reference": "55c6935bb4c004a72b55f723e79d5404881dcad3", "shasum": "" }, "require": { @@ -5292,7 +5294,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.1.8" + "source": "https://github.com/symfony/http-kernel/tree/v8.1.9" }, "funding": [ { @@ -5312,7 +5314,7 @@ "type": "tidelift" } ], - "time": "2026-09-29T14:08:01+00:00" + "time": "2026-10-14T06:47:03+00:00" }, { "name": "symfony/intl", @@ -5405,16 +5407,16 @@ }, { "name": "symfony/messenger", - "version": "v8.1.8", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/messenger.git", - "reference": "01b42daaefd1e020fabf2e94cf9448b1f2ad6f98" + "reference": "10cd7a8d31a3942c05d874225a05b9248e8e7626" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/messenger/zipball/01b42daaefd1e020fabf2e94cf9448b1f2ad6f98", - "reference": "01b42daaefd1e020fabf2e94cf9448b1f2ad6f98", + "url": "https://api.github.com/repos/symfony/messenger/zipball/10cd7a8d31a3942c05d874225a05b9248e8e7626", + "reference": "10cd7a8d31a3942c05d874225a05b9248e8e7626", "shasum": "" }, "require": { @@ -5473,7 +5475,7 @@ "description": "Helps applications send and receive messages to/from other applications or via message queues", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/messenger/tree/v8.1.8" + "source": "https://github.com/symfony/messenger/tree/v8.1.9" }, "funding": [ { @@ -5493,7 +5495,7 @@ "type": "tidelift" } ], - "time": "2026-09-29T12:38:40+00:00" + "time": "2026-10-13T13:51:40+00:00" }, { "name": "symfony/mime", @@ -5884,16 +5886,16 @@ }, { "name": "symfony/polyfill-deepclone", - "version": "v1.43.0", + "version": "v1.43.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-deepclone.git", - "reference": "8db92c5508a3f7f82d8688d32f0a4868a26e58ad" + "reference": "82274308b59f6e96f53a3fb37fc40877d843e5a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/8db92c5508a3f7f82d8688d32f0a4868a26e58ad", - "reference": "8db92c5508a3f7f82d8688d32f0a4868a26e58ad", + "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/82274308b59f6e96f53a3fb37fc40877d843e5a5", + "reference": "82274308b59f6e96f53a3fb37fc40877d843e5a5", "shasum": "" }, "require": { @@ -5947,7 +5949,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.43.0" + "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.43.2" }, "funding": [ { @@ -5967,7 +5969,7 @@ "type": "tidelift" } ], - "time": "2026-09-30T09:07:27+00:00" + "time": "2026-10-14T07:17:09+00:00" }, { "name": "symfony/polyfill-intl-grapheme", @@ -7140,16 +7142,16 @@ }, { "name": "symfony/routing", - "version": "v8.1.8", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "e46d741c56485441f37104a3e7eace4e17ed91f9" + "reference": "edb06fb6ced3c1faaaed74c1c6c0f176a614c330" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/e46d741c56485441f37104a3e7eace4e17ed91f9", - "reference": "e46d741c56485441f37104a3e7eace4e17ed91f9", + "url": "https://api.github.com/repos/symfony/routing/zipball/edb06fb6ced3c1faaaed74c1c6c0f176a614c330", + "reference": "edb06fb6ced3c1faaaed74c1c6c0f176a614c330", "shasum": "" }, "require": { @@ -7196,7 +7198,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.1.8" + "source": "https://github.com/symfony/routing/tree/v8.1.9" }, "funding": [ { @@ -7216,20 +7218,20 @@ "type": "tidelift" } ], - "time": "2026-09-28T19:13:15+00:00" + "time": "2026-10-12T12:37:13+00:00" }, { "name": "symfony/runtime", - "version": "v8.1.0", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/runtime.git", - "reference": "b7ea1abe04561e814b3134db0f56c287cedb35cc" + "reference": "8670c7b20dacb5b1bcc07891dc35546376685af1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/runtime/zipball/b7ea1abe04561e814b3134db0f56c287cedb35cc", - "reference": "b7ea1abe04561e814b3134db0f56c287cedb35cc", + "url": "https://api.github.com/repos/symfony/runtime/zipball/8670c7b20dacb5b1bcc07891dc35546376685af1", + "reference": "8670c7b20dacb5b1bcc07891dc35546376685af1", "shasum": "" }, "require": { @@ -7280,7 +7282,7 @@ "runtime" ], "support": { - "source": "https://github.com/symfony/runtime/tree/v8.1.0" + "source": "https://github.com/symfony/runtime/tree/v8.1.9" }, "funding": [ { @@ -7300,20 +7302,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-10-06T20:05:50+00:00" }, { "name": "symfony/scheduler", - "version": "v8.1.8", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/scheduler.git", - "reference": "54cfa740bdad258fcd29dfaba2540e7f9afccb70" + "reference": "0d76364a972a672ce3c33535791c91eaf8df7412" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/scheduler/zipball/54cfa740bdad258fcd29dfaba2540e7f9afccb70", - "reference": "54cfa740bdad258fcd29dfaba2540e7f9afccb70", + "url": "https://api.github.com/repos/symfony/scheduler/zipball/0d76364a972a672ce3c33535791c91eaf8df7412", + "reference": "0d76364a972a672ce3c33535791c91eaf8df7412", "shasum": "" }, "require": { @@ -7365,7 +7367,7 @@ "scheduler" ], "support": { - "source": "https://github.com/symfony/scheduler/tree/v8.1.8" + "source": "https://github.com/symfony/scheduler/tree/v8.1.9" }, "funding": [ { @@ -7385,20 +7387,20 @@ "type": "tidelift" } ], - "time": "2026-09-26T06:33:49+00:00" + "time": "2026-10-11T13:01:54+00:00" }, { "name": "symfony/security-bundle", - "version": "v8.1.8", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "b18be9c45bb6e8dbc91787165516013ffefb72fb" + "reference": "31993005911620056ea14e282b9c5cb11223ea6c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/b18be9c45bb6e8dbc91787165516013ffefb72fb", - "reference": "b18be9c45bb6e8dbc91787165516013ffefb72fb", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/31993005911620056ea14e282b9c5cb11223ea6c", + "reference": "31993005911620056ea14e282b9c5cb11223ea6c", "shasum": "" }, "require": { @@ -7414,7 +7416,7 @@ "symfony/password-hasher": "^7.4|^8.0", "symfony/security-core": "^7.4|^8.0", "symfony/security-csrf": "^7.4|^8.0", - "symfony/security-http": "^8.1", + "symfony/security-http": "^8.1.9", "symfony/service-contracts": "^2.5|^3" }, "require-dev": { @@ -7467,7 +7469,7 @@ "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-bundle/tree/v8.1.8" + "source": "https://github.com/symfony/security-bundle/tree/v8.1.9" }, "funding": [ { @@ -7487,20 +7489,20 @@ "type": "tidelift" } ], - "time": "2026-09-24T11:09:32+00:00" + "time": "2026-10-13T15:31:40+00:00" }, { "name": "symfony/security-core", - "version": "v8.1.8", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/security-core.git", - "reference": "2dc0c95a47282476d90cf6c9e274767c50126b04" + "reference": "41968f1068f89edf881ef4ae0e691e0d9dd5d448" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-core/zipball/2dc0c95a47282476d90cf6c9e274767c50126b04", - "reference": "2dc0c95a47282476d90cf6c9e274767c50126b04", + "url": "https://api.github.com/repos/symfony/security-core/zipball/41968f1068f89edf881ef4ae0e691e0d9dd5d448", + "reference": "41968f1068f89edf881ef4ae0e691e0d9dd5d448", "shasum": "" }, "require": { @@ -7550,7 +7552,7 @@ "description": "Symfony Security Component - Core Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-core/tree/v8.1.8" + "source": "https://github.com/symfony/security-core/tree/v8.1.9" }, "funding": [ { @@ -7570,7 +7572,7 @@ "type": "tidelift" } ], - "time": "2026-09-28T19:13:15+00:00" + "time": "2026-10-05T09:12:18+00:00" }, { "name": "symfony/security-csrf", @@ -7646,16 +7648,16 @@ }, { "name": "symfony/security-http", - "version": "v8.1.8", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "eac4db5a18d8c83a49d20ee46d6b42820103502f" + "reference": "f3733837bb5ac0b0eed6895b14e374892547c97e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/eac4db5a18d8c83a49d20ee46d6b42820103502f", - "reference": "eac4db5a18d8c83a49d20ee46d6b42820103502f", + "url": "https://api.github.com/repos/symfony/security-http/zipball/f3733837bb5ac0b0eed6895b14e374892547c97e", + "reference": "f3733837bb5ac0b0eed6895b14e374892547c97e", "shasum": "" }, "require": { @@ -7675,6 +7677,7 @@ "psr/log": "^1|^2|^3", "symfony/cache": "^7.4|^8.0", "symfony/clock": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", "symfony/expression-language": "^7.4|^8.0", "symfony/http-client": "^7.4|^8.0", "symfony/http-client-contracts": "^3.0", @@ -7710,7 +7713,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v8.1.8" + "source": "https://github.com/symfony/security-http/tree/v8.1.9" }, "funding": [ { @@ -7730,20 +7733,20 @@ "type": "tidelift" } ], - "time": "2026-09-28T19:13:15+00:00" + "time": "2026-10-12T12:37:13+00:00" }, { "name": "symfony/serializer", - "version": "v8.1.8", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "df54c4fe859d945eb633d2c5e6a410ce535f21e4" + "reference": "bc6f84d67225d7a25d56af6d4bd52e9e34997a96" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/df54c4fe859d945eb633d2c5e6a410ce535f21e4", - "reference": "df54c4fe859d945eb633d2c5e6a410ce535f21e4", + "url": "https://api.github.com/repos/symfony/serializer/zipball/bc6f84d67225d7a25d56af6d4bd52e9e34997a96", + "reference": "bc6f84d67225d7a25d56af6d4bd52e9e34997a96", "shasum": "" }, "require": { @@ -7809,7 +7812,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v8.1.8" + "source": "https://github.com/symfony/serializer/tree/v8.1.9" }, "funding": [ { @@ -7829,7 +7832,7 @@ "type": "tidelift" } ], - "time": "2026-09-28T09:40:25+00:00" + "time": "2026-10-12T19:11:16+00:00" }, { "name": "symfony/service-contracts", @@ -7986,16 +7989,16 @@ }, { "name": "symfony/string", - "version": "v8.1.7", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c" + "reference": "07fb208558f941eab50ec49c89c2a6a93fb11f01" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/d950140b5f56f31901e5b7a0c04ffc3a3deb943c", - "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c", + "url": "https://api.github.com/repos/symfony/string/zipball/07fb208558f941eab50ec49c89c2a6a93fb11f01", + "reference": "07fb208558f941eab50ec49c89c2a6a93fb11f01", "shasum": "" }, "require": { @@ -8052,7 +8055,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.1.7" + "source": "https://github.com/symfony/string/tree/v8.1.9" }, "funding": [ { @@ -8072,7 +8075,7 @@ "type": "tidelift" } ], - "time": "2026-09-11T14:51:16+00:00" + "time": "2026-10-03T15:16:03+00:00" }, { "name": "symfony/translation", @@ -8359,16 +8362,16 @@ }, { "name": "symfony/twig-bundle", - "version": "v8.1.7", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/twig-bundle.git", - "reference": "71648fac591a675b91ae408f03a3d95a3f98b436" + "reference": "127b678d3c8afc3639eea44a212d7db565a45509" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/71648fac591a675b91ae408f03a3d95a3f98b436", - "reference": "71648fac591a675b91ae408f03a3d95a3f98b436", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/127b678d3c8afc3639eea44a212d7db565a45509", + "reference": "127b678d3c8afc3639eea44a212d7db565a45509", "shasum": "" }, "require": { @@ -8419,7 +8422,7 @@ "description": "Provides a tight integration of Twig into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bundle/tree/v8.1.7" + "source": "https://github.com/symfony/twig-bundle/tree/v8.1.9" }, "funding": [ { @@ -8439,7 +8442,7 @@ "type": "tidelift" } ], - "time": "2026-09-11T14:51:16+00:00" + "time": "2026-10-09T16:13:24+00:00" }, { "name": "symfony/type-info", @@ -8525,16 +8528,16 @@ }, { "name": "symfony/uid", - "version": "v8.1.8", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "f412a0b5edd75eb3104a93bd0d11fd2ea3da34e4" + "reference": "cb487cf30380f0b0b090973bb9c0588193c06685" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/f412a0b5edd75eb3104a93bd0d11fd2ea3da34e4", - "reference": "f412a0b5edd75eb3104a93bd0d11fd2ea3da34e4", + "url": "https://api.github.com/repos/symfony/uid/zipball/cb487cf30380f0b0b090973bb9c0588193c06685", + "reference": "cb487cf30380f0b0b090973bb9c0588193c06685", "shasum": "" }, "require": { @@ -8579,7 +8582,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v8.1.8" + "source": "https://github.com/symfony/uid/tree/v8.1.9" }, "funding": [ { @@ -8599,20 +8602,20 @@ "type": "tidelift" } ], - "time": "2026-09-23T17:45:45+00:00" + "time": "2026-10-12T14:32:04+00:00" }, { "name": "symfony/ux-twig-component", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-twig-component.git", - "reference": "e47eb29a7880da340210c6dc90b66e221b5ec549" + "reference": "2af0f2664509c8fa8f8a6c2118d8ee4fc8f951c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/e47eb29a7880da340210c6dc90b66e221b5ec549", - "reference": "e47eb29a7880da340210c6dc90b66e221b5ec549", + "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/2af0f2664509c8fa8f8a6c2118d8ee4fc8f951c4", + "reference": "2af0f2664509c8fa8f8a6c2118d8ee4fc8f951c4", "shasum": "" }, "require": { @@ -8668,7 +8671,7 @@ "twig" ], "support": { - "source": "https://github.com/symfony/ux-twig-component/tree/v3.5.1" + "source": "https://github.com/symfony/ux-twig-component/tree/v3.6.0" }, "funding": [ { @@ -8688,20 +8691,20 @@ "type": "tidelift" } ], - "time": "2026-09-18T12:02:28+00:00" + "time": "2026-10-12T20:12:07+00:00" }, { "name": "symfony/validator", - "version": "v8.1.8", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "828f05cff662bb1661f195daa1b06961642ca9dd" + "reference": "020b0f92a34367a36ffad5956501f971545a3b9b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/828f05cff662bb1661f195daa1b06961642ca9dd", - "reference": "828f05cff662bb1661f195daa1b06961642ca9dd", + "url": "https://api.github.com/repos/symfony/validator/zipball/020b0f92a34367a36ffad5956501f971545a3b9b", + "reference": "020b0f92a34367a36ffad5956501f971545a3b9b", "shasum": "" }, "require": { @@ -8765,7 +8768,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v8.1.8" + "source": "https://github.com/symfony/validator/tree/v8.1.9" }, "funding": [ { @@ -8785,20 +8788,20 @@ "type": "tidelift" } ], - "time": "2026-09-29T12:38:40+00:00" + "time": "2026-10-13T16:18:12+00:00" }, { "name": "symfony/var-dumper", - "version": "v8.1.7", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "741a8c4c948b0bb9bd3653daacd3644c73c40ea5" + "reference": "b8c168c764c09f5d931f290c9dbdd13f1bf142f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/741a8c4c948b0bb9bd3653daacd3644c73c40ea5", - "reference": "741a8c4c948b0bb9bd3653daacd3644c73c40ea5", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/b8c168c764c09f5d931f290c9dbdd13f1bf142f3", + "reference": "b8c168c764c09f5d931f290c9dbdd13f1bf142f3", "shasum": "" }, "require": { @@ -8852,7 +8855,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v8.1.7" + "source": "https://github.com/symfony/var-dumper/tree/v8.1.9" }, "funding": [ { @@ -8872,26 +8875,26 @@ "type": "tidelift" } ], - "time": "2026-09-03T16:02:27+00:00" + "time": "2026-10-13T05:48:51+00:00" }, { "name": "symfony/var-exporter", - "version": "v8.1.6", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "802362f41128c430fd6685491e1fb72127fae78a" + "reference": "3717217839a76f6fa284931a224c4cada514da2a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/802362f41128c430fd6685491e1fb72127fae78a", - "reference": "802362f41128c430fd6685491e1fb72127fae78a", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/3717217839a76f6fa284931a224c4cada514da2a", + "reference": "3717217839a76f6fa284931a224c4cada514da2a", "shasum": "" }, "require": { "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-deepclone": "^1.40" + "symfony/polyfill-deepclone": "^1.43.1" }, "require-dev": { "symfony/property-access": "^7.4|^8.0", @@ -8935,7 +8938,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v8.1.6" + "source": "https://github.com/symfony/var-exporter/tree/v8.1.9" }, "funding": [ { @@ -8955,7 +8958,7 @@ "type": "tidelift" } ], - "time": "2026-08-29T06:30:03+00:00" + "time": "2026-10-07T07:28:40+00:00" }, { "name": "symfony/web-link", @@ -9043,16 +9046,16 @@ }, { "name": "symfony/yaml", - "version": "v8.1.8", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "030e96eed0303223bfca3af9109e9e64830b6ec3" + "reference": "6107ee26a25215c3df3af0a3315e56bb4fd0b7db" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/030e96eed0303223bfca3af9109e9e64830b6ec3", - "reference": "030e96eed0303223bfca3af9109e9e64830b6ec3", + "url": "https://api.github.com/repos/symfony/yaml/zipball/6107ee26a25215c3df3af0a3315e56bb4fd0b7db", + "reference": "6107ee26a25215c3df3af0a3315e56bb4fd0b7db", "shasum": "" }, "require": { @@ -9095,7 +9098,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.1.8" + "source": "https://github.com/symfony/yaml/tree/v8.1.9" }, "funding": [ { @@ -9115,7 +9118,7 @@ "type": "tidelift" } ], - "time": "2026-09-26T06:33:49+00:00" + "time": "2026-10-12T12:37:13+00:00" }, { "name": "twig/extra-bundle", @@ -9690,42 +9693,42 @@ }, { "name": "ergebnis/agent-detector", - "version": "1.2.0", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/ergebnis/agent-detector.git", - "reference": "e211f17928c8b95a51e06040792d57f5462fb271" + "reference": "0cd815cec1207873c37624d33f0f2853523152f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/e211f17928c8b95a51e06040792d57f5462fb271", - "reference": "e211f17928c8b95a51e06040792d57f5462fb271", + "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/0cd815cec1207873c37624d33f0f2853523152f3", + "reference": "0cd815cec1207873c37624d33f0f2853523152f3", "shasum": "" }, "require": { "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" }, "require-dev": { - "ergebnis/composer-normalize": "^2.51.0", - "ergebnis/license": "^2.7.0", + "ergebnis/composer-normalize": "^2.54.0", + "ergebnis/license": "^2.8.0", "ergebnis/php-cs-fixer-config": "^6.60.2", - "ergebnis/phpstan-rules": "^2.13.1", - "ergebnis/phpunit-slow-test-detector": "^2.24.0", - "ergebnis/rector-rules": "^1.18.1", + "ergebnis/phpstan-rules": "^2.14.0", + "ergebnis/phpunit-slow-test-detector": "^2.26.0", + "ergebnis/rector-rules": "^1.18.5", "fakerphp/faker": "^1.24.1", "infection/infection": "^0.26.6", "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.1.54", - "phpstan/phpstan-deprecation-rules": "^2.0.4", - "phpstan/phpstan-phpunit": "^2.0.16", - "phpstan/phpstan-strict-rules": "^2.0.10", + "phpstan/phpstan": "^2.2.14", + "phpstan/phpstan-deprecation-rules": "^2.0.5", + "phpstan/phpstan-phpunit": "^2.1.1", + "phpstan/phpstan-strict-rules": "^2.1.0", "phpunit/phpunit": "^9.6.34", - "rector/rector": "^2.4.2" + "rector/rector": "^2.7.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "1.2-dev" + "dev-main": "1.3-dev" }, "composer-normalize": { "indent-size": 2, @@ -9755,7 +9758,7 @@ "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md", "source": "https://github.com/ergebnis/agent-detector" }, - "time": "2026-05-07T08:19:07+00:00" + "time": "2026-10-15T11:13:32+00:00" }, { "name": "ergebnis/composer-normalize", @@ -11148,11 +11151,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.16", + "version": "2.3.1", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", - "reference": "46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/6155239b488e95d26d1ebd95c6b4bb294d7f09db", + "reference": "6155239b488e95d26d1ebd95c6b4bb294d7f09db", "shasum": "" }, "require": { @@ -11208,7 +11211,7 @@ "type": "github" } ], - "time": "2026-09-25T09:31:51+00:00" + "time": "2026-10-08T21:09:11+00:00" }, { "name": "phpstan/phpstan-doctrine", @@ -11289,22 +11292,22 @@ }, { "name": "phpstan/phpstan-phpunit", - "version": "2.0.20", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-phpunit.git", - "reference": "ef0c8f5c5e01b2b1863043738d90fbde52559159" + "reference": "8d16b8478e523a47f8c02e83bce8981b5b5a3b03" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/ef0c8f5c5e01b2b1863043738d90fbde52559159", - "reference": "ef0c8f5c5e01b2b1863043738d90fbde52559159", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/8d16b8478e523a47f8c02e83bce8981b5b5a3b03", + "reference": "8d16b8478e523a47f8c02e83bce8981b5b5a3b03", "shasum": "" }, "require": { "phar-io/version": "^3.2", "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.2.6" + "phpstan/phpstan": "^2.3.0" }, "conflict": { "phpunit/phpunit": "<7.0" @@ -11341,35 +11344,35 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-phpunit/issues", - "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.20" + "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.1.1" }, - "time": "2026-09-30T14:50:48+00:00" + "time": "2026-10-06T18:12:32+00:00" }, { "name": "phpstan/phpstan-symfony", - "version": "2.0.20", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-symfony.git", - "reference": "53f1a6462dbe71fad36ce054caf5e1b725b740fd" + "reference": "1981b94f0dd3a5bb42268f6a7a0e8954ac76dc2e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/53f1a6462dbe71fad36ce054caf5e1b725b740fd", - "reference": "53f1a6462dbe71fad36ce054caf5e1b725b740fd", + "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/1981b94f0dd3a5bb42268f6a7a0e8954ac76dc2e", + "reference": "1981b94f0dd3a5bb42268f6a7a0e8954ac76dc2e", "shasum": "" }, "require": { "ext-simplexml": "*", "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.1.13" + "phpstan/phpstan": "^2.3" }, "conflict": { "symfony/framework-bundle": "<3.0" }, "require-dev": { "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/phpstan-phpunit": "^2.0.8", + "phpstan/phpstan-phpunit": "^2.0.13", "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^9.6", "psr/container": "1.1.2", @@ -11415,22 +11418,22 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-symfony/issues", - "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.20" + "source": "https://github.com/phpstan/phpstan-symfony/tree/2.1.0" }, - "time": "2026-06-16T09:17:35+00:00" + "time": "2026-10-03T15:54:38+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "14.4.0", + "version": "14.4.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "9be27409ad7bedddd34ed3bc27561d47c6faea06" + "reference": "f1e54f06b6b4cea73cd6104ba3c0cfa0efa6d73f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/9be27409ad7bedddd34ed3bc27561d47c6faea06", - "reference": "9be27409ad7bedddd34ed3bc27561d47c6faea06", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f1e54f06b6b4cea73cd6104ba3c0cfa0efa6d73f", + "reference": "f1e54f06b6b4cea73cd6104ba3c0cfa0efa6d73f", "shasum": "" }, "require": { @@ -11440,9 +11443,9 @@ "ext-xmlwriter": "*", "nikic/php-parser": "^5.9.0", "php": ">=8.4", - "phpunit/php-text-template": "^6.0", + "phpunit/php-text-template": "^6.0.1", "sebastian/complexity": "^6.0", - "sebastian/environment": "^9.3.2", + "sebastian/environment": "^9.3.3", "sebastian/git-state": "^1.0", "sebastian/lines-of-code": "^5.0.2", "sebastian/version": "^7.0", @@ -11487,7 +11490,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.4.0" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.4.1" }, "funding": [ { @@ -11507,7 +11510,7 @@ "type": "tidelift" } ], - "time": "2026-09-30T12:46:01+00:00" + "time": "2026-10-03T14:05:38+00:00" }, { "name": "phpunit/php-file-iterator", @@ -11660,23 +11663,23 @@ }, { "name": "phpunit/php-text-template", - "version": "6.0.0", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" + "reference": "97107691cb2b6c743b5e0c44be19e983cb337a02" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", - "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/97107691cb2b6c743b5e0c44be19e983cb337a02", + "reference": "97107691cb2b6c743b5e0c44be19e983cb337a02", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.3.6" }, "type": "library", "extra": { @@ -11708,7 +11711,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-text-template/issues", "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.1" }, "funding": [ { @@ -11728,7 +11731,7 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:36:37+00:00" + "time": "2026-10-03T04:54:24+00:00" }, { "name": "phpunit/php-timer", @@ -11804,16 +11807,16 @@ }, { "name": "phpunit/phpunit", - "version": "13.3.6", + "version": "13.4.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "b7d0ee4ea89121cb963302b8708b1c09d9ebc7d6" + "reference": "2180c47c4229e5da117ff248753370443c074f10" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b7d0ee4ea89121cb963302b8708b1c09d9ebc7d6", - "reference": "b7d0ee4ea89121cb963302b8708b1c09d9ebc7d6", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/2180c47c4229e5da117ff248753370443c074f10", + "reference": "2180c47c4229e5da117ff248753370443c074f10", "shasum": "" }, "require": { @@ -11827,15 +11830,15 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.4.1", - "phpunit/php-code-coverage": "^14.3.5", + "phpunit/php-code-coverage": "^14.4.1", "phpunit/php-file-iterator": "^7.0.2", "phpunit/php-invoker": "^7.0.0", - "phpunit/php-text-template": "^6.0.0", + "phpunit/php-text-template": "^6.0.1", "phpunit/php-timer": "^9.0.0", "sebastian/cli-parser": "^5.0.1", "sebastian/comparator": "^8.4", "sebastian/diff": "^9.0.1", - "sebastian/environment": "^9.3.2", + "sebastian/environment": "^9.3.3", "sebastian/exporter": "^8.2.1", "sebastian/file-filter": "^1.0", "sebastian/git-state": "^1.0", @@ -11843,7 +11846,7 @@ "sebastian/object-enumerator": "^8.1.0", "sebastian/recursion-context": "^8.0.1", "sebastian/type": "^7.0.2", - "sebastian/version": "^7.0.0", + "sebastian/version": "^7.0.1", "staabm/side-effects-detector": "^1.0.5" }, "bin": [ @@ -11852,7 +11855,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "13.3-dev" + "dev-main": "13.4-dev" } }, "autoload": { @@ -11884,7 +11887,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/13.3.6" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.4.3" }, "funding": [ { @@ -11892,7 +11895,7 @@ "type": "other" } ], - "time": "2026-09-29T12:12:05+00:00" + "time": "2026-10-16T05:56:47+00:00" }, { "name": "react/cache", @@ -12422,21 +12425,21 @@ }, { "name": "rector/rector", - "version": "2.6.7", + "version": "2.7.0", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "f4aa224edb9b0630fb43826d4cb6a49b62e48d4c" + "reference": "4d6a39b7fff302cac0ce90cd959a53c2f6a32776" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/f4aa224edb9b0630fb43826d4cb6a49b62e48d4c", - "reference": "f4aa224edb9b0630fb43826d4cb6a49b62e48d4c", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/4d6a39b7fff302cac0ce90cd959a53c2f6a32776", + "reference": "4d6a39b7fff302cac0ce90cd959a53c2f6a32776", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.2.14" + "phpstan/phpstan": "^2.3.0" }, "conflict": { "rector/rector-doctrine": "*", @@ -12467,7 +12470,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.6.7" + "source": "https://github.com/rectorphp/rector/tree/2.7.0" }, "funding": [ { @@ -12475,7 +12478,7 @@ "type": "github" } ], - "time": "2026-09-13T20:17:44+00:00" + "time": "2026-10-06T08:46:45+00:00" }, { "name": "sebastian/cli-parser", @@ -12789,23 +12792,23 @@ }, { "name": "sebastian/environment", - "version": "9.3.2", + "version": "9.3.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e" + "reference": "d4dd10267a11055066a2a78ae04583d8ddd4f595" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", - "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/d4dd10267a11055066a2a78ae04583d8ddd4f595", + "reference": "d4dd10267a11055066a2a78ae04583d8ddd4f595", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.1.11" + "phpunit/phpunit": "^13.3.6" }, "suggest": { "ext-posix": "*" @@ -12841,7 +12844,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/9.3.2" + "source": "https://github.com/sebastianbergmann/environment/tree/9.3.3" }, "funding": [ { @@ -12861,7 +12864,7 @@ "type": "tidelift" } ], - "time": "2026-05-25T13:41:38+00:00" + "time": "2026-10-01T12:37:56+00:00" }, { "name": "sebastian/exporter", @@ -13519,16 +13522,16 @@ }, { "name": "sebastian/version", - "version": "7.0.0", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", - "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" + "reference": "f1598cf1d8d1ca4c0f194a52f2fe72a559a7b0b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", - "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/f1598cf1d8d1ca4c0f194a52f2fe72a559a7b0b1", + "reference": "f1598cf1d8d1ca4c0f194a52f2fe72a559a7b0b1", "shasum": "" }, "require": { @@ -13561,7 +13564,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/version/issues", "security": "https://github.com/sebastianbergmann/version/security/policy", - "source": "https://github.com/sebastianbergmann/version/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/version/tree/7.0.1" }, "funding": [ { @@ -13581,7 +13584,7 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:52:52+00:00" + "time": "2026-10-04T05:51:14+00:00" }, { "name": "staabm/side-effects-detector", @@ -14372,16 +14375,16 @@ }, { "name": "symfony/process", - "version": "v8.1.7", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "10823b09358e690df4ff943e24e8492bb1019fc3" + "reference": "59a682fd049b2d4d37c444f5ac21b7d01b8c7189" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/10823b09358e690df4ff943e24e8492bb1019fc3", - "reference": "10823b09358e690df4ff943e24e8492bb1019fc3", + "url": "https://api.github.com/repos/symfony/process/zipball/59a682fd049b2d4d37c444f5ac21b7d01b8c7189", + "reference": "59a682fd049b2d4d37c444f5ac21b7d01b8c7189", "shasum": "" }, "require": { @@ -14413,7 +14416,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.1.7" + "source": "https://github.com/symfony/process/tree/v8.1.9" }, "funding": [ { @@ -14433,20 +14436,20 @@ "type": "tidelift" } ], - "time": "2026-09-02T12:40:29+00:00" + "time": "2026-10-08T16:57:43+00:00" }, { "name": "symfony/web-profiler-bundle", - "version": "v8.1.8", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "bd30b3f8e2cafe758d71c2237dc09544e144402e" + "reference": "481a6d4b0b6bce7e83573576abcf01d9674cd934" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/bd30b3f8e2cafe758d71c2237dc09544e144402e", - "reference": "bd30b3f8e2cafe758d71c2237dc09544e144402e", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/481a6d4b0b6bce7e83573576abcf01d9674cd934", + "reference": "481a6d4b0b6bce7e83573576abcf01d9674cd934", "shasum": "" }, "require": { @@ -14498,7 +14501,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v8.1.8" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v8.1.9" }, "funding": [ { @@ -14518,7 +14521,7 @@ "type": "tidelift" } ], - "time": "2026-09-28T19:13:15+00:00" + "time": "2026-10-07T15:50:48+00:00" }, { "name": "theofidry/alice-data-fixtures", @@ -14748,16 +14751,16 @@ }, { "name": "webmozart/assert", - "version": "2.4.1", + "version": "2.5.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" + "reference": "8a163706bbcac613733dba03f51e13b5eb1bb819" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", - "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/8a163706bbcac613733dba03f51e13b5eb1bb819", + "reference": "8a163706bbcac613733dba03f51e13b5eb1bb819", "shasum": "" }, "require": { @@ -14808,9 +14811,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.4.1" + "source": "https://github.com/webmozarts/assert/tree/2.5.0" }, - "time": "2026-06-15T15:31:57+00:00" + "time": "2026-10-05T23:14:19+00:00" } ], "aliases": [], diff --git a/config/reference.php b/config/reference.php index 336e9a53..b16679e5 100644 --- a/config/reference.php +++ b/config/reference.php @@ -953,7 +953,7 @@ * token_handler?: Param|string|array{ * id?: scalar|Param|null, * oidc_user_info?: Param|string|array{ - * base_uri?: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured). + * base_uri?: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured). The latter must end with a slash when it has a path, as the discovery path is resolved against it as a relative URL. * discovery?: array{ // Enable the OIDC discovery. * cache?: array{ * id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. From 5b5ba455b0f88426d71bbeb5d96c476d72cb9565 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Mon, 19 Oct 2026 17:34:30 +0200 Subject: [PATCH 04/35] docs: add changelog entry for #165 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14016969..b6866f1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- [#165](https://github.com/itk-dev/devops_itksites/pull/165) + Update Composer packages, including `symfony/expression-language` for + CVE-2026-109449 - [#164](https://github.com/itk-dev/devops_itksites/pull/164) Clear two Doctrine deprecations: pass `SortDirection` to `orderBy()`, and run migrations without a transaction, which MariaDB never honoured From 8cc772a65495f8adfb5ed27612fef5859526c57c Mon Sep 17 00:00:00 2001 From: turegjorup Date: Mon, 19 Oct 2026 17:38:18 +0200 Subject: [PATCH 05/35] feat: update EasyAdmin to 5.7 --- .claude/skills/easyadmin/SKILL.md | 2 +- .claude/skills/easyadmin/references/api.md | 1 + CLAUDE.md | 2 +- composer.json | 2 +- composer.lock | 16 ++++++++-------- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.claude/skills/easyadmin/SKILL.md b/.claude/skills/easyadmin/SKILL.md index 0c7717e0..b3e6f26b 100644 --- a/.claude/skills/easyadmin/SKILL.md +++ b/.claude/skills/easyadmin/SKILL.md @@ -17,7 +17,7 @@ description: >- license: MIT metadata: author: EasyCorp - easyadmin-version: '5.6.1' + easyadmin-version: '5.7.0' installed-by: 'easyadmin:ai:install' --- diff --git a/.claude/skills/easyadmin/references/api.md b/.claude/skills/easyadmin/references/api.md index cec7f12d..3d0c7c40 100644 --- a/.claude/skills/easyadmin/references/api.md +++ b/.claude/skills/easyadmin/references/api.md @@ -1067,6 +1067,7 @@ collapsible(bool $collapsible = true): self renderCollapsed(bool $collapsed = true): self setBadge(Stringable|bool|callable|float|int|string|null $content, string $style = 'secondary', array $htmlAttributes = []): self setIcon(string $iconCssClass): self +setTabId(string $id): self ``` Also accepts every FieldTrait method (see [Field\FieldTrait](#fieldfieldtrait)). diff --git a/CLAUDE.md b/CLAUDE.md index 1537d3d4..7e512148 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -213,7 +213,7 @@ Pull requests run these checks: - API specs (`public/api-spec-v1.yaml` and `.json`) must be regenerated and committed when API changes -This project uses EasyAdmin 5.6.1. +This project uses EasyAdmin 5.7.0. Before creating or modifying admin dashboards, CRUD controllers, fields, actions, filters or their tests, read and follow the `easyadmin` skill at `.claude/skills/easyadmin/SKILL.md`. diff --git a/composer.json b/composer.json index 5df317da..212f9c70 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ "doctrine/doctrine-bundle": "^3.2", "doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/orm": "^3.0", - "easycorp/easyadmin-bundle": "^5.0", + "easycorp/easyadmin-bundle": "^5.7", "itk-dev/openid-connect-bundle": "^6.1", "itk-dev/vault-bundle": "^1.0.0", "nelmio/cors-bundle": "^2.2", diff --git a/composer.lock b/composer.lock index a774edfa..5fbffc39 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5b60abf97614f40273a6d34abdbbb8b4", + "content-hash": "add265be51e42b4531bddc6cbb629f77", "packages": [ { "name": "api-platform/core", @@ -1422,16 +1422,16 @@ }, { "name": "easycorp/easyadmin-bundle", - "version": "v5.6.1", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/EasyCorp/EasyAdminBundle.git", - "reference": "0b34965c9c8b7b82a6bbeefd892078b29e027614" + "reference": "ce1d6a5b8b2cbabc87ca63545b83b5430d9167ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/EasyCorp/EasyAdminBundle/zipball/0b34965c9c8b7b82a6bbeefd892078b29e027614", - "reference": "0b34965c9c8b7b82a6bbeefd892078b29e027614", + "url": "https://api.github.com/repos/EasyCorp/EasyAdminBundle/zipball/ce1d6a5b8b2cbabc87ca63545b83b5430d9167ad", + "reference": "ce1d6a5b8b2cbabc87ca63545b83b5430d9167ad", "shasum": "" }, "require": { @@ -1484,7 +1484,7 @@ "symfony/phpunit-bridge": "^6.4.26|^7.0|^8.0", "symfony/process": "^6.4.33|^7.0|^8.0", "symfony/web-link": "^6.4.32|^7.0|^8.0", - "vincentlanglet/twig-cs-fixer": "^3.10", + "vincentlanglet/twig-cs-fixer": "^4.1", "zenstruck/foundry": "^2.3" }, "type": "symfony-bundle", @@ -1520,7 +1520,7 @@ ], "support": { "issues": "https://github.com/EasyCorp/EasyAdminBundle/issues", - "source": "https://github.com/EasyCorp/EasyAdminBundle/tree/v5.6.1" + "source": "https://github.com/EasyCorp/EasyAdminBundle/tree/v5.7.0" }, "funding": [ { @@ -1528,7 +1528,7 @@ "type": "github" } ], - "time": "2026-09-30T05:59:24+00:00" + "time": "2026-10-14T06:20:50+00:00" }, { "name": "firebase/php-jwt", From ed9df367ede3a8b7800751ad70ca2d43d2202182 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Mon, 19 Oct 2026 17:38:35 +0200 Subject: [PATCH 06/35] docs: add changelog entry for #166 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6866f1d..fae2c595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- [#166](https://github.com/itk-dev/devops_itksites/pull/166) + Update EasyAdmin to 5.7, which adds the UI Kit at `/admin/_ea_ui_kit` in + debug mode - [#165](https://github.com/itk-dev/devops_itksites/pull/165) Update Composer packages, including `symfony/expression-language` for CVE-2026-109449 From e0628593c970383600d4f7ec7ec5f19e5c6dba1a Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 21 Oct 2026 12:25:56 +0200 Subject: [PATCH 07/35] fix: keep only sites inside the latest dir result Ignore dir results older than the server's latest, so replays cannot apply an old list. Attach sites to the listed dir they are in instead of a LIKE prefix, where _ was a wildcard, and delete sites and results outside every listed dir. --- phpstan.dist.neon | 1 - src/Handler/DirectoryHandler.php | 54 ++++--- src/Repository/DetectionResultRepository.php | 15 -- src/Repository/SiteRepository.php | 13 -- tests/Handler/DirectoryHandlerTest.php | 149 +++++++++++++++++++ 5 files changed, 183 insertions(+), 49 deletions(-) create mode 100644 tests/Handler/DirectoryHandlerTest.php diff --git a/phpstan.dist.neon b/phpstan.dist.neon index b723202c..f135648a 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -23,4 +23,3 @@ parameters: - src/Entity/ - src/Form/ - src/Service/ - - src/Handler/ diff --git a/src/Handler/DirectoryHandler.php b/src/Handler/DirectoryHandler.php index 5b7cca17..ea71f7c4 100644 --- a/src/Handler/DirectoryHandler.php +++ b/src/Handler/DirectoryHandler.php @@ -10,7 +10,9 @@ use App\Repository\SiteRepository; use App\Service\InstallationFactory; use App\Types\DetectionType; +use App\Utils\RootDirNormalizer; use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\EntityManagerInterface; /** * Handler for DetectionResult off type "dir" (Installations). @@ -24,34 +26,38 @@ public function __construct( private SiteRepository $siteRepository, private InstallationFactory $installationFactory, private DetectionResultRepository $detectionResultRepository, + private EntityManagerInterface $entityManager, ) { } public function handleResult(DetectionResult $detectionResult): void { $server = $detectionResult->getServer(); - $oldInstallations = $server->getInstallations(); + + // Only the latest dir result describes the server. Older ones (e.g. from a replay) are history. + $latest = $this->detectionResultRepository->findOneBy(['server' => $server, 'type' => DetectionType::DIRECTORY], ['lastContact' => 'DESC']); + if ($latest !== $detectionResult) { + return; + } $installations = $this->installationFactory->getInstallations($detectionResult); - /** @var Installation $installation */ - foreach ($installations as $installation) { - $sites = $this->siteRepository->findByRootDirAndServer( - $installation->getRootDir(), - $server - ); - /* @var Site $site */ - foreach ($sites as $site) { + // Sites belong to the listed dir they are in. Sites outside every listed dir are gone. + foreach ($this->siteRepository->findBy(['server' => $server]) as $site) { + $installation = $this->findEnclosing($installations, $site->getRootDir()); + if (null === $installation) { + $this->entityManager->remove($site); + } else { $installation->addSite($site); } } - $detectionResult->getServer()->setInstallations($installations); + $server->setInstallations($installations); - // Delete results from installations no longer seen on the server. - foreach ($oldInstallations as $oldInstallation) { - if (!$installations->contains($oldInstallation)) { - $this->detectionResultRepository->deleteByInstallation($oldInstallation); + // Delete results from dirs no longer listed, so a replay cannot recreate them. + foreach ($this->detectionResultRepository->findBy(['server' => $server]) as $result) { + if (DetectionType::DIRECTORY !== $result->getType() && null === $this->findEnclosing($installations, $result->getRootDir())) { + $this->entityManager->remove($result); } } } @@ -61,15 +67,23 @@ public function supportsType(string $type): bool return DetectionType::DIRECTORY === $type; } - private function getRemovedInstallations(Collection $oldInstallations, Collection $newInstallations): array + /** + * Find the installation for the root dir itself or, failing that, its closest parent dir. + * + * @param Collection $installations + */ + private function findEnclosing(Collection $installations, string $rootDir): ?Installation { - $result = []; - foreach ($oldInstallations as $installation) { - if (!$newInstallations->contains($installation)) { - $result[] = $installation; + $rootDir = RootDirNormalizer::normalize($rootDir); + $enclosing = null; + foreach ($installations as $installation) { + $dir = $installation->getRootDir(); + if (($rootDir === $dir || str_starts_with($rootDir, $dir.'/')) + && strlen($dir) > strlen($enclosing?->getRootDir() ?? '')) { + $enclosing = $installation; } } - return $result; + return $enclosing; } } diff --git a/src/Repository/DetectionResultRepository.php b/src/Repository/DetectionResultRepository.php index 56364897..0f304f4a 100644 --- a/src/Repository/DetectionResultRepository.php +++ b/src/Repository/DetectionResultRepository.php @@ -5,7 +5,6 @@ namespace App\Repository; use App\Entity\DetectionResult; -use App\Entity\Installation; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -89,18 +88,4 @@ public function cleanup(DetectionResult $detectionResult, int $keep = 5, bool $f $em->flush(); } } - - /** - * Delete all detection results for a given installation. - */ - public function deleteByInstallation(Installation $installation): void - { - $qb = $this->getEntityManager()->createQueryBuilder(); - $qb->delete(DetectionResult::class, 'd') - ->where('d.server = :server') - ->andWhere('d.rootDir =:rootDir') - ->setParameter('server', $installation->getServer()) - ->setParameter('rootDir', $installation->getRootDir()); - $qb->getQuery()->execute(); - } } diff --git a/src/Repository/SiteRepository.php b/src/Repository/SiteRepository.php index d4df5296..f8ed961e 100644 --- a/src/Repository/SiteRepository.php +++ b/src/Repository/SiteRepository.php @@ -4,7 +4,6 @@ namespace App\Repository; -use App\Entity\Server; use App\Entity\Site; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\AbstractQuery; @@ -25,18 +24,6 @@ public function __construct(ManagerRegistry $registry) parent::__construct($registry, Site::class); } - public function findByRootDirAndServer(string $rootDir, Server $server): mixed - { - return $this->createQueryBuilder('s') - ->andWhere('s.rootDir LIKE :rootDir') - ->setParameter('rootDir', $rootDir.'%') - ->andWhere('s.server = :server') - ->setParameter('server', $server->getId()->toBinary()) - ->getQuery() - ->getResult() - ; - } - /** * Get unique primary domains from existing sites. */ diff --git a/tests/Handler/DirectoryHandlerTest.php b/tests/Handler/DirectoryHandlerTest.php new file mode 100644 index 00000000..1c5e13b5 --- /dev/null +++ b/tests/Handler/DirectoryHandlerTest.php @@ -0,0 +1,149 @@ +entityManager = self::getContainer()->get(EntityManagerInterface::class); + $this->serverId = (string) $this->entityManager->getRepository(Server::class)->findOneBy([])->getId(); + } + + public function testRemovesInstallationsNoLongerInDirResult(): void + { + $this->process(DetectionType::DIRECTORY, '/data/www', [self::KEPT_DIR, self::GONE_DIR], new \DateTimeImmutable('-1 day')); + $this->process(DetectionType::DOCKER, self::KEPT_DIR, $this->dockerData('kept-probe.example.com')); + $goneDockerResultId = $this->process(DetectionType::DOCKER, self::GONE_DIR, $this->dockerData('gone-probe.example.com')); + + $this->assertNotNull($this->findInstallation(self::GONE_DIR)); + + $this->process(DetectionType::DIRECTORY, '/data/www', [self::KEPT_DIR]); + + $this->assertNull($this->findInstallation(self::GONE_DIR)); + $this->assertNull($this->entityManager->getRepository(Site::class)->findOneBy(['rootDir' => self::GONE_DIR])); + $this->assertNull($this->entityManager->getRepository(DetectionResult::class)->find($goneDockerResultId)); + + $this->assertNotNull($this->findInstallation(self::KEPT_DIR)); + $this->assertNotNull($this->entityManager->getRepository(Site::class)->findOneBy(['rootDir' => self::KEPT_DIR])); + } + + public function testIgnoresDirResultsOlderThanTheLatest(): void + { + $this->process(DetectionType::DIRECTORY, '/data/www', [self::KEPT_DIR, self::GONE_DIR]); + $this->process(DetectionType::DOCKER, self::GONE_DIR, $this->dockerData('gone-probe.example.com')); + + // E.g. replaying a dir result from before GONE_DIR was added. + $this->process(DetectionType::DIRECTORY, '/data', [self::KEPT_DIR], new \DateTimeImmutable('-1 day')); + + $this->assertNotNull($this->findInstallation(self::GONE_DIR)); + $this->assertNotNull($this->entityManager->getRepository(Site::class)->findOneBy(['rootDir' => self::GONE_DIR])); + $this->assertNotNull($this->entityManager->getRepository(DetectionResult::class)->findOneBy(['rootDir' => self::GONE_DIR])); + } + + public function testKeepsOnlySitesInsideAListedDir(): void + { + $subDir = self::KEPT_DIR.'/web'; + $lookAlikes = [ + '/data/www/kept.probe/htdocs', // matched "kept_probe" when "_" was a LIKE wildcard + self::KEPT_DIR.'_old', // matched as a prefix without a "/" boundary + ]; + + $this->process(DetectionType::DIRECTORY, '/data/www', [self::KEPT_DIR], new \DateTimeImmutable('-1 day')); + foreach ([$subDir, ...$lookAlikes] as $i => $rootDir) { + $this->process(DetectionType::DOCKER, $rootDir, $this->dockerData('probe'.$i.'.example.com')); + } + + // The state prod is in: look-alike sites moved onto the kept installation, their own installations gone. + $siteRepository = $this->entityManager->getRepository(Site::class); + $kept = $this->findInstallation(self::KEPT_DIR); + foreach ($lookAlikes as $rootDir) { + $site = $siteRepository->findOneBy(['rootDir' => $rootDir]); + $this->entityManager->remove($site->getInstallation()); + $kept->addSite($site); + } + $this->entityManager->flush(); + + $this->process(DetectionType::DIRECTORY, '/data', [self::KEPT_DIR]); + + $this->entityManager->clear(); + foreach ($lookAlikes as $rootDir) { + $this->assertNull($siteRepository->findOneBy(['rootDir' => $rootDir]), $rootDir); + $this->assertNull($this->entityManager->getRepository(DetectionResult::class)->findOneBy(['rootDir' => $rootDir]), $rootDir); + } + $this->assertSame(self::KEPT_DIR, $siteRepository->findOneBy(['rootDir' => $subDir])?->getInstallation()->getRootDir()); + $this->assertNotNull($this->entityManager->getRepository(DetectionResult::class)->findOneBy(['rootDir' => $subDir])); + } + + /** + * @return array + */ + private function dockerData(string $domain): array + { + return [ + 'domain' => $domain, + 'containers' => [[ + 'name' => 'phpfpm', + 'image' => 'itkdev/php8.4-fpm', + 'packages' => 'unknown', + 'audit' => 'unknown', + 'drupal' => 'unknown', + 'symfony' => 'unknown', + ]], + ]; + } + + /** + * Persist a detection result and run it through the real processing flow. + * + * @param array $data + */ + private function process(string $type, string $rootDir, array $data, ?\DateTimeImmutable $lastContact = null): string + { + $server = $this->entityManager->getRepository(Server::class)->find($this->serverId); + + $detectionResult = new DetectionResult(); + $detectionResult->setType($type); + $detectionResult->setRootDir($rootDir); + $detectionResult->setServer($server); + $detectionResult->setData(json_encode($data, JSON_THROW_ON_ERROR)); + $detectionResult->generateHash(); + $detectionResult->setLastContact($lastContact); + $this->entityManager->persist($detectionResult); + $this->entityManager->flush(); + + $id = $detectionResult->getId(); + self::getContainer()->get(ProcessDetectionResultHandler::class)(new ProcessDetectionResult($id)); + + return (string) $id; + } + + private function findInstallation(string $rootDir): ?Installation + { + $this->entityManager->clear(); + + return $this->entityManager->getRepository(Installation::class)->findOneBy(['rootDir' => $rootDir]); + } +} From ed1c3399dd434227c25b8417b3f2657d99759335 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 21 Oct 2026 12:25:59 +0200 Subject: [PATCH 08/35] fix: re-apply unchanged dir results on every harvest An unchanged dir result only had its timestamp updated, so sites recreated after it was first processed, e.g. by a docker replay, were never removed. --- .../PersistDetectionResultHandler.php | 7 ++++ .../PersistDetectionResultHandlerTest.php | 34 ++++++++++++++++--- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/MessageHandler/PersistDetectionResultHandler.php b/src/MessageHandler/PersistDetectionResultHandler.php index c745f155..24bc0008 100644 --- a/src/MessageHandler/PersistDetectionResultHandler.php +++ b/src/MessageHandler/PersistDetectionResultHandler.php @@ -8,6 +8,7 @@ use App\Message\ProcessDetectionResult; use App\Repository\DetectionResultRepository; use App\Repository\ServerRepository; +use App\Types\DetectionType; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; @@ -58,6 +59,12 @@ public function __invoke(PersistDetectionResult $message): void $existingResult->setLastContact($message->receivedAt); $this->entityManager->flush(); + + // Except dir results: they decide which installations exist, so re-apply them to + // remove anything recreated since they were first processed (e.g. by a replay). + if (DetectionType::DIRECTORY === $existingResult->getType() && null !== $id = $existingResult->getId()) { + $this->messageBus->dispatch(new ProcessDetectionResult($id)); + } } $this->entityManager->clear(); diff --git a/tests/MessageHandler/PersistDetectionResultHandlerTest.php b/tests/MessageHandler/PersistDetectionResultHandlerTest.php index 830d8e75..ff041e91 100644 --- a/tests/MessageHandler/PersistDetectionResultHandlerTest.php +++ b/tests/MessageHandler/PersistDetectionResultHandlerTest.php @@ -12,6 +12,7 @@ use App\Repository\DetectionResultRepository; use App\Repository\ServerRepository; use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\MockObject\MockObject; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\MessageBusInterface; @@ -19,10 +20,10 @@ class PersistDetectionResultHandlerTest extends KernelTestCase { - private ServerRepository $serverRepositoryMock; - private DetectionResultRepository $detectionResultRepositoryMock; - private EntityManagerInterface $entityManagerMock; - private MessageBusInterface $messageBusMock; + private ServerRepository&MockObject $serverRepositoryMock; + private DetectionResultRepository&MockObject $detectionResultRepositoryMock; + private EntityManagerInterface&MockObject $entityManagerMock; + private MessageBusInterface&MockObject $messageBusMock; private PersistDetectionResultHandler $handler; private DetectionResult $detectionResult; @@ -78,6 +79,8 @@ public function testInvokeNewDetectionResult(): void public function testInvokeExistingDetectionResult(): void { + $this->detectionResult->setType('docker'); + $apiKey = '12345678'; $receivedAt = new \DateTimeImmutable(); $detectionResultReceived = new PersistDetectionResult($this->detectionResult, $apiKey, $receivedAt); @@ -99,4 +102,27 @@ public function testInvokeExistingDetectionResult(): void // "lastContact" should only be updated for the existing result $this->assertEquals($receivedAt, $existingResult->getLastContact()); } + + public function testInvokeExistingDirDetectionResultIsReprocessed(): void + { + $receivedAt = new \DateTimeImmutable(); + $detectionResultReceived = new PersistDetectionResult($this->detectionResult, '12345678', $receivedAt); + + $existingResult = new DetectionResult(); + $existingResult->setId(new Ulid()); + $existingResult->setType('dir'); + + $this->detectionResultRepositoryMock->expects($this->once())->method('findOneBy') + ->willReturn($existingResult); + $this->entityManagerMock->expects($this->never())->method('persist'); + + // The dir list decides which installations exist, so it is re-applied on every harvest. + $this->messageBusMock->expects($this->once())->method('dispatch') + ->with(new ProcessDetectionResult($existingResult->getId())) + ->willReturn(new Envelope(new ProcessDetectionResult(new Ulid()))); + + $this->handler->__invoke($detectionResultReceived); + + $this->assertEquals($receivedAt, $existingResult->getLastContact()); + } } From a34976854083347ed9a78105016b8dbea6297d44 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 21 Oct 2026 12:26:01 +0200 Subject: [PATCH 09/35] feat: purge detection results older than 30 days daily Run app:data:purge on the default schedule at 03:00 with up to 30 minutes jitter. --date now takes relative dates such as "30 days ago". The worker consumes scheduler_default. --- composer.json | 2 +- docker-compose.server.override.yml | 2 +- src/Command/PurgeCommand.php | 10 ++-- tests/Command/PurgeCommandTest.php | 78 ++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 tests/Command/PurgeCommandTest.php diff --git a/composer.json b/composer.json index 212f9c70..4bd02797 100644 --- a/composer.json +++ b/composer.json @@ -139,7 +139,7 @@ "bin/console hautelook:fixtures:load --no-interaction" ], "queues": [ - "bin/console messenger:consume async scheduler_drupal_releases --failure-limit=1 -vvv" + "bin/console messenger:consume async scheduler_drupal_releases scheduler_default --failure-limit=1 -vvv" ], "tests": [ "bin/console --env=test doctrine:database:drop --if-exists --force --quiet", diff --git a/docker-compose.server.override.yml b/docker-compose.server.override.yml index 8eb7ce73..da42b572 100644 --- a/docker-compose.server.override.yml +++ b/docker-compose.server.override.yml @@ -24,7 +24,7 @@ services: restart: unless-stopped stop_grace_period: 20s environment: - - APP_SUPERVISOR_COMMAND=/app/bin/console messenger:consume --env=prod --no-debug --time-limit=900 --failure-limit=1 async scheduler_drupal_releases + - APP_SUPERVISOR_COMMAND=/app/bin/console messenger:consume --env=prod --no-debug --time-limit=900 --failure-limit=1 async scheduler_drupal_releases scheduler_default - APP_SUPERVISOR_WORKERS=1 - APP_SUPERVISOR_USER=deploy - PHP_MEMORY_LIMIT=256M diff --git a/src/Command/PurgeCommand.php b/src/Command/PurgeCommand.php index d37cbad1..32981dc2 100644 --- a/src/Command/PurgeCommand.php +++ b/src/Command/PurgeCommand.php @@ -11,11 +11,13 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Scheduler\Attribute\AsPeriodicTask; #[AsCommand( name: 'app:data:purge', description: 'Purge old data', )] +#[AsPeriodicTask(frequency: '1 day', from: '03:00', jitter: 1800, arguments: '--date="30 days ago"')] class PurgeCommand extends Command { public function __construct( @@ -29,17 +31,17 @@ public function __construct( */ protected function configure(): void { - $this->addOption('date', null, InputOption::VALUE_REQUIRED, 'Remove results older that this date (Y-m-d)'); + $this->addOption('date', null, InputOption::VALUE_REQUIRED, 'Remove results not seen since this date, e.g. "2026-09-21" or "30 days ago"'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - $format = 'Y-m-d'; $date = (string) $input->getOption('date'); - $dateTime = \DateTime::createFromFormat($format, $date); - if (false === $dateTime || $dateTime->format($format) !== $date) { + try { + $dateTime = new \DateTime($date); + } catch (\DateMalformedStringException) { $output->writeln('Unknown date format: '.$date); return Command::FAILURE; diff --git a/tests/Command/PurgeCommandTest.php b/tests/Command/PurgeCommandTest.php new file mode 100644 index 00000000..50ba149a --- /dev/null +++ b/tests/Command/PurgeCommandTest.php @@ -0,0 +1,78 @@ +get(EntityManagerInterface::class); + $server = $entityManager->getRepository(Server::class)->findOneBy([]); + foreach (['-31 days' => 'old', '-29 days' => 'recent'] as $lastContact => $data) { + $result = new DetectionResult(); + $result->setType(DetectionType::DOCKER); + $result->setRootDir(self::ROOT_DIR); + $result->setServer($server); + $result->setData($data); + $result->generateHash(); + $result->setLastContact(new \DateTimeImmutable($lastContact)); + $entityManager->persist($result); + } + $entityManager->flush(); + + $tester = $this->console(['--date' => '30 days ago']); + + $tester->assertCommandIsSuccessful(); + $entityManager->clear(); + $remaining = $entityManager->getRepository(DetectionResult::class)->findBy(['rootDir' => self::ROOT_DIR]); + $this->assertSame(['recent'], array_map(static fn (DetectionResult $result): string => $result->getData(), $remaining)); + } + + public function testRejectsAnUnparsableDate(): void + { + self::bootKernel(); + + $this->console(['--date' => 'not a date'])->assertCommandFailed(); + } + + public function testRunsDailyOnTheDefaultSchedule(): void + { + self::bootKernel(); + + $tester = $this->console(['command' => 'debug:scheduler', 'schedule' => ['default']]); + + $tester->assertCommandIsSuccessful(); + $this->assertStringContainsString('every 1 day with 0-1800 second jitter', $tester->getDisplay()); + $this->assertStringContainsString('app:data:purge --date="30 days ago"', $tester->getDisplay()); + } + + /** + * @param array $input + */ + private function console(array $input): ApplicationTester + { + $application = new Application(self::$kernel); + $application->setAutoExit(false); + + $tester = new ApplicationTester($application); + $tester->run($input + ['command' => 'app:data:purge']); + + return $tester; + } +} From 1d8a04472584f6f921365ef5d002968b4a962ead Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 21 Oct 2026 12:33:21 +0200 Subject: [PATCH 10/35] refactor: run scheduled tasks on one stateful default schedule Attribute tasks such as the purge cannot be stateful on their own, and the worker restarts every 15 minutes, so the jittered purge would skip most days. Turn DrupalReleaseSchedule into the stateful default schedule that attribute tasks join, and consume only scheduler_default. --- composer.json | 2 +- docker-compose.server.override.yml | 2 +- ...eleaseSchedule.php => DefaultSchedule.php} | 12 +++-- tests/Scheduler/DefaultScheduleTest.php | 51 +++++++++++++++++++ tests/Scheduler/DrupalReleaseScheduleTest.php | 39 -------------- 5 files changed, 60 insertions(+), 46 deletions(-) rename src/Scheduler/{DrupalReleaseSchedule.php => DefaultSchedule.php} (62%) create mode 100644 tests/Scheduler/DefaultScheduleTest.php delete mode 100644 tests/Scheduler/DrupalReleaseScheduleTest.php diff --git a/composer.json b/composer.json index 4bd02797..01ef105c 100644 --- a/composer.json +++ b/composer.json @@ -139,7 +139,7 @@ "bin/console hautelook:fixtures:load --no-interaction" ], "queues": [ - "bin/console messenger:consume async scheduler_drupal_releases scheduler_default --failure-limit=1 -vvv" + "bin/console messenger:consume async scheduler_default --failure-limit=1 -vvv" ], "tests": [ "bin/console --env=test doctrine:database:drop --if-exists --force --quiet", diff --git a/docker-compose.server.override.yml b/docker-compose.server.override.yml index da42b572..384113c1 100644 --- a/docker-compose.server.override.yml +++ b/docker-compose.server.override.yml @@ -24,7 +24,7 @@ services: restart: unless-stopped stop_grace_period: 20s environment: - - APP_SUPERVISOR_COMMAND=/app/bin/console messenger:consume --env=prod --no-debug --time-limit=900 --failure-limit=1 async scheduler_drupal_releases scheduler_default + - APP_SUPERVISOR_COMMAND=/app/bin/console messenger:consume --env=prod --no-debug --time-limit=900 --failure-limit=1 async scheduler_default - APP_SUPERVISOR_WORKERS=1 - APP_SUPERVISOR_USER=deploy - PHP_MEMORY_LIMIT=256M diff --git a/src/Scheduler/DrupalReleaseSchedule.php b/src/Scheduler/DefaultSchedule.php similarity index 62% rename from src/Scheduler/DrupalReleaseSchedule.php rename to src/Scheduler/DefaultSchedule.php index 01b13ce4..3c7f2e62 100644 --- a/src/Scheduler/DrupalReleaseSchedule.php +++ b/src/Scheduler/DefaultSchedule.php @@ -12,13 +12,14 @@ use Symfony\Contracts\Cache\CacheInterface; /** - * Check drupal.org releases every 12 hours. + * The app's one schedule. Tasks declared with #[AsPeriodicTask] or + * #[AsCronTask] join it, e.g. app:data:purge. * - * Anchored and stateful because the worker restarts every 15 minutes - * (--time-limit=900), which would otherwise reset the clock on each boot. + * Stateful because the worker restarts every 15 minutes (--time-limit=900), + * which would otherwise reset the clock on each boot and skip runs. */ -#[AsSchedule('drupal_releases')] -final readonly class DrupalReleaseSchedule implements ScheduleProviderInterface +#[AsSchedule] +final readonly class DefaultSchedule implements ScheduleProviderInterface { public function __construct( private CacheInterface $cache, @@ -28,6 +29,7 @@ public function __construct( public function getSchedule(): Schedule { return new Schedule() + // Check drupal.org releases every 12 hours. ->add(RecurringMessage::every('12 hours', new CheckDrupalReleases(), from: '06:00')) ->stateful($this->cache); } diff --git a/tests/Scheduler/DefaultScheduleTest.php b/tests/Scheduler/DefaultScheduleTest.php new file mode 100644 index 00000000..3eaeb858 --- /dev/null +++ b/tests/Scheduler/DefaultScheduleTest.php @@ -0,0 +1,51 @@ +get('messenger.receiver_locator'); + + $this->assertTrue($receivers->has('scheduler_default')); + $this->assertFalse($receivers->has('scheduler_drupal_releases')); + } + + public function testIsStatefulAndHoldsTheDrupalCheckAndThePurge(): void + { + self::bootKernel(); + /** @var Schedule $schedule */ + $schedule = self::getContainer()->get('scheduler.provider.default'); + + // Stateful, so the worker's 15 minute restarts do not skip runs. + $this->assertNotNull($schedule->getState()); + + $messages = []; + foreach ($schedule->getRecurringMessages() as $message) { + $messages[$this->messageOf($message)::class] = $message; + } + $this->assertEqualsCanonicalizing([CheckDrupalReleases::class, RunCommandMessage::class], array_keys($messages)); + + $trigger = $messages[CheckDrupalReleases::class]->getTrigger(); + $this->assertEquals(new \DateTimeImmutable('18:00'), $trigger->getNextRunDate(new \DateTimeImmutable('07:00'))); + $this->assertEquals(new \DateTimeImmutable('tomorrow 06:00'), $trigger->getNextRunDate(new \DateTimeImmutable('19:00'))); + } + + private function messageOf(RecurringMessage $message): object + { + $context = new MessageContext('default', $message->getId(), $message->getTrigger(), new \DateTimeImmutable()); + + return iterator_to_array($message->getMessages($context))[0]; + } +} diff --git a/tests/Scheduler/DrupalReleaseScheduleTest.php b/tests/Scheduler/DrupalReleaseScheduleTest.php deleted file mode 100644 index 224cc2d6..00000000 --- a/tests/Scheduler/DrupalReleaseScheduleTest.php +++ /dev/null @@ -1,39 +0,0 @@ -assertTrue(self::getContainer()->get('messenger.receiver_locator')->has('scheduler_drupal_releases')); - } - - public function testRecursEvery12HoursFrom0600AndIsStateful(): void - { - self::bootKernel(); - $schedule = self::getContainer()->get(DrupalReleaseSchedule::class)->getSchedule(); - - $this->assertNotNull($schedule->getState()); - - $messages = $schedule->getRecurringMessages(); - $this->assertCount(1, $messages); - $message = $messages[0]; - $trigger = $message->getTrigger(); - - $context = new MessageContext('drupal_releases', $message->getId(), $trigger, new \DateTimeImmutable()); - $this->assertInstanceOf(CheckDrupalReleases::class, iterator_to_array($message->getMessages($context))[0]); - - $this->assertEquals(new \DateTimeImmutable('18:00'), $trigger->getNextRunDate(new \DateTimeImmutable('07:00'))); - $this->assertEquals(new \DateTimeImmutable('tomorrow 06:00'), $trigger->getNextRunDate(new \DateTimeImmutable('19:00'))); - } -} From d2911b1e956f19288ef9e8a1a7edea018d6869ba Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 21 Oct 2026 12:46:36 +0200 Subject: [PATCH 11/35] chore: drop --failure-limit=1 from the workers The worker resets services after each message, which replaces a closed EntityManager, and failed messages are retried and then sent to the failed transport. Stopping on every failure only restarted the worker. --- README.md | 2 +- Taskfile.yml | 2 +- composer.json | 2 +- docker-compose.server.override.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 06197c5e..3c9d0817 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ docker compose exec phpfpm composer queues or ```shell -docker compose exec phpfpm bin/console messenger:consume async --failure-limit=1 -vvv +docker compose exec phpfpm bin/console messenger:consume async -vvv ``` ### Assets diff --git a/Taskfile.yml b/Taskfile.yml index c17a696f..a4807ea8 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -59,7 +59,7 @@ tasks: cmds: - task: console vars: - CONSOLE_ARGS: messenger:consume async --failure-limit=1 -vvv + CONSOLE_ARGS: messenger:consume async -vvv composer: desc: "Run `composer` command. Example: task composer -- normalize" diff --git a/composer.json b/composer.json index 01ef105c..053673da 100644 --- a/composer.json +++ b/composer.json @@ -139,7 +139,7 @@ "bin/console hautelook:fixtures:load --no-interaction" ], "queues": [ - "bin/console messenger:consume async scheduler_default --failure-limit=1 -vvv" + "bin/console messenger:consume async scheduler_default -vvv" ], "tests": [ "bin/console --env=test doctrine:database:drop --if-exists --force --quiet", diff --git a/docker-compose.server.override.yml b/docker-compose.server.override.yml index 384113c1..994958e4 100644 --- a/docker-compose.server.override.yml +++ b/docker-compose.server.override.yml @@ -24,7 +24,7 @@ services: restart: unless-stopped stop_grace_period: 20s environment: - - APP_SUPERVISOR_COMMAND=/app/bin/console messenger:consume --env=prod --no-debug --time-limit=900 --failure-limit=1 async scheduler_default + - APP_SUPERVISOR_COMMAND=/app/bin/console messenger:consume --env=prod --no-debug --time-limit=900 async scheduler_default - APP_SUPERVISOR_WORKERS=1 - APP_SUPERVISOR_USER=deploy - PHP_MEMORY_LIMIT=256M From 8d03f65d57bced827461f6ad9a97d557f1459acd Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 21 Oct 2026 12:47:10 +0200 Subject: [PATCH 12/35] docs: add changelog entry for #167 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fae2c595..4c5d1e45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- [#167](https://github.com/itk-dev/devops_itksites/pull/167) + Let the latest dir result decide which sites exist, so replays no longer + revive removed sites or delete live ones, and purge detection results not + seen for 30 days every night - [#166](https://github.com/itk-dev/devops_itksites/pull/166) Update EasyAdmin to 5.7, which adds the UI Kit at `/admin/_ea_ui_kit` in debug mode From f5452a8276a34ee032f2d0890e35046769c02c6c Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 21 Oct 2026 14:40:38 +0200 Subject: [PATCH 13/35] test: stop assuming the fixture set is minimal The site filter test now removes the fixture sites it does not set up, the link command test counts from what the fixtures already link, the module sort test checks the order of the counts, and the linker test checks the module is not linked by name rather than not linked at all. --- tests/Api/SiteFilterTest.php | 9 +++++++-- tests/Command/DrupalLinkPackagesCommandTest.php | 12 ++++++++---- tests/Controller/Admin/AdminSmokeTest.php | 12 +++++++++--- tests/Service/DrupalPackageLinkerTest.php | 5 +++-- 4 files changed, 27 insertions(+), 11 deletions(-) diff --git a/tests/Api/SiteFilterTest.php b/tests/Api/SiteFilterTest.php index 2d226659..f6d61168 100644 --- a/tests/Api/SiteFilterTest.php +++ b/tests/Api/SiteFilterTest.php @@ -25,8 +25,8 @@ class SiteFilterTest extends ApiTestCase protected static ?bool $alwaysBootKernel = true; /** - * Give the two fixture sites their own server and known values, so every - * filter has a predictable result. + * Give the two fixture sites their own server and known values, and remove + * the other fixture sites, so every filter has a predictable result. */ private function createClientWithKnownSites(): Client { @@ -41,6 +41,11 @@ private function createClientWithKnownSites(): Client $servers = $manager->getRepository(Server::class)->findAll(); foreach ($manager->getRepository(Site::class)->findAll() as $site) { + if (!isset($sites[$site->getPrimaryDomain()])) { + $manager->remove($site); + + continue; + } [$name, $type, $hostingProvider, $phpVersion, $configFilePath] = $sites[$site->getPrimaryDomain()]; $server = array_shift($servers); self::assertInstanceOf(Server::class, $server); diff --git a/tests/Command/DrupalLinkPackagesCommandTest.php b/tests/Command/DrupalLinkPackagesCommandTest.php index 43d8f690..92ed2820 100644 --- a/tests/Command/DrupalLinkPackagesCommandTest.php +++ b/tests/Command/DrupalLinkPackagesCommandTest.php @@ -23,6 +23,12 @@ public function testBackfillsExistingRows(): void { self::bootKernel(); $entityManager = self::getContainer()->get(EntityManagerInterface::class); + $tester = new CommandTester(new Application(self::$kernel)->find('app:drupal:link-packages')); + + // The fixtures hold modules the command links too. + $tester->execute([]); + preg_match('/Linked (\d+) modules/', $tester->getDisplay(), $matches); + $expected = sprintf('Linked %1$d modules and %1$d module versions.', (int) $matches[1] + 1); // Rows written without the factories, as before the linker existed. $package = new Package()->setVendor('drupal')->setName('backfill_probe'); @@ -40,17 +46,15 @@ public function testBackfillsExistingRows(): void ->addModuleVersion($moduleVersion); $entityManager->flush(); - $tester = new CommandTester(new Application(self::$kernel)->find('app:drupal:link-packages')); - $tester->execute([]); $tester->assertCommandIsSuccessful(); - $this->assertStringContainsString('Linked 1 modules and 1 module versions.', $tester->getDisplay()); + $this->assertStringContainsString($expected, $tester->getDisplay()); $this->assertSame($packageVersion, $moduleVersion->getComposerPackageVersion()); $this->assertSame($package, $module->getComposerPackage()); $tester->execute([]); $tester->assertCommandIsSuccessful(); - $this->assertStringContainsString('Linked 1 modules and 1 module versions.', $tester->getDisplay()); + $this->assertStringContainsString($expected, $tester->getDisplay()); $this->assertSame($packageVersion, $moduleVersion->getComposerPackageVersion()); } } diff --git a/tests/Controller/Admin/AdminSmokeTest.php b/tests/Controller/Admin/AdminSmokeTest.php index c7dcd6f2..f3703f01 100644 --- a/tests/Controller/Admin/AdminSmokeTest.php +++ b/tests/Controller/Admin/AdminSmokeTest.php @@ -34,6 +34,7 @@ use Hautelook\AliceBundle\PhpUnit\RefreshDatabaseTrait; use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\DomCrawler\Crawler; class AdminSmokeTest extends WebTestCase { @@ -121,7 +122,7 @@ public function testModuleAdvisoriesRender(string $controllerClass, string $sort // A reboot between requests drops the rows created above. $client->disableReboot(); - foreach (['DESC' => true, 'ASC' => false] as $direction => $linkedFirst) { + foreach (['DESC', 'ASC'] as $direction) { $url = static::getContainer()->get(AdminUrlGenerator::class) ->setController($controllerClass) ->setAction(Crud::PAGE_INDEX) @@ -131,8 +132,13 @@ public function testModuleAdvisoriesRender(string $controllerClass, string $sort $crawler = $client->request('GET', $url); $this->assertResponseIsSuccessful(); - $firstId = $crawler->filter('tbody tr[data-id]')->first()->attr('data-id'); - $this->assertSame($linkedFirst, $linkedId === $firstId, $direction); + // A row without advisories has no badge. + $counts = $crawler->filter('tbody tr[data-id]')->each( + static fn (Crawler $row): int => $row->filter('.badge-danger')->count() > 0 ? (int) $row->filter('.badge-danger')->text() : 0 + ); + $sorted = $counts; + 'DESC' === $direction ? rsort($sorted) : sort($sorted); + $this->assertSame($sorted, $counts, $direction); } $this->assertSelectorTextContains('tr[data-id="'.$linkedId.'"] .badge-danger', '1'); diff --git a/tests/Service/DrupalPackageLinkerTest.php b/tests/Service/DrupalPackageLinkerTest.php index 2630f5ce..f961e5c1 100644 --- a/tests/Service/DrupalPackageLinkerTest.php +++ b/tests/Service/DrupalPackageLinkerTest.php @@ -126,8 +126,9 @@ public function testDoesNotLinkByNameWhenProjectDiffers(): void $packageVersion = $this->entityManager->getRepository(PackageVersion::class)->findOneBy(['version' => '6.2.3']); self::getContainer()->get(DrupalPackageLinker::class)->linkPackageVersion($packageVersion); - $this->assertNull($moduleVersion->getComposerPackageVersion()); - $this->assertNull($moduleVersion->getModule()->getComposerPackage()); + // It may link to its project, drupal/webform, which the fixtures hold. + $this->assertNotSame($packageVersion, $moduleVersion->getComposerPackageVersion()); + $this->assertNotSame('webform_ui', $moduleVersion->getModule()->getComposerPackage()?->getName()); } /** From 6f37f0418e1eb3f27bba3b5a8ccbb01cce768c9d Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 21 Oct 2026 14:40:41 +0200 Subject: [PATCH 14/35] test: add advisory, git repo and Drupal module fixtures Made-up data shaped like production: a repo checked out at two versions with overlapping advisories, a repo without advisories, a release deployment without a repo, drupal.org and Packagist advisories with and without a CVE, one CVE under two advisory ids, and Drupal modules linked to their Composer packages. A test pins these cases and that only example data is used. --- fixtures/advisory.yaml | 382 ++++++++++++++++++++ tests/DataFixtures/AdvisoryFixturesTest.php | 137 +++++++ 2 files changed, 519 insertions(+) create mode 100644 fixtures/advisory.yaml create mode 100644 tests/DataFixtures/AdvisoryFixturesTest.php diff --git a/fixtures/advisory.yaml b/fixtures/advisory.yaml new file mode 100644 index 00000000..a31ab3e5 --- /dev/null +++ b/fixtures/advisory.yaml @@ -0,0 +1,382 @@ +# Installations with packages, advisories, git repos and Drupal modules, +# shaped like production data. All names, versions and ids are made up: CVE ids +# use the year 2099 and git repos the example-org organization. +# +# - example-org/shop is checked out twice, stg on a branch and prod on a tag. +# Both have the twig advisory, only prod has the http-foundation one. +# - example-org/intranet is a Drupal site whose modules reach advisories through +# their Composer packages. drupal/core has a drupal.org and a Packagist +# advisory for the same CVE. +# - release.example.com is deployed from a release download, so it has no git +# repo, but it has advisories. +# - example-org/clean has no advisories. + +App\Entity\GitRepo: + git_repo/shop: + provider: "github.com" + organization: "example-org" + repo: "shop" + git_repo/intranet: + provider: "github.com" + organization: "example-org" + repo: "intranet" + git_repo/clean: + provider: "github.com" + organization: "example-org" + repo: "clean" + +App\Entity\GitTag: + # A branch checkout has no tag. + git_tag/shop-unknown: + tag: "unknown" + repo: "@git_repo/shop" + git_tag/shop-2.4.1: + tag: "2.4.1" + repo: "@git_repo/shop" + git_tag/intranet-1.8.0: + tag: "1.8.0" + repo: "@git_repo/intranet" + git_tag/clean-1.0.0: + tag: "1.0.0" + repo: "@git_repo/clean" + +App\Entity\Package: + package/symfony-http-foundation: + vendor: "symfony" + name: "http-foundation" + description: "Defines an object-oriented layer for the HTTP specification" + license: "MIT" + package/twig: + vendor: "twig" + name: "twig" + description: "Twig, the flexible, fast, and secure template language for PHP" + license: "BSD-3-Clause" + package/league-commonmark: + vendor: "league" + name: "commonmark" + description: "Highly-extensible PHP Markdown parser" + license: "BSD-3-Clause" + package/guzzle: + vendor: "guzzlehttp" + name: "guzzle" + description: "Guzzle is a PHP HTTP client library" + license: "MIT" + package/drupal-core: + vendor: "drupal" + name: "core" + description: "Drupal is an open source content management platform" + license: "GPL-2.0-or-later" + package/drupal-webform: + vendor: "drupal" + name: "webform" + description: "Enables the creation of webforms and questionnaires" + license: "GPL-2.0-or-later" + +App\Entity\Advisory: + advisory/symfony-http-foundation: + advisoryId: "PKSA-x9q2-7fhd-3k1m" + package: "@package/symfony-http-foundation" + affectedVersions: ">=6.0.0,<6.4.18|>=7.0.0,<7.2.4" + title: "CVE-2099-10001: Example open redirect in Request::getUri()" + cve: "CVE-2099-10001" + link: "https://example.com/advisories/cve-2099-10001" + reportedAt: '<(new DateTimeImmutable("2026-02-11 10:00:00"))>' + sources: + - name: "FriendsOfPHP/security-advisories" + remoteId: "symfony/http-foundation/CVE-2099-10001.yaml" + advisory/twig: + advisoryId: "PKSA-b4n7-2wqz-8c5r" + package: "@package/twig" + affectedVersions: ">=3.0.0,<3.11.2" + title: "CVE-2099-10002: Example sandbox bypass" + cve: "CVE-2099-10002" + link: "https://example.com/advisories/cve-2099-10002" + reportedAt: '<(new DateTimeImmutable("2026-03-04 09:30:00"))>' + sources: + - name: "FriendsOfPHP/security-advisories" + remoteId: "twig/twig/CVE-2099-10002.yaml" + advisory/league-commonmark: + advisoryId: "PKSA-m3r8-5tyv-1h6p" + package: "@package/league-commonmark" + affectedVersions: ">=2.0.0,<2.6.0" + title: "Example denial of service through nested lists" + link: "https://example.com/advisories/ghsa-7f3m-q2kx-9w4c" + reportedAt: '<(new DateTimeImmutable("2026-04-20 14:15:00"))>' + sources: + - name: "GitHub" + remoteId: "GHSA-7f3m-q2kx-9w4c" + advisory/drupal-core-sa: + advisoryId: "SA-CORE-2099-001" + package: "@package/drupal-core" + affectedVersions: ">= 8.0.0 < 10.3.9 || >= 10.4.0 < 10.4.2" + title: "Drupal core - Moderately critical - Example access bypass" + cve: "CVE-2099-10003" + link: "https://example.com/advisories/sa-core-2099-001" + reportedAt: '<(new DateTimeImmutable("2026-05-13 17:00:00"))>' + sources: + - name: "Drupal" + remoteId: "SA-CORE-2099-001" + # The same CVE as SA-CORE-2099-001, reported to Packagist. + advisory/drupal-core-pksa: + advisoryId: "PKSA-d8k2-6vnp-4j9t" + package: "@package/drupal-core" + affectedVersions: ">=8.0.0,<10.3.9|>=10.4.0,<10.4.2" + title: "Drupal core - Moderately critical - Example access bypass" + cve: "CVE-2099-10003" + link: "https://example.com/advisories/sa-core-2099-001" + reportedAt: '<(new DateTimeImmutable("2026-05-13 17:00:00"))>' + sources: + - name: "FriendsOfPHP/security-advisories" + remoteId: "drupal/core/CVE-2099-10003.yaml" + advisory/drupal-webform: + advisoryId: "SA-CONTRIB-2099-001" + package: "@package/drupal-webform" + affectedVersions: "<6.2.10" + title: "Webform - Critical - Example cross site scripting" + link: "https://example.com/advisories/sa-contrib-2099-001" + reportedAt: '<(new DateTimeImmutable("2026-06-03 16:45:00"))>' + sources: + - name: "Drupal" + remoteId: "SA-CONTRIB-2099-001" + +App\Entity\PackageVersion: + package_version/symfony-http-foundation-6.4.10: + package: "@package/symfony-http-foundation" + version: "6.4.10" + latest: "6.4.20" + latestStatus: "semver-safe-update" + __calls: + - addAdvisory: ["@advisory/symfony-http-foundation"] + package_version/symfony-http-foundation-6.4.20: + package: "@package/symfony-http-foundation" + version: "6.4.20" + latest: "7.2.4" + latestStatus: "update-possible" + package_version/twig-3.8.0: + package: "@package/twig" + version: "3.8.0" + latest: "3.11.2" + latestStatus: "semver-safe-update" + __calls: + - addAdvisory: ["@advisory/twig"] + package_version/league-commonmark-2.4.0: + package: "@package/league-commonmark" + version: "2.4.0" + latest: "2.6.0" + latestStatus: "semver-safe-update" + __calls: + - addAdvisory: ["@advisory/league-commonmark"] + package_version/guzzle-7.9.2: + package: "@package/guzzle" + version: "7.9.2" + latest: "7.9.2" + latestStatus: "up-to-date" + package_version/drupal-core-10.3.5: + package: "@package/drupal-core" + version: "10.3.5" + latest: "10.4.2" + latestStatus: "semver-safe-update" + __calls: + - addAdvisory: ["@advisory/drupal-core-sa"] + - addAdvisory: ["@advisory/drupal-core-pksa"] + package_version/drupal-webform-6.2.9: + package: "@package/drupal-webform" + version: "6.2.9" + latest: "6.2.10" + latestStatus: "semver-safe-update" + __calls: + - addAdvisory: ["@advisory/drupal-webform"] + +App\Entity\Module: + module/node: + package: "Core" + name: "node" + displayName: "Node" + project: "drupal" + enabled: true + composerPackage: "@package/drupal-core" + module/webform: + package: "Webform" + name: "webform" + displayName: "Webform" + project: "webform" + enabled: true + composerPackage: "@package/drupal-webform" + +App\Entity\ModuleVersion: + module_version/node-10.3.5: + module: "@module/node" + version: "10.3.5" + composerPackageVersion: "@package_version/drupal-core-10.3.5" + module_version/webform-6.2.9: + module: "@module/webform" + version: "6.2.9" + composerPackageVersion: "@package_version/drupal-webform-6.2.9" + +App\Entity\DetectionResult: + detection_result/shop-stg: + server: "@server_2" + type: "docker" + rootDir: "/data/www/stg_shop_example_com/htdocs" + data: '{"domain": "stg.shop.example.com"}' + __calls: + - generateHash: [] + - setLastContact: [] + detection_result/shop-prod: + server: "@server_3" + type: "docker" + rootDir: "/data/www/shop_example_com/htdocs" + data: '{"domain": "shop.example.com www.shop.example.com"}' + __calls: + - generateHash: [] + - setLastContact: [] + detection_result/intranet: + server: "@server_2" + type: "docker" + rootDir: "/data/www/intranet_example_com/htdocs" + data: '{"domain": "intranet.example.com"}' + __calls: + - generateHash: [] + - setLastContact: [] + detection_result/release: + server: "@server_4" + type: "docker" + rootDir: "/data/www/release_example_com/htdocs" + data: '{"domain": "release.example.com"}' + __calls: + - generateHash: [] + - setLastContact: [] + detection_result/clean: + server: "@server_3" + type: "docker" + rootDir: "/data/www/clean_example_com/htdocs" + data: '{"domain": "clean.example.com"}' + __calls: + - generateHash: [] + - setLastContact: [] + +App\Entity\Installation: + installation/shop-stg: + detectionResult: "@detection_result/shop-stg" + type: "symfony" + frameworkVersion: "6.4.20" + phpVersion: "8.3.30" + composerVersion: "2.8.5" + lts: true + eol: "11/2027" + gitTag: "@git_tag/shop-unknown" + gitClonedScheme: "ssh" + __calls: + - addPackageVersion: + ["@package_version/symfony-http-foundation-6.4.20"] + - addPackageVersion: ["@package_version/twig-3.8.0"] + - addPackageVersion: ["@package_version/guzzle-7.9.2"] + installation/shop-prod: + detectionResult: "@detection_result/shop-prod" + type: "symfony" + frameworkVersion: "6.4.10" + phpVersion: "8.3.30" + composerVersion: "2.8.5" + lts: true + eol: "11/2027" + gitTag: "@git_tag/shop-2.4.1" + gitClonedScheme: "https" + __calls: + - addPackageVersion: + ["@package_version/symfony-http-foundation-6.4.10"] + - addPackageVersion: ["@package_version/twig-3.8.0"] + - addPackageVersion: ["@package_version/guzzle-7.9.2"] + installation/intranet: + detectionResult: "@detection_result/intranet" + type: "drupal" + frameworkVersion: "10.3.5" + phpVersion: "8.3.30" + composerVersion: "2.8.5" + gitTag: "@git_tag/intranet-1.8.0" + gitClonedScheme: "https" + __calls: + - addPackageVersion: ["@package_version/drupal-core-10.3.5"] + - addPackageVersion: ["@package_version/drupal-webform-6.2.9"] + - addPackageVersion: ["@package_version/league-commonmark-2.4.0"] + - addModuleVersion: ["@module_version/node-10.3.5"] + - addModuleVersion: ["@module_version/webform-6.2.9"] + installation/release: + detectionResult: "@detection_result/release" + type: "drupal" + frameworkVersion: "10.3.5" + phpVersion: "8.3.30" + composerVersion: "2.8.5" + __calls: + - addPackageVersion: ["@package_version/drupal-core-10.3.5"] + - addPackageVersion: ["@package_version/drupal-webform-6.2.9"] + - addModuleVersion: ["@module_version/node-10.3.5"] + - addModuleVersion: ["@module_version/webform-6.2.9"] + installation/clean: + detectionResult: "@detection_result/clean" + type: "symfony" + frameworkVersion: "7.2.4" + phpVersion: "8.4.5" + composerVersion: "2.8.5" + eol: "07/2025 Expired" + gitTag: "@git_tag/clean-1.0.0" + gitClonedScheme: "ssh" + __calls: + - addPackageVersion: ["@package_version/guzzle-7.9.2"] + +App\Entity\Domain: + domain/stg.shop.example.com: + address: "stg.shop.example.com" + detectionResult: "@detection_result/shop-stg" + domain/shop.example.com: + address: "shop.example.com" + detectionResult: "@detection_result/shop-prod" + domain/www.shop.example.com: + address: "www.shop.example.com" + detectionResult: "@detection_result/shop-prod" + domain/intranet.example.com: + address: "intranet.example.com" + detectionResult: "@detection_result/intranet" + domain/release.example.com: + address: "release.example.com" + detectionResult: "@detection_result/release" + domain/clean.example.com: + address: "clean.example.com" + detectionResult: "@detection_result/clean" + +App\Entity\Site: + site/shop-stg: + detectionResult: "@detection_result/shop-stg" + installation: "@installation/shop-stg" + type: "docker" + phpVersion: "8.3" + __calls: + - addDomain: ["@domain/stg.shop.example.com"] + site/shop-prod: + detectionResult: "@detection_result/shop-prod" + installation: "@installation/shop-prod" + type: "docker" + phpVersion: "8.3" + __calls: + - addDomain: ["@domain/shop.example.com"] + - addDomain: ["@domain/www.shop.example.com"] + site/intranet: + detectionResult: "@detection_result/intranet" + installation: "@installation/intranet" + type: "docker" + phpVersion: "8.3" + __calls: + - addDomain: ["@domain/intranet.example.com"] + site/release: + detectionResult: "@detection_result/release" + installation: "@installation/release" + type: "docker" + phpVersion: "8.3" + __calls: + - addDomain: ["@domain/release.example.com"] + site/clean: + detectionResult: "@detection_result/clean" + installation: "@installation/clean" + type: "docker" + phpVersion: "8.4" + __calls: + - addDomain: ["@domain/clean.example.com"] diff --git a/tests/DataFixtures/AdvisoryFixturesTest.php b/tests/DataFixtures/AdvisoryFixturesTest.php new file mode 100644 index 00000000..6b772582 --- /dev/null +++ b/tests/DataFixtures/AdvisoryFixturesTest.php @@ -0,0 +1,137 @@ +entityManager = self::getContainer()->get(EntityManagerInterface::class); + // Read what was stored, not the objects the loader left in memory, + // whose inverse collections are not filled in. + $this->entityManager->clear(); + } + + public function testARepoIsCheckedOutOnSeveralInstallationsWithOverlappingAdvisories(): void + { + $repos = array_filter( + $this->entityManager->getRepository(GitRepo::class)->findAll(), + fn (GitRepo $repo): bool => count($this->installationsOf($repo)) > 1, + ); + $this->assertNotEmpty($repos); + + $installations = $this->installationsOf(reset($repos)); + $tags = array_unique(array_map(static fn (Installation $installation): string => (string) $installation->getGitTag()?->getTag(), $installations)); + $this->assertGreaterThan(1, count($tags), 'Checkouts at different versions'); + + [$first, $second] = array_map($this->advisoryIdsOf(...), $installations); + $this->assertNotEmpty(array_intersect($first, $second), 'Advisories shared by the checkouts'); + $this->assertNotEmpty(array_diff($first, $second) ?: array_diff($second, $first), 'Advisories only one checkout has'); + } + + public function testARepoHasNoAdvisories(): void + { + $this->assertNotEmpty(array_filter( + $this->entityManager->getRepository(GitRepo::class)->findAll(), + fn (GitRepo $repo): bool => [] === array_merge(...array_map($this->advisoryIdsOf(...), $this->installationsOf($repo))), + )); + } + + public function testAnInstallationWithoutRepoHasAdvisories(): void + { + $this->assertNotEmpty(array_filter( + $this->entityManager->getRepository(Installation::class)->findBy(['gitTag' => null]), + fn (Installation $installation): bool => [] !== $this->advisoryIdsOf($installation), + )); + } + + public function testAdvisoriesCoverComposerAndDrupalSourcesWithAndWithoutCve(): void + { + $advisories = $this->entityManager->getRepository(Advisory::class)->findAll(); + + $prefixes = array_unique(array_map(static fn (Advisory $advisory): string => explode('-', $advisory->getAdvisoryId())[0].'-'.explode('-', $advisory->getAdvisoryId())[1], $advisories)); + $this->assertContains('SA-CORE', $prefixes); + $this->assertContains('SA-CONTRIB', $prefixes); + $this->assertNotEmpty(array_filter($advisories, static fn (Advisory $advisory): bool => str_starts_with($advisory->getAdvisoryId(), 'PKSA-'))); + + $this->assertNotEmpty(array_filter($advisories, static fn (Advisory $advisory): bool => null === $advisory->getCve()), 'An advisory without a CVE'); + + $cves = array_filter(array_map(static fn (Advisory $advisory): ?string => $advisory->getCve(), $advisories)); + $this->assertNotSame(array_unique($cves), $cves, 'Two advisories sharing one CVE'); + } + + public function testADrupalModuleReachesAnAdvisoryThroughItsComposerPackage(): void + { + $this->assertNotEmpty(array_filter( + $this->entityManager->getRepository(ModuleVersion::class)->findAll(), + static fn (ModuleVersion $moduleVersion): bool => ($moduleVersion->getComposerPackageVersion()?->getAdvisoryCount() ?? 0) > 0, + )); + } + + /** + * Fixtures are committed and loaded on every dev setup, so they must never + * carry production data: example domains and an example organization only. + */ + public function testFixturesUseExampleDataOnly(): void + { + foreach ($this->entityManager->getRepository(Domain::class)->findAll() as $domain) { + $this->assertMatchesRegularExpression('/(^|\.)example\.com$/', $domain->getAddress()); + } + foreach ($this->entityManager->getRepository(GitRepo::class)->findAll() as $repo) { + $this->assertSame('example-org', $repo->getOrganization()); + } + foreach ($this->entityManager->getRepository(Advisory::class)->findAll() as $advisory) { + $this->assertMatchesRegularExpression('/^(CVE-2099-\d+)?$/', (string) $advisory->getCve(), 'Made-up CVE ids only'); + } + } + + /** + * @return list + */ + private function installationsOf(GitRepo $repo): array + { + $installations = []; + foreach ($repo->getGitTags() as $gitTag) { + array_push($installations, ...$gitTag->getInstallations()); + } + + return $installations; + } + + /** + * @return list + */ + private function advisoryIdsOf(Installation $installation): array + { + $ids = []; + foreach ($installation->getPackageVersions() as $packageVersion) { + foreach ($packageVersion->getAdvisories() as $advisory) { + $ids[] = $advisory->getAdvisoryId(); + } + } + + return $ids; + } +} From d20e097a609a11283a8ba6f2255b112e8a776e90 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 21 Oct 2026 14:41:41 +0200 Subject: [PATCH 15/35] docs: add changelog entry for #168 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c5d1e45..a6aeb855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- [#168](https://github.com/itk-dev/devops_itksites/pull/168) + Add fixtures for advisories, git repos and Drupal modules - [#167](https://github.com/itk-dev/devops_itksites/pull/167) Let the latest dir result decide which sites exist, so replays no longer revive removed sites or delete live ones, and purge detection results not From 94c698f2787af4046e8bc3783486390b67c90acc Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 21 Oct 2026 15:21:45 +0200 Subject: [PATCH 16/35] test: rewrite the fixtures to cover all content One file per area: servers, sites with the detection results behind them, git repos, packages and advisories, Docker images, projects with code owners and security contracts, OIDC and service certificates. Every entity, detection type, server type and site type is represented, with made-up values shaped like production. FixturesTest pins the scenarios, that replaying the detection results rebuilds the same installations and sites, that each server's dir result lists all its installations, and that only example data is used. Tests tied to the old fixtures now use the shop sites, the never-harvested gpu-01 server or counts relative to the fixtures. --- fixtures/advisory.yaml | 382 ----------- fixtures/docker.yaml | 43 ++ fixtures/git.yaml | 46 ++ fixtures/oidc.yaml | 14 +- fixtures/package.yaml | 187 ++++++ fixtures/project.yaml | 73 +++ fixtures/server.yaml | 103 ++- fixtures/service_certificates.yaml | 21 +- fixtures/site.yaml | 664 ++++++++++++++++++-- tests/Api/PaginationTest.php | 4 +- tests/Api/SiteFilterTest.php | 28 +- tests/Controller/Admin/AdminSmokeTest.php | 7 +- tests/DataFixtures/AdvisoryFixturesTest.php | 137 ---- tests/DataFixtures/FixturesTest.php | 289 +++++++++ tests/Handler/DirectoryHandlerTest.php | 3 +- 15 files changed, 1370 insertions(+), 631 deletions(-) delete mode 100644 fixtures/advisory.yaml create mode 100644 fixtures/docker.yaml create mode 100644 fixtures/git.yaml create mode 100644 fixtures/package.yaml create mode 100644 fixtures/project.yaml delete mode 100644 tests/DataFixtures/AdvisoryFixturesTest.php create mode 100644 tests/DataFixtures/FixturesTest.php diff --git a/fixtures/advisory.yaml b/fixtures/advisory.yaml deleted file mode 100644 index a31ab3e5..00000000 --- a/fixtures/advisory.yaml +++ /dev/null @@ -1,382 +0,0 @@ -# Installations with packages, advisories, git repos and Drupal modules, -# shaped like production data. All names, versions and ids are made up: CVE ids -# use the year 2099 and git repos the example-org organization. -# -# - example-org/shop is checked out twice, stg on a branch and prod on a tag. -# Both have the twig advisory, only prod has the http-foundation one. -# - example-org/intranet is a Drupal site whose modules reach advisories through -# their Composer packages. drupal/core has a drupal.org and a Packagist -# advisory for the same CVE. -# - release.example.com is deployed from a release download, so it has no git -# repo, but it has advisories. -# - example-org/clean has no advisories. - -App\Entity\GitRepo: - git_repo/shop: - provider: "github.com" - organization: "example-org" - repo: "shop" - git_repo/intranet: - provider: "github.com" - organization: "example-org" - repo: "intranet" - git_repo/clean: - provider: "github.com" - organization: "example-org" - repo: "clean" - -App\Entity\GitTag: - # A branch checkout has no tag. - git_tag/shop-unknown: - tag: "unknown" - repo: "@git_repo/shop" - git_tag/shop-2.4.1: - tag: "2.4.1" - repo: "@git_repo/shop" - git_tag/intranet-1.8.0: - tag: "1.8.0" - repo: "@git_repo/intranet" - git_tag/clean-1.0.0: - tag: "1.0.0" - repo: "@git_repo/clean" - -App\Entity\Package: - package/symfony-http-foundation: - vendor: "symfony" - name: "http-foundation" - description: "Defines an object-oriented layer for the HTTP specification" - license: "MIT" - package/twig: - vendor: "twig" - name: "twig" - description: "Twig, the flexible, fast, and secure template language for PHP" - license: "BSD-3-Clause" - package/league-commonmark: - vendor: "league" - name: "commonmark" - description: "Highly-extensible PHP Markdown parser" - license: "BSD-3-Clause" - package/guzzle: - vendor: "guzzlehttp" - name: "guzzle" - description: "Guzzle is a PHP HTTP client library" - license: "MIT" - package/drupal-core: - vendor: "drupal" - name: "core" - description: "Drupal is an open source content management platform" - license: "GPL-2.0-or-later" - package/drupal-webform: - vendor: "drupal" - name: "webform" - description: "Enables the creation of webforms and questionnaires" - license: "GPL-2.0-or-later" - -App\Entity\Advisory: - advisory/symfony-http-foundation: - advisoryId: "PKSA-x9q2-7fhd-3k1m" - package: "@package/symfony-http-foundation" - affectedVersions: ">=6.0.0,<6.4.18|>=7.0.0,<7.2.4" - title: "CVE-2099-10001: Example open redirect in Request::getUri()" - cve: "CVE-2099-10001" - link: "https://example.com/advisories/cve-2099-10001" - reportedAt: '<(new DateTimeImmutable("2026-02-11 10:00:00"))>' - sources: - - name: "FriendsOfPHP/security-advisories" - remoteId: "symfony/http-foundation/CVE-2099-10001.yaml" - advisory/twig: - advisoryId: "PKSA-b4n7-2wqz-8c5r" - package: "@package/twig" - affectedVersions: ">=3.0.0,<3.11.2" - title: "CVE-2099-10002: Example sandbox bypass" - cve: "CVE-2099-10002" - link: "https://example.com/advisories/cve-2099-10002" - reportedAt: '<(new DateTimeImmutable("2026-03-04 09:30:00"))>' - sources: - - name: "FriendsOfPHP/security-advisories" - remoteId: "twig/twig/CVE-2099-10002.yaml" - advisory/league-commonmark: - advisoryId: "PKSA-m3r8-5tyv-1h6p" - package: "@package/league-commonmark" - affectedVersions: ">=2.0.0,<2.6.0" - title: "Example denial of service through nested lists" - link: "https://example.com/advisories/ghsa-7f3m-q2kx-9w4c" - reportedAt: '<(new DateTimeImmutable("2026-04-20 14:15:00"))>' - sources: - - name: "GitHub" - remoteId: "GHSA-7f3m-q2kx-9w4c" - advisory/drupal-core-sa: - advisoryId: "SA-CORE-2099-001" - package: "@package/drupal-core" - affectedVersions: ">= 8.0.0 < 10.3.9 || >= 10.4.0 < 10.4.2" - title: "Drupal core - Moderately critical - Example access bypass" - cve: "CVE-2099-10003" - link: "https://example.com/advisories/sa-core-2099-001" - reportedAt: '<(new DateTimeImmutable("2026-05-13 17:00:00"))>' - sources: - - name: "Drupal" - remoteId: "SA-CORE-2099-001" - # The same CVE as SA-CORE-2099-001, reported to Packagist. - advisory/drupal-core-pksa: - advisoryId: "PKSA-d8k2-6vnp-4j9t" - package: "@package/drupal-core" - affectedVersions: ">=8.0.0,<10.3.9|>=10.4.0,<10.4.2" - title: "Drupal core - Moderately critical - Example access bypass" - cve: "CVE-2099-10003" - link: "https://example.com/advisories/sa-core-2099-001" - reportedAt: '<(new DateTimeImmutable("2026-05-13 17:00:00"))>' - sources: - - name: "FriendsOfPHP/security-advisories" - remoteId: "drupal/core/CVE-2099-10003.yaml" - advisory/drupal-webform: - advisoryId: "SA-CONTRIB-2099-001" - package: "@package/drupal-webform" - affectedVersions: "<6.2.10" - title: "Webform - Critical - Example cross site scripting" - link: "https://example.com/advisories/sa-contrib-2099-001" - reportedAt: '<(new DateTimeImmutable("2026-06-03 16:45:00"))>' - sources: - - name: "Drupal" - remoteId: "SA-CONTRIB-2099-001" - -App\Entity\PackageVersion: - package_version/symfony-http-foundation-6.4.10: - package: "@package/symfony-http-foundation" - version: "6.4.10" - latest: "6.4.20" - latestStatus: "semver-safe-update" - __calls: - - addAdvisory: ["@advisory/symfony-http-foundation"] - package_version/symfony-http-foundation-6.4.20: - package: "@package/symfony-http-foundation" - version: "6.4.20" - latest: "7.2.4" - latestStatus: "update-possible" - package_version/twig-3.8.0: - package: "@package/twig" - version: "3.8.0" - latest: "3.11.2" - latestStatus: "semver-safe-update" - __calls: - - addAdvisory: ["@advisory/twig"] - package_version/league-commonmark-2.4.0: - package: "@package/league-commonmark" - version: "2.4.0" - latest: "2.6.0" - latestStatus: "semver-safe-update" - __calls: - - addAdvisory: ["@advisory/league-commonmark"] - package_version/guzzle-7.9.2: - package: "@package/guzzle" - version: "7.9.2" - latest: "7.9.2" - latestStatus: "up-to-date" - package_version/drupal-core-10.3.5: - package: "@package/drupal-core" - version: "10.3.5" - latest: "10.4.2" - latestStatus: "semver-safe-update" - __calls: - - addAdvisory: ["@advisory/drupal-core-sa"] - - addAdvisory: ["@advisory/drupal-core-pksa"] - package_version/drupal-webform-6.2.9: - package: "@package/drupal-webform" - version: "6.2.9" - latest: "6.2.10" - latestStatus: "semver-safe-update" - __calls: - - addAdvisory: ["@advisory/drupal-webform"] - -App\Entity\Module: - module/node: - package: "Core" - name: "node" - displayName: "Node" - project: "drupal" - enabled: true - composerPackage: "@package/drupal-core" - module/webform: - package: "Webform" - name: "webform" - displayName: "Webform" - project: "webform" - enabled: true - composerPackage: "@package/drupal-webform" - -App\Entity\ModuleVersion: - module_version/node-10.3.5: - module: "@module/node" - version: "10.3.5" - composerPackageVersion: "@package_version/drupal-core-10.3.5" - module_version/webform-6.2.9: - module: "@module/webform" - version: "6.2.9" - composerPackageVersion: "@package_version/drupal-webform-6.2.9" - -App\Entity\DetectionResult: - detection_result/shop-stg: - server: "@server_2" - type: "docker" - rootDir: "/data/www/stg_shop_example_com/htdocs" - data: '{"domain": "stg.shop.example.com"}' - __calls: - - generateHash: [] - - setLastContact: [] - detection_result/shop-prod: - server: "@server_3" - type: "docker" - rootDir: "/data/www/shop_example_com/htdocs" - data: '{"domain": "shop.example.com www.shop.example.com"}' - __calls: - - generateHash: [] - - setLastContact: [] - detection_result/intranet: - server: "@server_2" - type: "docker" - rootDir: "/data/www/intranet_example_com/htdocs" - data: '{"domain": "intranet.example.com"}' - __calls: - - generateHash: [] - - setLastContact: [] - detection_result/release: - server: "@server_4" - type: "docker" - rootDir: "/data/www/release_example_com/htdocs" - data: '{"domain": "release.example.com"}' - __calls: - - generateHash: [] - - setLastContact: [] - detection_result/clean: - server: "@server_3" - type: "docker" - rootDir: "/data/www/clean_example_com/htdocs" - data: '{"domain": "clean.example.com"}' - __calls: - - generateHash: [] - - setLastContact: [] - -App\Entity\Installation: - installation/shop-stg: - detectionResult: "@detection_result/shop-stg" - type: "symfony" - frameworkVersion: "6.4.20" - phpVersion: "8.3.30" - composerVersion: "2.8.5" - lts: true - eol: "11/2027" - gitTag: "@git_tag/shop-unknown" - gitClonedScheme: "ssh" - __calls: - - addPackageVersion: - ["@package_version/symfony-http-foundation-6.4.20"] - - addPackageVersion: ["@package_version/twig-3.8.0"] - - addPackageVersion: ["@package_version/guzzle-7.9.2"] - installation/shop-prod: - detectionResult: "@detection_result/shop-prod" - type: "symfony" - frameworkVersion: "6.4.10" - phpVersion: "8.3.30" - composerVersion: "2.8.5" - lts: true - eol: "11/2027" - gitTag: "@git_tag/shop-2.4.1" - gitClonedScheme: "https" - __calls: - - addPackageVersion: - ["@package_version/symfony-http-foundation-6.4.10"] - - addPackageVersion: ["@package_version/twig-3.8.0"] - - addPackageVersion: ["@package_version/guzzle-7.9.2"] - installation/intranet: - detectionResult: "@detection_result/intranet" - type: "drupal" - frameworkVersion: "10.3.5" - phpVersion: "8.3.30" - composerVersion: "2.8.5" - gitTag: "@git_tag/intranet-1.8.0" - gitClonedScheme: "https" - __calls: - - addPackageVersion: ["@package_version/drupal-core-10.3.5"] - - addPackageVersion: ["@package_version/drupal-webform-6.2.9"] - - addPackageVersion: ["@package_version/league-commonmark-2.4.0"] - - addModuleVersion: ["@module_version/node-10.3.5"] - - addModuleVersion: ["@module_version/webform-6.2.9"] - installation/release: - detectionResult: "@detection_result/release" - type: "drupal" - frameworkVersion: "10.3.5" - phpVersion: "8.3.30" - composerVersion: "2.8.5" - __calls: - - addPackageVersion: ["@package_version/drupal-core-10.3.5"] - - addPackageVersion: ["@package_version/drupal-webform-6.2.9"] - - addModuleVersion: ["@module_version/node-10.3.5"] - - addModuleVersion: ["@module_version/webform-6.2.9"] - installation/clean: - detectionResult: "@detection_result/clean" - type: "symfony" - frameworkVersion: "7.2.4" - phpVersion: "8.4.5" - composerVersion: "2.8.5" - eol: "07/2025 Expired" - gitTag: "@git_tag/clean-1.0.0" - gitClonedScheme: "ssh" - __calls: - - addPackageVersion: ["@package_version/guzzle-7.9.2"] - -App\Entity\Domain: - domain/stg.shop.example.com: - address: "stg.shop.example.com" - detectionResult: "@detection_result/shop-stg" - domain/shop.example.com: - address: "shop.example.com" - detectionResult: "@detection_result/shop-prod" - domain/www.shop.example.com: - address: "www.shop.example.com" - detectionResult: "@detection_result/shop-prod" - domain/intranet.example.com: - address: "intranet.example.com" - detectionResult: "@detection_result/intranet" - domain/release.example.com: - address: "release.example.com" - detectionResult: "@detection_result/release" - domain/clean.example.com: - address: "clean.example.com" - detectionResult: "@detection_result/clean" - -App\Entity\Site: - site/shop-stg: - detectionResult: "@detection_result/shop-stg" - installation: "@installation/shop-stg" - type: "docker" - phpVersion: "8.3" - __calls: - - addDomain: ["@domain/stg.shop.example.com"] - site/shop-prod: - detectionResult: "@detection_result/shop-prod" - installation: "@installation/shop-prod" - type: "docker" - phpVersion: "8.3" - __calls: - - addDomain: ["@domain/shop.example.com"] - - addDomain: ["@domain/www.shop.example.com"] - site/intranet: - detectionResult: "@detection_result/intranet" - installation: "@installation/intranet" - type: "docker" - phpVersion: "8.3" - __calls: - - addDomain: ["@domain/intranet.example.com"] - site/release: - detectionResult: "@detection_result/release" - installation: "@installation/release" - type: "docker" - phpVersion: "8.3" - __calls: - - addDomain: ["@domain/release.example.com"] - site/clean: - detectionResult: "@detection_result/clean" - installation: "@installation/clean" - type: "docker" - phpVersion: "8.4" - __calls: - - addDomain: ["@domain/clean.example.com"] diff --git a/fixtures/docker.yaml b/fixtures/docker.yaml new file mode 100644 index 00000000..e225cd5f --- /dev/null +++ b/fixtures/docker.yaml @@ -0,0 +1,43 @@ +# Docker images the docker sites run, and their tags. The official nginx image +# is public; the rest belong to the made-up example-org organization. + +App\Entity\DockerImage: + docker_image/php8.3-fpm: + registry: "docker.io" + organization: "example-org" + repository: "php8.3-fpm" + description: "PHP 8.3 FPM base image" + docker_image/php8.4-fpm: + registry: "docker.io" + organization: "example-org" + repository: "php8.4-fpm" + description: "PHP 8.4 FPM base image" + # An official image is stored with its name as the organization. + docker_image/nginx: + registry: "docker.io" + organization: "nginx" + repository: "" + description: "Official build of Nginx" + # Images outside Docker Hub carry their registry. + docker_image/shop-worker: + registry: "ghcr.io" + organization: "example-org" + repository: "shop-worker" + description: "Queue worker for the shop" + +App\Entity\DockerImageTag: + docker_image_tag/php8.3-fpm-latest: + dockerImage: "@docker_image/php8.3-fpm" + tag: "latest" + docker_image_tag/php8.4-fpm-latest: + dockerImage: "@docker_image/php8.4-fpm" + tag: "latest" + docker_image_tag/nginx-1.27-alpine: + dockerImage: "@docker_image/nginx" + tag: "1.27-alpine" + docker_image_tag/shop-worker-2.4.1: + dockerImage: "@docker_image/shop-worker" + tag: "2.4.1" + docker_image_tag/shop-worker-develop: + dockerImage: "@docker_image/shop-worker" + tag: "develop" diff --git a/fixtures/git.yaml b/fixtures/git.yaml new file mode 100644 index 00000000..583f1027 --- /dev/null +++ b/fixtures/git.yaml @@ -0,0 +1,46 @@ +# Git repos and the tags installations are checked out at. All repos belong to +# the made-up example-org organization. +# +# - example-org/shop is checked out twice, stg on a branch and prod on a tag. +# - example-org/clean has no advisories. +# - example-org/docs belongs to a project but is not deployed anywhere. + +App\Entity\GitRepo: + git_repo/shop: + provider: "github.com" + organization: "example-org" + repo: "shop" + git_repo/intranet: + provider: "github.com" + organization: "example-org" + repo: "intranet" + git_repo/clean: + provider: "github.com" + organization: "example-org" + repo: "clean" + git_repo/legacy: + provider: "github.com" + organization: "example-org" + repo: "legacy" + git_repo/docs: + provider: "github.com" + organization: "example-org" + repo: "docs" + +App\Entity\GitTag: + # A branch checkout has no tag. + git_tag/shop-unknown: + tag: "unknown" + repo: "@git_repo/shop" + git_tag/shop-2.4.1: + tag: "2.4.1" + repo: "@git_repo/shop" + git_tag/intranet-1.8.0: + tag: "1.8.0" + repo: "@git_repo/intranet" + git_tag/clean-1.0.0: + tag: "1.0.0" + repo: "@git_repo/clean" + git_tag/legacy-0.9.3: + tag: "0.9.3" + repo: "@git_repo/legacy" diff --git a/fixtures/oidc.yaml b/fixtures/oidc.yaml index 26fcc887..048a86dc 100644 --- a/fixtures/oidc.yaml +++ b/fixtures/oidc.yaml @@ -1,8 +1,14 @@ -# @see https://fakerphp.github.io/formatters/ App\Entity\OIDC: - oidcservice_certificate/dev.example.com: - domain: "dev.example.com" + oidc/intranet.example.com: + domain: "intranet.example.com" expirationTime: <(new DateTime('+2 years'))> onePasswordUrl: "https://example.com/1password" - usageDocumentationUrl: https://example.com/example.com/README.md#oidc + usageDocumentationUrl: https://example.com/intranet/README.md#oidc + type: "prod" + oidc/stg.shop.example.com: + domain: "stg.shop.example.com" + expirationTime: <(new DateTime('+20 days'))> + onePasswordUrl: "https://example.com/1password" + usageDocumentationUrl: https://example.com/shop/README.md#oidc type: "stg" + notes: "Renew before the next release" diff --git a/fixtures/package.yaml b/fixtures/package.yaml new file mode 100644 index 00000000..0adca1db --- /dev/null +++ b/fixtures/package.yaml @@ -0,0 +1,187 @@ +# Composer packages, their versions and advisories, and Drupal modules linked +# to them. All ids are made up: CVE ids use the year 2099. +# +# - http-foundation 6.4.10 is affected, 6.4.20 is not, so two checkouts of the +# same repo can differ. +# - drupal/core has a drupal.org and a Packagist advisory for the same CVE. +# - league/commonmark and drupal/webform have advisories without a CVE. +# - guzzle has no advisories. + +App\Entity\Package: + package/symfony-http-foundation: + vendor: "symfony" + name: "http-foundation" + description: "Defines an object-oriented layer for the HTTP specification" + license: "MIT" + package/twig: + vendor: "twig" + name: "twig" + description: "Twig, the flexible, fast, and secure template language for PHP" + license: "BSD-3-Clause" + package/league-commonmark: + vendor: "league" + name: "commonmark" + description: "Highly-extensible PHP Markdown parser" + license: "BSD-3-Clause" + package/guzzle: + vendor: "guzzlehttp" + name: "guzzle" + description: "Guzzle is a PHP HTTP client library" + license: "MIT" + package/drupal-core: + vendor: "drupal" + name: "core" + description: "Drupal is an open source content management platform" + license: "GPL-2.0-or-later" + package/drupal-webform: + vendor: "drupal" + name: "webform" + description: "Enables the creation of webforms and questionnaires" + license: "GPL-2.0-or-later" + +App\Entity\Advisory: + advisory/symfony-http-foundation: + advisoryId: "PKSA-x9q2-7fhd-3k1m" + package: "@package/symfony-http-foundation" + affectedVersions: ">=6.0.0,<6.4.18|>=7.0.0,<7.2.4" + title: "CVE-2099-10001: Example open redirect in Request::getUri()" + cve: "CVE-2099-10001" + link: "https://example.com/advisories/cve-2099-10001" + reportedAt: '<(new DateTimeImmutable("2026-02-11 10:00:00"))>' + sources: + - name: "FriendsOfPHP/security-advisories" + remoteId: "symfony/http-foundation/CVE-2099-10001.yaml" + advisory/twig: + advisoryId: "PKSA-b4n7-2wqz-8c5r" + package: "@package/twig" + affectedVersions: ">=3.0.0,<3.11.2" + title: "CVE-2099-10002: Example sandbox bypass" + cve: "CVE-2099-10002" + link: "https://example.com/advisories/cve-2099-10002" + reportedAt: '<(new DateTimeImmutable("2026-03-04 09:30:00"))>' + sources: + - name: "FriendsOfPHP/security-advisories" + remoteId: "twig/twig/CVE-2099-10002.yaml" + advisory/league-commonmark: + advisoryId: "PKSA-m3r8-5tyv-1h6p" + package: "@package/league-commonmark" + affectedVersions: ">=2.0.0,<2.6.0" + title: "Example denial of service through nested lists" + link: "https://example.com/advisories/ghsa-7f3m-q2kx-9w4c" + reportedAt: '<(new DateTimeImmutable("2026-04-20 14:15:00"))>' + sources: + - name: "GitHub" + remoteId: "GHSA-7f3m-q2kx-9w4c" + advisory/drupal-core-sa: + advisoryId: "SA-CORE-2099-001" + package: "@package/drupal-core" + affectedVersions: ">= 8.0.0 < 10.3.9 || >= 10.4.0 < 10.4.2" + title: "Drupal core - Moderately critical - Example access bypass" + cve: "CVE-2099-10003" + link: "https://example.com/advisories/sa-core-2099-001" + reportedAt: '<(new DateTimeImmutable("2026-05-13 17:00:00"))>' + sources: + - name: "Drupal" + remoteId: "SA-CORE-2099-001" + # The same CVE as SA-CORE-2099-001, reported to Packagist. + advisory/drupal-core-pksa: + advisoryId: "PKSA-d8k2-6vnp-4j9t" + package: "@package/drupal-core" + affectedVersions: ">=8.0.0,<10.3.9|>=10.4.0,<10.4.2" + title: "Drupal core - Moderately critical - Example access bypass" + cve: "CVE-2099-10003" + link: "https://example.com/advisories/sa-core-2099-001" + reportedAt: '<(new DateTimeImmutable("2026-05-13 17:00:00"))>' + sources: + - name: "FriendsOfPHP/security-advisories" + remoteId: "drupal/core/CVE-2099-10003.yaml" + advisory/drupal-webform: + advisoryId: "SA-CONTRIB-2099-001" + package: "@package/drupal-webform" + affectedVersions: "<6.2.10" + title: "Webform - Critical - Example cross site scripting" + link: "https://example.com/advisories/sa-contrib-2099-001" + reportedAt: '<(new DateTimeImmutable("2026-06-03 16:45:00"))>' + sources: + - name: "Drupal" + remoteId: "SA-CONTRIB-2099-001" + +App\Entity\PackageVersion: + package_version/symfony-http-foundation-6.4.10: + package: "@package/symfony-http-foundation" + version: "6.4.10" + latest: "6.4.20" + latestStatus: "semver-safe-update" + __calls: + - addAdvisory: ["@advisory/symfony-http-foundation"] + package_version/symfony-http-foundation-6.4.20: + package: "@package/symfony-http-foundation" + version: "6.4.20" + latest: "7.2.4" + latestStatus: "update-possible" + package_version/twig-3.8.0: + package: "@package/twig" + version: "3.8.0" + latest: "3.11.2" + latestStatus: "semver-safe-update" + __calls: + - addAdvisory: ["@advisory/twig"] + package_version/league-commonmark-2.4.0: + package: "@package/league-commonmark" + version: "2.4.0" + latest: "2.6.0" + latestStatus: "semver-safe-update" + __calls: + - addAdvisory: ["@advisory/league-commonmark"] + package_version/guzzle-7.9.2: + package: "@package/guzzle" + version: "7.9.2" + latest: "7.9.2" + latestStatus: "up-to-date" + package_version/drupal-core-10.3.5: + package: "@package/drupal-core" + version: "10.3.5" + latest: "10.4.2" + latestStatus: "semver-safe-update" + drupalReleaseTerms: ["Bug fixes", "Security update"] + drupalInsecure: true + drupalReleaseCheckedAt: '<(new DateTimeImmutable("2026-10-20 06:00:00"))>' + __calls: + - addAdvisory: ["@advisory/drupal-core-sa"] + - addAdvisory: ["@advisory/drupal-core-pksa"] + package_version/drupal-webform-6.2.9: + package: "@package/drupal-webform" + version: "6.2.9" + latest: "6.2.10" + latestStatus: "semver-safe-update" + drupalReleaseTerms: ["Security update"] + drupalInsecure: true + drupalReleaseCheckedAt: '<(new DateTimeImmutable("2026-10-20 06:00:00"))>' + __calls: + - addAdvisory: ["@advisory/drupal-webform"] + +App\Entity\Module: + module/node: + package: "Core" + name: "node" + displayName: "Node" + project: "drupal" + enabled: true + composerPackage: "@package/drupal-core" + module/webform: + package: "Webform" + name: "webform" + displayName: "Webform" + project: "webform" + enabled: true + composerPackage: "@package/drupal-webform" + +App\Entity\ModuleVersion: + module_version/node-10.3.5: + module: "@module/node" + version: "10.3.5" + composerPackageVersion: "@package_version/drupal-core-10.3.5" + module_version/webform-6.2.9: + module: "@module/webform" + version: "6.2.9" + composerPackageVersion: "@package_version/drupal-webform-6.2.9" diff --git a/fixtures/project.yaml b/fixtures/project.yaml new file mode 100644 index 00000000..f76828b8 --- /dev/null +++ b/fixtures/project.yaml @@ -0,0 +1,73 @@ +# Projects, code owners and security contracts, as synced from Economics. +# All names, ids and contacts are made up. +# +# - The shop project has two repos, two code owners and an active contract +# on a dedicated server. +# - The intranet contract has ended. +# - One contract has no project. + +App\Entity\CodeOwner: + code_owner/alice: + economicsId: 9001 + name: "Alice Example" + email: "alice@example.com" + code_owner/bob: + economicsId: 9002 + name: "Bob Example" + email: "bob@example.com" + +App\Entity\Project: + project/shop: + economicsId: 8001 + name: "Example shop" + leantimeId: "101" + leantimeUrl: "https://leantime.example.com/projects/showProject/101" + __calls: + - addCodeOwner: ["@code_owner/alice"] + - addCodeOwner: ["@code_owner/bob"] + - addGitRepo: ["@git_repo/shop"] + - addGitRepo: ["@git_repo/docs"] + project/intranet: + economicsId: 8002 + name: "Example intranet" + leantimeId: "102" + leantimeUrl: "https://leantime.example.com/projects/showProject/102" + __calls: + - addCodeOwner: ["@code_owner/bob"] + - addGitRepo: ["@git_repo/intranet"] + project/legacy: + economicsId: 8003 + name: "Example legacy app" + +App\Entity\SecurityContract: + security_contract/shop: + economicsId: 7001 + project: "@project/shop" + hostingProvider: "HETZNER" + documentUrl: "https://example.com/contracts/7001.pdf" + monthlyPrice: 4500 + validFrom: '<(new DateTimeImmutable("2026-01-01"))>' + validTo: '<(new DateTimeImmutable("2027-12-31"))>' + active: true + clientContactName: "Carol Example" + clientContactEmail: "carol@example.com" + dedicatedServer: true + serverSize: "custom" + systemOwnerNotices: ["Patch window is Tuesday evenings"] + security_contract/intranet: + economicsId: 7002 + project: "@project/intranet" + hostingProvider: "DMZ" + monthlyPrice: 1100 + validFrom: '<(new DateTimeImmutable("2024-01-01"))>' + validTo: '<(new DateTimeImmutable("2025-12-31"))>' + eol: true + clientContactName: "Dave Example" + clientContactEmail: "dave@example.com" + systemOwnerNotices: [] + security_contract/unassigned: + economicsId: 7003 + hostingProvider: "HETZNER" + monthlyPrice: 1100 + validFrom: '<(new DateTimeImmutable("2026-06-01"))>' + active: true diff --git a/fixtures/server.yaml b/fixtures/server.yaml index c23095ab..1b054bfa 100644 --- a/fixtures/server.yaml +++ b/fixtures/server.yaml @@ -1,17 +1,94 @@ +# Servers, one or more of each type. Names are example.com hosts, internal IPs +# are private and external IPs are from the documentation ranges (RFC 5737). +# +# - web-prod-02 is hosted in the municipality's DMZ, the rest at Hetzner. +# - devops-01 is harvested but runs no sites. +# - gpu-01 has never been harvested. + App\Entity\Server: - server_{1..10}: + server/web-stg-01: # A server id is needed by App\Entity\DetectionResult::generateHash(). id: '<(new Symfony\Component\Uid\Ulid())>' apiKey: - name: - hostingProvider: - internalIp: - externalIp: - aarhusSsl: - letsEncryptSsl: - veeam: - azureBackup: - monitoring: - databaseVersion: - system: - type: + name: "web-stg-01.example.com" + hostingProvider: "Hetzner" + internalIp: "10.0.1.11" + externalIp: "192.0.2.11" + aarhusSsl: false + letsEncryptSsl: true + veeam: false + azureBackup: false + monitoring: true + databaseVersion: "10.11" + system: "ubuntu2404" + type: "stg" + usedFor: "Staging of example-org sites" + __calls: + - updateLastContactAt: ['<(new DateTimeImmutable("-5 minutes"))>'] + server/web-prod-01: + id: '<(new Symfony\Component\Uid\Ulid())>' + apiKey: + name: "web-prod-01.example.com" + hostingProvider: "Hetzner" + internalIp: "10.0.1.21" + externalIp: "192.0.2.21" + aarhusSsl: false + letsEncryptSsl: true + veeam: false + azureBackup: false + monitoring: true + databaseVersion: "10.11" + system: "ubuntu2404" + type: "prod" + __calls: + - updateLastContactAt: ['<(new DateTimeImmutable("-5 minutes"))>'] + server/web-prod-02: + id: '<(new Symfony\Component\Uid\Ulid())>' + apiKey: + name: "web-prod-02.example.com" + hostingProvider: "It Relation (DMZ)" + hostingProviderName: "Example DMZ hosting" + internalIp: "10.0.2.22" + externalIp: "198.51.100.22" + aarhusSsl: true + letsEncryptSsl: false + veeam: true + azureBackup: false + monitoring: true + databaseVersion: "10.11" + system: "ubuntu2204" + type: "prod" + note: "Firewall changes go through the DMZ change process." + __calls: + - updateLastContactAt: ['<(new DateTimeImmutable("-15 minutes"))>'] + server/devops-01: + id: '<(new Symfony\Component\Uid\Ulid())>' + apiKey: + name: "devops-01.example.com" + hostingProvider: "Hetzner" + internalIp: "10.0.1.31" + externalIp: "192.0.2.31" + aarhusSsl: false + letsEncryptSsl: true + veeam: false + azureBackup: false + monitoring: true + system: "ubuntu2604" + type: "devops" + usedFor: "CI runners" + __calls: + - updateLastContactAt: ['<(new DateTimeImmutable("-5 minutes"))>'] + server/gpu-01: + id: '<(new Symfony\Component\Uid\Ulid())>' + apiKey: + name: "gpu-01.example.com" + hostingProvider: "Hetzner" + internalIp: "10.0.1.41" + externalIp: "203.0.113.41" + aarhusSsl: false + letsEncryptSsl: true + veeam: false + azureBackup: false + monitoring: false + system: "ubuntu2404" + type: "gpu" diff --git a/fixtures/service_certificates.yaml b/fixtures/service_certificates.yaml index 66c8cd6b..68e209a2 100644 --- a/fixtures/service_certificates.yaml +++ b/fixtures/service_certificates.yaml @@ -1,30 +1,29 @@ -# @see https://fakerphp.github.io/formatters/ App\Entity\ServiceCertificate: - service_certificate/dev.example.com: - domain: "dev.example.com" + service_certificate/shop.example.com: + domain: "shop.example.com" name: "CPR-opslag" description: | Bruges til opslag af CPR-numre. onePasswordUrl: "https://example.com/1password" expirationTime: <(new DateTime('+2 years'))> - usageDocumentationUrl: https://example.com/example.com/README.md#certificates + usageDocumentationUrl: https://example.com/shop/README.md#certificates - service_certificate/prod.example.com: - domain: "prod.example.com" + service_certificate/intranet.example.com: + domain: "intranet.example.com" name: "CVR-opslag" description: | Bruges til opslag af CVR-numre. onePasswordUrl: "https://example.com/1password" expirationTime: <(new DateTime('+4 years'))> - usageDocumentationUrl: https://example.com/example.com/README.md#certificates + usageDocumentationUrl: https://example.com/intranet/README.md#certificates App\Entity\ServiceCertificate\Service: - service/dev.example.com/service_certificate/digital-post: - certificate: "@service_certificate/dev.example.com" + service/shop.example.com/digital-post: + certificate: "@service_certificate/shop.example.com" type: "digital post" onePasswordUrl: "https://example.com/1password" - service/dev.example.com/service_certificate/cpr-lookup: - certificate: "@service_certificate/dev.example.com" + service/shop.example.com/cpr-lookup: + certificate: "@service_certificate/shop.example.com" type: "cpr lookup" onePasswordUrl: "https://example.com/1password" diff --git a/fixtures/site.yaml b/fixtures/site.yaml index 1e321b1b..b326ace0 100644 --- a/fixtures/site.yaml +++ b/fixtures/site.yaml @@ -1,83 +1,619 @@ +# Detection results as the harvester reports them, and the installations, +# sites and domains built from them. Names, domains and versions are made up. +# +# web-stg-01 stg.shop.example.com docker example-org/shop at a branch +# web-prod-01 shop.example.com docker example-org/shop at 2.4.1 +# clean.example.com docker example-org/clean, no advisories +# web-prod-02 intranet.example.com docker example-org/intranet, Drupal +# legacy.example.com nginx example-org/legacy, Symfony 5.4 +# release.example.com nginx release download, no git repo +# +# The data of each result is defined as a parameter and stored as JSON, as the +# harvester sends it. Each server's dir result lists all its installations, +# so replaying the fixtures keeps them. + +parameters: + # Composer packages as listed by composer show --format=json. + installed_http_foundation_6_4_10: &http_foundation_6_4_10 + name: "symfony/http-foundation" + version: "6.4.10" + latest: "6.4.20" + latest-status: "semver-safe-update" + description: "Defines an object-oriented layer for the HTTP specification" + abandoned: false + installed_http_foundation_6_4_20: &http_foundation_6_4_20 + name: "symfony/http-foundation" + version: "6.4.20" + latest: "7.2.4" + latest-status: "update-possible" + description: "Defines an object-oriented layer for the HTTP specification" + abandoned: false + installed_twig_3_8_0: &twig_3_8_0 + name: "twig/twig" + version: "3.8.0" + latest: "3.11.2" + latest-status: "semver-safe-update" + description: "Twig, the flexible, fast, and secure template language for PHP" + abandoned: false + installed_guzzle_7_9_2: &guzzle_7_9_2 + name: "guzzlehttp/guzzle" + version: "7.9.2" + latest: "7.9.2" + latest-status: "up-to-date" + description: "Guzzle is a PHP HTTP client library" + abandoned: false + installed_commonmark_2_4_0: &commonmark_2_4_0 + name: "league/commonmark" + version: "2.4.0" + latest: "2.6.0" + latest-status: "semver-safe-update" + description: "Highly-extensible PHP Markdown parser" + abandoned: false + installed_drupal_core_10_3_5: &drupal_core_10_3_5 + name: "drupal/core" + version: "10.3.5" + latest: "10.4.2" + latest-status: "semver-safe-update" + description: "Drupal is an open source content management platform" + abandoned: false + installed_drupal_webform_6_2_9: &drupal_webform_6_2_9 + name: "drupal/webform" + version: "6.2.9" + latest: "6.2.10" + latest-status: "semver-safe-update" + description: "Enables the creation of webforms and questionnaires" + abandoned: false + + # Advisories as reported by composer audit --format=json. + audit_http_foundation: &audit_http_foundation + advisoryId: "PKSA-x9q2-7fhd-3k1m" + packageName: "symfony/http-foundation" + affectedVersions: ">=6.0.0,<6.4.18|>=7.0.0,<7.2.4" + title: "CVE-2099-10001: Example open redirect in Request::getUri()" + cve: "CVE-2099-10001" + link: "https://example.com/advisories/cve-2099-10001" + reportedAt: "2026-02-11T10:00:00+00:00" + sources: + - name: "FriendsOfPHP/security-advisories" + remoteId: "symfony/http-foundation/CVE-2099-10001.yaml" + audit_twig: &audit_twig + advisoryId: "PKSA-b4n7-2wqz-8c5r" + packageName: "twig/twig" + affectedVersions: ">=3.0.0,<3.11.2" + title: "CVE-2099-10002: Example sandbox bypass" + cve: "CVE-2099-10002" + link: "https://example.com/advisories/cve-2099-10002" + reportedAt: "2026-03-04T09:30:00+00:00" + sources: + - name: "FriendsOfPHP/security-advisories" + remoteId: "twig/twig/CVE-2099-10002.yaml" + audit_commonmark: &audit_commonmark + advisoryId: "PKSA-m3r8-5tyv-1h6p" + packageName: "league/commonmark" + affectedVersions: ">=2.0.0,<2.6.0" + title: "Example denial of service through nested lists" + cve: null + link: "https://example.com/advisories/ghsa-7f3m-q2kx-9w4c" + reportedAt: "2026-04-20T14:15:00+00:00" + sources: + - name: "GitHub" + remoteId: "GHSA-7f3m-q2kx-9w4c" + audit_drupal_core: &audit_drupal_core + advisoryId: "PKSA-d8k2-6vnp-4j9t" + packageName: "drupal/core" + affectedVersions: ">=8.0.0,<10.3.9|>=10.4.0,<10.4.2" + title: "Drupal core - Moderately critical - Example access bypass" + cve: "CVE-2099-10003" + link: "https://example.com/advisories/sa-core-2099-001" + reportedAt: "2026-05-13T17:00:00+00:00" + sources: + - name: "FriendsOfPHP/security-advisories" + remoteId: "drupal/core/CVE-2099-10003.yaml" + + # Drupal modules as listed by drush pm:list --format=json. + drupal_modules: &drupal_modules + node: + package: "Core" + project: "drupal" + path: "core/modules/node" + status: "Enabled" + version: "10.3.5" + webform: + package: "Webform" + project: "webform" + path: "modules/contrib/webform" + status: "Enabled" + version: "6.2.9" + + # Containers the harvester finds without a PHP application. + nginx_container: &nginx_container + name: "nginx" + image: "nginx" + version: "1.27-alpine" + packages: "unknown" + audit: "unknown" + symfony: "unknown" + drupal: "unknown" + + # dir: the installation roots on each server. + dir_web_stg_01: + - "/data/www/stg_shop_example_com/htdocs" + dir_web_prod_01: + - "/data/www/clean_example_com/htdocs" + - "/data/www/shop_example_com/htdocs" + dir_web_prod_02: + - "/data/www/intranet_example_com/htdocs" + - "/data/www/legacy_example_com/htdocs" + - "/data/www/release_example_com/htdocs" + dir_devops_01: [] + + # docker: the domains and containers of a docker compose site. + docker_shop_stg: + domain: "stg.shop.example.com" + containers: + - name: "phpfpm" + image: "example-org/php8.3-fpm" + version: "latest" + packages: + installed: + [*http_foundation_6_4_20, *twig_3_8_0, *guzzle_7_9_2] + audit: + advisories: + twig/twig: [*audit_twig] + abandoned: {} + symfony: + version: "6.4.20" + eof: "11/2027" + lts: "Yes" + phpVersion: "8.3.30" + drupal: "unknown" + - *nginx_container + - name: "worker" + image: "ghcr.io/example-org/shop-worker" + version: "develop" + packages: "unknown" + audit: "unknown" + symfony: "unknown" + drupal: "unknown" + docker_shop_prod: + domain: "shop.example.com www.shop.example.com" + containers: + - name: "phpfpm" + image: "example-org/php8.3-fpm" + version: "latest" + packages: + installed: + [*http_foundation_6_4_10, *twig_3_8_0, *guzzle_7_9_2] + audit: + advisories: + symfony/http-foundation: [*audit_http_foundation] + twig/twig: [*audit_twig] + abandoned: {} + symfony: + version: "6.4.10" + eof: "11/2027" + lts: "Yes" + phpVersion: "8.3.30" + drupal: "unknown" + - *nginx_container + - name: "worker" + image: "ghcr.io/example-org/shop-worker" + version: "2.4.1" + packages: "unknown" + audit: "unknown" + symfony: "unknown" + drupal: "unknown" + docker_clean: + domain: "clean.example.com" + containers: + - name: "phpfpm" + image: "example-org/php8.4-fpm" + version: "latest" + packages: + installed: [*guzzle_7_9_2] + audit: + advisories: {} + abandoned: {} + symfony: + version: "7.2.4" + eof: "07/2025 Expired" + lts: "No" + phpVersion: "8.4.5" + drupal: "unknown" + - *nginx_container + docker_intranet: + domain: "intranet.example.com" + containers: + - name: "phpfpm" + image: "example-org/php8.3-fpm" + version: "latest" + packages: + installed: + [ + *drupal_core_10_3_5, + *drupal_webform_6_2_9, + *commonmark_2_4_0, + ] + audit: + advisories: + drupal/core: [*audit_drupal_core] + league/commonmark: [*audit_commonmark] + abandoned: {} + symfony: "unknown" + drupal: *drupal_modules + - *nginx_container + + # nginx: a site served by nginx on the host. + nginx_legacy: + domain: "legacy.example.com www.legacy.example.com" + phpVersion: "8.1" + config: "/etc/nginx/sites-enabled/legacy.example.com" + nginx_release: + domain: "release.example.com" + phpVersion: "8.3" + config: "/etc/nginx/sites-enabled/release.example.com" + + # symfony and drupal: an application found on the host. + symfony_legacy: + composerVersion: "2.8.5" + symfony: + version: "5.4.52" + eof: "02/2029" + lts: "Yes" + phpVersion: "8.1.31" + packages: + installed: [*twig_3_8_0, *guzzle_7_9_2] + audit: + advisories: + twig/twig: [*audit_twig] + abandoned: {} + drupal_release: + composerVersion: "2.8.5" + version: "10.3.5" + modules: *drupal_modules + packages: + installed: [*drupal_core_10_3_5, *drupal_webform_6_2_9] + audit: + advisories: + drupal/core: [*audit_drupal_core] + abandoned: {} + + # git: the remote, tag and uncommitted changes of a checkout. + git_shop_stg: + remotes: ["https://github.com/example-org/shop.git"] + tag: "unknown" + changes: [] + git_shop_prod: + remotes: ["https://github.com/example-org/shop.git"] + tag: "2.4.1" + changes: [] + git_clean: + remotes: ["https://github.com/example-org/clean.git"] + tag: "1.0.0" + changes: [] + git_intranet: + remotes: ["https://github.com/example-org/intranet.git"] + tag: "1.8.0" + changes: ["?? .phpunit.cache/"] + git_legacy: + remotes: ["https://github.com/example-org/legacy.git"] + tag: "0.9.3" + changes: [] + App\Entity\DetectionResult: - detection_result/dev.example.com: - server: "@server_1" - type: "nginx" - rootDir: "/data/www/dev.example.com/htdocs/public" - data: | - { - "domain": "dev.example.com", - "phpVersion": "8.2", - "config": "/etc/nginx/sites-enabled/dev.example.com" - } + detection_result/dir-web-stg-01: + server: "@server/web-stg-01" + type: "dir" + rootDir: "/data/www" + data: ", 64)>" + __calls: + - generateHash: [] + - setLastContact: [] + detection_result/dir-web-prod-01: + server: "@server/web-prod-01" + type: "dir" + rootDir: "/data/www" + data: ", 64)>" + __calls: + - generateHash: [] + - setLastContact: [] + detection_result/dir-web-prod-02: + server: "@server/web-prod-02" + type: "dir" + rootDir: "/data/www" + data: ", 64)>" + __calls: + - generateHash: [] + - setLastContact: [] + detection_result/dir-devops-01: + server: "@server/devops-01" + type: "dir" + rootDir: "/data/www" + data: ", 64)>" __calls: - generateHash: [] - setLastContact: [] - detection_result/test.example.com: - server: "@server_1" - type: "nginx" - rootDir: "/data/www/test.example.com/htdocs/public" - data: | - { - "domain": "test.example.com", - "phpVersion": "8.2", - "config": "/etc/nginx/sites-enabled/test.example.com" - } + detection_result/docker-shop-stg: + server: "@server/web-stg-01" + type: "docker" + rootDir: "/data/www/stg_shop_example_com/htdocs" + data: ", 64)>" + __calls: + - generateHash: [] + - setLastContact: [] + detection_result/docker-shop-prod: + server: "@server/web-prod-01" + type: "docker" + rootDir: "/data/www/shop_example_com/htdocs" + data: ", 64)>" + __calls: + - generateHash: [] + - setLastContact: [] + detection_result/docker-clean: + server: "@server/web-prod-01" + type: "docker" + rootDir: "/data/www/clean_example_com/htdocs" + data: ", 64)>" + __calls: + - generateHash: [] + - setLastContact: [] + detection_result/docker-intranet: + server: "@server/web-prod-02" + type: "docker" + rootDir: "/data/www/intranet_example_com/htdocs" + data: ", 64)>" __calls: - generateHash: [] - setLastContact: [] - detection_result/prod.example.com: - server: "@server_1" + detection_result/nginx-legacy: + server: "@server/web-prod-02" type: "nginx" - rootDir: "/data/www/prod.example.com/htdocs/public" - data: | - { - "domain": "prod.example.com", - "phpVersion": "8.2", - "config": "/etc/nginx/sites-enabled/prod.example.com" - } + rootDir: "/data/www/legacy_example_com/htdocs/public" + data: ", 64)>" + __calls: + - generateHash: [] + - setLastContact: [] + detection_result/nginx-release: + server: "@server/web-prod-02" + type: "nginx" + rootDir: "/data/www/release_example_com/htdocs/web" + data: ", 64)>" + __calls: + - generateHash: [] + - setLastContact: [] + detection_result/symfony-legacy: + server: "@server/web-prod-02" + type: "symfony" + rootDir: "/data/www/legacy_example_com/htdocs" + data: ", 64)>" + __calls: + - generateHash: [] + - setLastContact: [] + detection_result/drupal-release: + server: "@server/web-prod-02" + type: "drupal" + rootDir: "/data/www/release_example_com/htdocs" + data: ", 64)>" __calls: - generateHash: [] - setLastContact: [] -App\Entity\Domain: - domain/dev.example.com: - rootDir: "/tmp" - address: "dev.example.com" - detectionResult: "@detection_result/dev.example.com" - domain/test.example.com: - rootDir: "/tmp" - address: "test.example.com" - detectionResult: "@detection_result/test.example.com" - domain/prod.example.com: - rootDir: "/tmp" - address: "prod.example.com" - detectionResult: "@detection_result/prod.example.com" + detection_result/git-shop-stg: + server: "@server/web-stg-01" + type: "git" + rootDir: "/data/www/stg_shop_example_com/htdocs" + data: ", 64)>" + __calls: + - generateHash: [] + - setLastContact: [] + detection_result/git-shop-prod: + server: "@server/web-prod-01" + type: "git" + rootDir: "/data/www/shop_example_com/htdocs" + data: ", 64)>" + __calls: + - generateHash: [] + - setLastContact: [] + detection_result/git-clean: + server: "@server/web-prod-01" + type: "git" + rootDir: "/data/www/clean_example_com/htdocs" + data: ", 64)>" + __calls: + - generateHash: [] + - setLastContact: [] + detection_result/git-intranet: + server: "@server/web-prod-02" + type: "git" + rootDir: "/data/www/intranet_example_com/htdocs" + data: ", 64)>" + __calls: + - generateHash: [] + - setLastContact: [] + detection_result/git-legacy: + server: "@server/web-prod-02" + type: "git" + rootDir: "/data/www/legacy_example_com/htdocs" + data: ", 64)>" + __calls: + - generateHash: [] + - setLastContact: [] + # A release download is not a git checkout, so the harvester reports nothing. + detection_result/git-release: + server: "@server/web-prod-02" + type: "git" + rootDir: "/data/www/release_example_com/htdocs" + data: "" + __calls: + - generateHash: [] + - setLastContact: [] App\Entity\Installation: - installation/dev.example.com: - rootDir: "/tmp" - detectionResult: "@detection_result/dev.example.com" + installation/shop-stg: + detectionResult: "@detection_result/docker-shop-stg" + type: "symfony" + frameworkVersion: "6.4.20" + phpVersion: "8.3.30" + composerVersion: "2.8.5" + lts: true + eol: "11/2027" + gitTag: "@git_tag/shop-unknown" + gitClonedScheme: "https" + __calls: + - addPackageVersion: + ["@package_version/symfony-http-foundation-6.4.20"] + - addPackageVersion: ["@package_version/twig-3.8.0"] + - addPackageVersion: ["@package_version/guzzle-7.9.2"] + - addDockerImageTag: ["@docker_image_tag/php8.3-fpm-latest"] + - addDockerImageTag: ["@docker_image_tag/nginx-1.27-alpine"] + - addDockerImageTag: ["@docker_image_tag/shop-worker-develop"] + installation/shop-prod: + detectionResult: "@detection_result/docker-shop-prod" + type: "symfony" + frameworkVersion: "6.4.10" + phpVersion: "8.3.30" + composerVersion: "2.8.5" + lts: true + eol: "11/2027" + gitTag: "@git_tag/shop-2.4.1" + gitClonedScheme: "https" + __calls: + - addPackageVersion: + ["@package_version/symfony-http-foundation-6.4.10"] + - addPackageVersion: ["@package_version/twig-3.8.0"] + - addPackageVersion: ["@package_version/guzzle-7.9.2"] + - addDockerImageTag: ["@docker_image_tag/php8.3-fpm-latest"] + - addDockerImageTag: ["@docker_image_tag/nginx-1.27-alpine"] + - addDockerImageTag: ["@docker_image_tag/shop-worker-2.4.1"] + installation/clean: + detectionResult: "@detection_result/docker-clean" + type: "symfony" + frameworkVersion: "7.2.4" + phpVersion: "8.4.5" + composerVersion: "2.8.5" + eol: "07/2025 Expired" + gitTag: "@git_tag/clean-1.0.0" + gitClonedScheme: "https" + __calls: + - addPackageVersion: ["@package_version/guzzle-7.9.2"] + - addDockerImageTag: ["@docker_image_tag/php8.4-fpm-latest"] + - addDockerImageTag: ["@docker_image_tag/nginx-1.27-alpine"] + installation/intranet: + detectionResult: "@detection_result/docker-intranet" + type: "drupal" + frameworkVersion: "10.3.5" + phpVersion: "8.3.30" + composerVersion: "2.8.5" + gitTag: "@git_tag/intranet-1.8.0" + gitClonedScheme: "https" + gitChanges: "?? .phpunit.cache/" + gitChangesCount: 1 + __calls: + - addPackageVersion: ["@package_version/drupal-core-10.3.5"] + - addPackageVersion: ["@package_version/drupal-webform-6.2.9"] + - addPackageVersion: ["@package_version/league-commonmark-2.4.0"] + - addModuleVersion: ["@module_version/node-10.3.5"] + - addModuleVersion: ["@module_version/webform-6.2.9"] + - addDockerImageTag: ["@docker_image_tag/php8.3-fpm-latest"] + - addDockerImageTag: ["@docker_image_tag/nginx-1.27-alpine"] + installation/legacy: + detectionResult: "@detection_result/symfony-legacy" + type: "symfony" + frameworkVersion: "5.4.52" + phpVersion: "8.1.31" + composerVersion: "2.8.5" + lts: true + eol: "02/2029" + gitTag: "@git_tag/legacy-0.9.3" + gitClonedScheme: "https" + __calls: + - addPackageVersion: ["@package_version/twig-3.8.0"] + - addPackageVersion: ["@package_version/guzzle-7.9.2"] + installation/release: + detectionResult: "@detection_result/drupal-release" + type: "drupal" + frameworkVersion: "10.3.5" + phpVersion: "8.3.30" + composerVersion: "2.8.5" + __calls: + - addPackageVersion: ["@package_version/drupal-core-10.3.5"] + - addPackageVersion: ["@package_version/drupal-webform-6.2.9"] + - addModuleVersion: ["@module_version/node-10.3.5"] + - addModuleVersion: ["@module_version/webform-6.2.9"] - installation/prod.example.com: - rootDir: "/tmp" - detectionResult: "@detection_result/prod.example.com" +App\Entity\Domain: + domain/stg.shop.example.com: + address: "stg.shop.example.com" + detectionResult: "@detection_result/docker-shop-stg" + domain/shop.example.com: + address: "shop.example.com" + detectionResult: "@detection_result/docker-shop-prod" + domain/www.shop.example.com: + address: "www.shop.example.com" + detectionResult: "@detection_result/docker-shop-prod" + domain/clean.example.com: + address: "clean.example.com" + detectionResult: "@detection_result/docker-clean" + domain/intranet.example.com: + address: "intranet.example.com" + detectionResult: "@detection_result/docker-intranet" + domain/legacy.example.com: + address: "legacy.example.com" + detectionResult: "@detection_result/nginx-legacy" + domain/www.legacy.example.com: + address: "www.legacy.example.com" + detectionResult: "@detection_result/nginx-legacy" + domain/release.example.com: + address: "release.example.com" + detectionResult: "@detection_result/nginx-release" App\Entity\Site: - site/dev.example.com: - server: "@server_1" - detectionResult: "@detection_result/dev.example.com" - rootDir: "/data/www/dev.example.com/htdocs/public" - installation: "@installation/dev.example.com" - __calls: - - addDomain: ["@domain/dev.example.com"] - - site/prod.example.com: - server: "@server_1" - detectionResult: "@detection_result/prod.example.com" - rootDir: "/data/www/prod.example.com/htdocs/public" - installation: "@installation/prod.example.com" - __calls: - - addDomain: ["@domain/prod.example.com"] - - addDomain: ["@domain/test.example.com"] + site/shop-stg: + detectionResult: "@detection_result/docker-shop-stg" + installation: "@installation/shop-stg" + type: "docker" + phpVersion: "8.3" + __calls: + - addDomain: ["@domain/stg.shop.example.com"] + site/shop-prod: + detectionResult: "@detection_result/docker-shop-prod" + installation: "@installation/shop-prod" + type: "docker" + phpVersion: "8.3" + __calls: + - addDomain: ["@domain/shop.example.com"] + - addDomain: ["@domain/www.shop.example.com"] + site/clean: + detectionResult: "@detection_result/docker-clean" + installation: "@installation/clean" + type: "docker" + phpVersion: "8.4" + __calls: + - addDomain: ["@domain/clean.example.com"] + site/intranet: + detectionResult: "@detection_result/docker-intranet" + installation: "@installation/intranet" + type: "docker" + phpVersion: "8.3" + __calls: + - addDomain: ["@domain/intranet.example.com"] + # Served from a sub directory of the installation. + site/legacy: + detectionResult: "@detection_result/nginx-legacy" + installation: "@installation/legacy" + type: "nginx" + phpVersion: "8.1" + configFilePath: "/etc/nginx/sites-enabled/legacy.example.com" + __calls: + - addDomain: ["@domain/legacy.example.com"] + - addDomain: ["@domain/www.legacy.example.com"] + site/release: + detectionResult: "@detection_result/nginx-release" + installation: "@installation/release" + type: "nginx" + phpVersion: "8.3" + configFilePath: "/etc/nginx/sites-enabled/release.example.com" + __calls: + - addDomain: ["@domain/release.example.com"] diff --git a/tests/Api/PaginationTest.php b/tests/Api/PaginationTest.php index ef0ae1ea..7e3a9274 100644 --- a/tests/Api/PaginationTest.php +++ b/tests/Api/PaginationTest.php @@ -22,7 +22,7 @@ class PaginationTest extends ApiTestCase protected static ?bool $alwaysBootKernel = true; /** - * Top the ten fixture servers up to 101, one more than a page. + * Top the fixture servers up to 101, one more than a page. */ private function createClientWith101Servers(): Client { @@ -30,7 +30,7 @@ private function createClientWith101Servers(): Client $manager = static::getContainer()->get('doctrine')->getManager(); self::assertInstanceOf(EntityManagerInterface::class, $manager); - for ($i = 0; $i < 91; ++$i) { + for ($i = $manager->getRepository(Server::class)->count([]); $i < 101; ++$i) { $manager->persist(new Server() ->setName('server-'.$i.'.example.com') ->setSystem('Ubuntu 24.04') diff --git a/tests/Api/SiteFilterTest.php b/tests/Api/SiteFilterTest.php index f6d61168..7a6c99e7 100644 --- a/tests/Api/SiteFilterTest.php +++ b/tests/Api/SiteFilterTest.php @@ -35,8 +35,8 @@ private function createClientWithKnownSites(): Client self::assertInstanceOf(EntityManagerInterface::class, $manager); $sites = [ - 'dev.example.com' => ['alpha.example.com', ServerTypeType::STG, HostingProviderType::AZURE, '8.1.2', '/etc/nginx/sites-enabled/dev.conf'], - 'prod.example.com' => ['beta.example.com', ServerTypeType::PROD, HostingProviderType::HETZNER, '8.3', '/etc/nginx/sites-enabled/prod.conf'], + 'stg.shop.example.com' => ['alpha.example.com', ServerTypeType::STG, HostingProviderType::AZURE, '8.1.2', '/etc/nginx/sites-enabled/stg.conf'], + 'shop.example.com' => ['beta.example.com', ServerTypeType::PROD, HostingProviderType::HETZNER, '8.3', '/etc/nginx/sites-enabled/prod.conf'], ]; $servers = $manager->getRepository(Server::class)->findAll(); @@ -69,19 +69,19 @@ private function createClientWithKnownSites(): Client */ public static function filters(): iterable { - yield 'no filter' => ['', ['dev.example.com', 'prod.example.com']]; - yield 'primary domain contains' => ['primaryDomain=prod', ['prod.example.com']]; - yield 'config file path contains' => ['configFilePath=dev.conf', ['dev.example.com']]; - yield 'php version equals' => ['phpVersion=8.3.0', ['prod.example.com']]; - yield 'php version gte' => ['phpVersion[gte]=8.2', ['prod.example.com']]; - yield 'php version lt' => ['phpVersion[lt]=8.2', ['dev.example.com']]; - yield 'php version ne' => ['phpVersion[ne]=8.3', ['dev.example.com']]; - yield 'php version range' => ['phpVersion[gt]=8.1.2&phpVersion[lte]=8.3', ['prod.example.com']]; - yield 'php version between' => ['phpVersion[between]=8.3..8.1', ['dev.example.com', 'prod.example.com']]; + yield 'no filter' => ['', ['shop.example.com', 'stg.shop.example.com']]; + yield 'primary domain contains' => ['primaryDomain=stg', ['stg.shop.example.com']]; + yield 'config file path contains' => ['configFilePath=stg.conf', ['stg.shop.example.com']]; + yield 'php version equals' => ['phpVersion=8.3.0', ['shop.example.com']]; + yield 'php version gte' => ['phpVersion[gte]=8.2', ['shop.example.com']]; + yield 'php version lt' => ['phpVersion[lt]=8.2', ['stg.shop.example.com']]; + yield 'php version ne' => ['phpVersion[ne]=8.3', ['stg.shop.example.com']]; + yield 'php version range' => ['phpVersion[gt]=8.1.2&phpVersion[lte]=8.3', ['shop.example.com']]; + yield 'php version between' => ['phpVersion[between]=8.3..8.1', ['shop.example.com', 'stg.shop.example.com']]; yield 'php version not a version' => ['phpVersion=latest', []]; - yield 'server name' => ['serverName=alpha.example.com', ['dev.example.com']]; - yield 'server type' => ['serverType=prod', ['prod.example.com']]; - yield 'hosting provider' => ['hostingProvider=Azure', ['dev.example.com']]; + yield 'server name' => ['serverName=alpha.example.com', ['stg.shop.example.com']]; + yield 'server type' => ['serverType=prod', ['shop.example.com']]; + yield 'hosting provider' => ['hostingProvider=Azure', ['stg.shop.example.com']]; yield 'combined' => ['serverType=stg&hostingProvider=Hetzner', []]; } diff --git a/tests/Controller/Admin/AdminSmokeTest.php b/tests/Controller/Admin/AdminSmokeTest.php index f3703f01..04760862 100644 --- a/tests/Controller/Admin/AdminSmokeTest.php +++ b/tests/Controller/Admin/AdminSmokeTest.php @@ -214,9 +214,10 @@ public function testDockerImagesFilterByRegistry(): void $crawler = $client->request('GET', $url); $this->assertResponseIsSuccessful(); - $rows = $crawler->filter('tbody tr[data-id]'); - $this->assertCount(1, $rows); - $this->assertStringContainsString('probe-project', $rows->text()); + // The fixtures hold ghcr.io images too. + $rows = implode("\n", $crawler->filter('tbody tr[data-id]')->each(static fn (Crawler $row): string => $row->text())); + $this->assertStringContainsString('probe-project', $rows); + $this->assertStringNotContainsString('probe-itkdev', $rows); } /** diff --git a/tests/DataFixtures/AdvisoryFixturesTest.php b/tests/DataFixtures/AdvisoryFixturesTest.php deleted file mode 100644 index 6b772582..00000000 --- a/tests/DataFixtures/AdvisoryFixturesTest.php +++ /dev/null @@ -1,137 +0,0 @@ -entityManager = self::getContainer()->get(EntityManagerInterface::class); - // Read what was stored, not the objects the loader left in memory, - // whose inverse collections are not filled in. - $this->entityManager->clear(); - } - - public function testARepoIsCheckedOutOnSeveralInstallationsWithOverlappingAdvisories(): void - { - $repos = array_filter( - $this->entityManager->getRepository(GitRepo::class)->findAll(), - fn (GitRepo $repo): bool => count($this->installationsOf($repo)) > 1, - ); - $this->assertNotEmpty($repos); - - $installations = $this->installationsOf(reset($repos)); - $tags = array_unique(array_map(static fn (Installation $installation): string => (string) $installation->getGitTag()?->getTag(), $installations)); - $this->assertGreaterThan(1, count($tags), 'Checkouts at different versions'); - - [$first, $second] = array_map($this->advisoryIdsOf(...), $installations); - $this->assertNotEmpty(array_intersect($first, $second), 'Advisories shared by the checkouts'); - $this->assertNotEmpty(array_diff($first, $second) ?: array_diff($second, $first), 'Advisories only one checkout has'); - } - - public function testARepoHasNoAdvisories(): void - { - $this->assertNotEmpty(array_filter( - $this->entityManager->getRepository(GitRepo::class)->findAll(), - fn (GitRepo $repo): bool => [] === array_merge(...array_map($this->advisoryIdsOf(...), $this->installationsOf($repo))), - )); - } - - public function testAnInstallationWithoutRepoHasAdvisories(): void - { - $this->assertNotEmpty(array_filter( - $this->entityManager->getRepository(Installation::class)->findBy(['gitTag' => null]), - fn (Installation $installation): bool => [] !== $this->advisoryIdsOf($installation), - )); - } - - public function testAdvisoriesCoverComposerAndDrupalSourcesWithAndWithoutCve(): void - { - $advisories = $this->entityManager->getRepository(Advisory::class)->findAll(); - - $prefixes = array_unique(array_map(static fn (Advisory $advisory): string => explode('-', $advisory->getAdvisoryId())[0].'-'.explode('-', $advisory->getAdvisoryId())[1], $advisories)); - $this->assertContains('SA-CORE', $prefixes); - $this->assertContains('SA-CONTRIB', $prefixes); - $this->assertNotEmpty(array_filter($advisories, static fn (Advisory $advisory): bool => str_starts_with($advisory->getAdvisoryId(), 'PKSA-'))); - - $this->assertNotEmpty(array_filter($advisories, static fn (Advisory $advisory): bool => null === $advisory->getCve()), 'An advisory without a CVE'); - - $cves = array_filter(array_map(static fn (Advisory $advisory): ?string => $advisory->getCve(), $advisories)); - $this->assertNotSame(array_unique($cves), $cves, 'Two advisories sharing one CVE'); - } - - public function testADrupalModuleReachesAnAdvisoryThroughItsComposerPackage(): void - { - $this->assertNotEmpty(array_filter( - $this->entityManager->getRepository(ModuleVersion::class)->findAll(), - static fn (ModuleVersion $moduleVersion): bool => ($moduleVersion->getComposerPackageVersion()?->getAdvisoryCount() ?? 0) > 0, - )); - } - - /** - * Fixtures are committed and loaded on every dev setup, so they must never - * carry production data: example domains and an example organization only. - */ - public function testFixturesUseExampleDataOnly(): void - { - foreach ($this->entityManager->getRepository(Domain::class)->findAll() as $domain) { - $this->assertMatchesRegularExpression('/(^|\.)example\.com$/', $domain->getAddress()); - } - foreach ($this->entityManager->getRepository(GitRepo::class)->findAll() as $repo) { - $this->assertSame('example-org', $repo->getOrganization()); - } - foreach ($this->entityManager->getRepository(Advisory::class)->findAll() as $advisory) { - $this->assertMatchesRegularExpression('/^(CVE-2099-\d+)?$/', (string) $advisory->getCve(), 'Made-up CVE ids only'); - } - } - - /** - * @return list - */ - private function installationsOf(GitRepo $repo): array - { - $installations = []; - foreach ($repo->getGitTags() as $gitTag) { - array_push($installations, ...$gitTag->getInstallations()); - } - - return $installations; - } - - /** - * @return list - */ - private function advisoryIdsOf(Installation $installation): array - { - $ids = []; - foreach ($installation->getPackageVersions() as $packageVersion) { - foreach ($packageVersion->getAdvisories() as $advisory) { - $ids[] = $advisory->getAdvisoryId(); - } - } - - return $ids; - } -} diff --git a/tests/DataFixtures/FixturesTest.php b/tests/DataFixtures/FixturesTest.php new file mode 100644 index 00000000..99b7f635 --- /dev/null +++ b/tests/DataFixtures/FixturesTest.php @@ -0,0 +1,289 @@ +entityManager = self::getContainer()->get(EntityManagerInterface::class); + // Read what was stored, not the objects the loader left in memory, + // whose inverse collections are not filled in. + $this->entityManager->clear(); + } + + public function testEveryEntityHasFixtures(): void + { + foreach ($this->entityManager->getMetadataFactory()->getAllMetadata() as $metadata) { + if ($metadata->isMappedSuperclass) { + continue; + } + $this->assertGreaterThan(0, $this->entityManager->getRepository($metadata->getName())->count([]), $metadata->getName()); + } + } + + public function testEveryDetectionTypeHasAResult(): void + { + $types = array_map(static fn (DetectionResult $result): string => $result->getType(), $this->entityManager->getRepository(DetectionResult::class)->findAll()); + + $this->assertEqualsCanonicalizing(array_values(DetectionType::CHOICES), array_values(array_unique($types))); + } + + public function testEveryServerTypeHasAServer(): void + { + $types = array_map(static fn (Server $server): string => (string) $server->getType(), $this->entityManager->getRepository(Server::class)->findAll()); + + $this->assertEqualsCanonicalizing(array_values(ServerTypeType::CHOICES), array_values(array_unique($types))); + } + + public function testSitesAreServedByDockerAndNginx(): void + { + $types = array_map(static fn (Site $site): string => (string) $site->getType(), $this->entityManager->getRepository(Site::class)->findAll()); + + $this->assertEqualsCanonicalizing([SiteType::DOCKER, SiteType::NGINX], array_values(array_unique($types))); + } + + /** + * The latest dir result decides which installations a server has, so a + * replay of the fixtures would remove any installation it does not list. + */ + public function testTheLatestDirResultListsEveryInstallationOnItsServer(): void + { + foreach ($this->entityManager->getRepository(Server::class)->findAll() as $server) { + $dirResult = $this->entityManager->getRepository(DetectionResult::class)->findOneBy(['server' => $server, 'type' => DetectionType::DIRECTORY], ['lastContact' => 'DESC']); + $listed = null === $dirResult ? [] : json_decode($dirResult->getData(), true, 512, JSON_THROW_ON_ERROR); + $installed = array_map(static fn (Installation $installation): string => $installation->getRootDir(), $server->getInstallations()->toArray()); + + $this->assertEqualsCanonicalizing($listed, $installed, (string) $server->getName()); + } + } + + /** + * The installations and sites are written out by hand next to the + * detection results they come from. Replaying the results must rebuild the + * same data, or the two have drifted apart. + */ + public function testReplayingTheDetectionResultsRebuildsTheSameData(): void + { + $before = $this->snapshot(); + + $repository = $this->entityManager->getRepository(DetectionResult::class); + $ids = static fn (array $results): array => array_map(static fn (DetectionResult $result) => $result->getId(), $results); + // Like a harvest: every result, then the dir results again, which + // fold a site's sub directory back into its installation. + foreach ([...$ids($repository->findBy([], ['id' => 'ASC'])), ...$ids($repository->findBy(['type' => DetectionType::DIRECTORY]))] as $id) { + self::getContainer()->get(ProcessDetectionResultHandler::class)(new ProcessDetectionResult($id)); + } + $this->entityManager->clear(); + + $this->assertSame($before, $this->snapshot()); + } + + public function testARepoIsCheckedOutOnSeveralInstallationsWithOverlappingAdvisories(): void + { + $repos = array_filter( + $this->entityManager->getRepository(GitRepo::class)->findAll(), + fn (GitRepo $repo): bool => count($this->installationsOf($repo)) > 1, + ); + $this->assertNotEmpty($repos); + + $installations = $this->installationsOf(reset($repos)); + $tags = array_unique(array_map(static fn (Installation $installation): string => (string) $installation->getGitTag()?->getTag(), $installations)); + $this->assertGreaterThan(1, count($tags), 'Checkouts at different versions'); + + [$first, $second] = array_map($this->advisoryIdsOf(...), $installations); + $this->assertNotEmpty(array_intersect($first, $second), 'Advisories shared by the checkouts'); + $this->assertNotEmpty(array_diff($first, $second) ?: array_diff($second, $first), 'Advisories only one checkout has'); + } + + public function testARepoHasNoAdvisories(): void + { + $this->assertNotEmpty(array_filter( + $this->entityManager->getRepository(GitRepo::class)->findAll(), + fn (GitRepo $repo): bool => [] === array_merge(...array_map($this->advisoryIdsOf(...), $this->installationsOf($repo))), + )); + } + + public function testAnInstallationWithoutRepoHasAdvisories(): void + { + $this->assertNotEmpty(array_filter( + $this->entityManager->getRepository(Installation::class)->findBy(['gitTag' => null]), + fn (Installation $installation): bool => [] !== $this->advisoryIdsOf($installation), + )); + } + + public function testAdvisoriesCoverComposerAndDrupalSourcesWithAndWithoutCve(): void + { + $advisories = $this->entityManager->getRepository(Advisory::class)->findAll(); + + $prefixes = array_unique(array_map(static fn (Advisory $advisory): string => explode('-', $advisory->getAdvisoryId())[0].'-'.explode('-', $advisory->getAdvisoryId())[1], $advisories)); + $this->assertContains('SA-CORE', $prefixes); + $this->assertContains('SA-CONTRIB', $prefixes); + $this->assertNotEmpty(array_filter($advisories, static fn (Advisory $advisory): bool => str_starts_with($advisory->getAdvisoryId(), 'PKSA-'))); + + $this->assertNotEmpty(array_filter($advisories, static fn (Advisory $advisory): bool => null === $advisory->getCve()), 'An advisory without a CVE'); + + $cves = array_filter(array_map(static fn (Advisory $advisory): ?string => $advisory->getCve(), $advisories)); + $this->assertNotSame(array_unique($cves), $cves, 'Two advisories sharing one CVE'); + } + + public function testADrupalModuleReachesAnAdvisoryThroughItsComposerPackage(): void + { + $this->assertNotEmpty(array_filter( + $this->entityManager->getRepository(ModuleVersion::class)->findAll(), + static fn (ModuleVersion $moduleVersion): bool => ($moduleVersion->getComposerPackageVersion()?->getAdvisoryCount() ?? 0) > 0, + )); + } + + /** + * Fixtures are committed and loaded on every dev setup, so they must never + * carry production data: example domains, emails and organizations, + * private or documentation IP addresses and made-up ids only. + */ + public function testFixturesUseExampleDataOnly(): void + { + $exampleHost = '/(^|\.)example\.com$/'; + foreach ($this->entityManager->getRepository(Domain::class)->findAll() as $domain) { + $this->assertMatchesRegularExpression($exampleHost, $domain->getAddress()); + } + foreach ($this->entityManager->getRepository(Server::class)->findAll() as $server) { + $this->assertMatchesRegularExpression($exampleHost, (string) $server->getName()); + // RFC 1918 private and RFC 5737 documentation ranges. + $this->assertMatchesRegularExpression('/^(10\.|192\.168\.|192\.0\.2\.|198\.51\.100\.|203\.0\.113\.)/', (string) $server->getInternalIp()); + $this->assertMatchesRegularExpression('/^(192\.0\.2\.|198\.51\.100\.|203\.0\.113\.)/', (string) $server->getExternalIp()); + } + foreach ($this->entityManager->getRepository(OIDC::class)->findAll() as $oidc) { + $this->assertMatchesRegularExpression($exampleHost, (string) $oidc->getDomain()); + } + foreach ($this->entityManager->getRepository(ServiceCertificate::class)->findAll() as $certificate) { + $this->assertMatchesRegularExpression($exampleHost, (string) $certificate->getDomain()); + } + $emails = [ + ...array_map(static fn (User $user): string => (string) $user->getEmail(), $this->entityManager->getRepository(User::class)->findAll()), + ...array_map(static fn (CodeOwner $owner): string => $owner->getEmail(), $this->entityManager->getRepository(CodeOwner::class)->findAll()), + ...array_filter(array_map(static fn (SecurityContract $contract): ?string => $contract->getClientContactEmail(), $this->entityManager->getRepository(SecurityContract::class)->findAll())), + ]; + foreach ($emails as $email) { + $this->assertMatchesRegularExpression('/@example\.com$/', $email); + } + foreach ($this->entityManager->getRepository(GitRepo::class)->findAll() as $repo) { + $this->assertSame('example-org', $repo->getOrganization()); + } + foreach ($this->entityManager->getRepository(DockerImage::class)->findAll() as $image) { + // Official images, stored without a repository, are public; the + // rest belong to the example organization. + if ('' !== $image->getRepository()) { + $this->assertSame('example-org', $image->getOrganization(), (string) $image); + } + } + foreach ($this->entityManager->getRepository(Advisory::class)->findAll() as $advisory) { + $this->assertMatchesRegularExpression('/^(CVE-2099-\d+)?$/', (string) $advisory->getCve(), 'Made-up CVE ids only'); + } + } + + /** + * What the handlers derive from the detection results, keyed by root dir. + * + * @return array + */ + private function snapshot(): array + { + $names = static fn (iterable $items): array => array_values(array_unique(array_map(strval(...), [...$items]))); + $sorted = static function (array $values): array { + sort($values); + + return $values; + }; + + $installations = []; + foreach ($this->entityManager->getRepository(Installation::class)->findAll() as $installation) { + $installations[$installation->getServer()->getName().$installation->getRootDir()] = [ + 'type' => $installation->getType(), + 'frameworkVersion' => $installation->getFrameworkVersion(), + 'gitTag' => (string) $installation->getGitTag(), + 'packageVersions' => $sorted($names($installation->getPackageVersions())), + 'moduleVersions' => $sorted($names($installation->getModuleVersions())), + 'dockerImageTags' => $sorted($names($installation->getDockerImageTags())), + 'advisories' => $sorted($this->advisoryIdsOf($installation)), + ]; + } + $sites = []; + foreach ($this->entityManager->getRepository(Site::class)->findAll() as $site) { + $sites[$site->getServer()->getName().$site->getRootDir()] = [ + 'type' => $site->getType(), + 'phpVersion' => $site->getPhpVersion(), + 'installation' => $site->getInstallation()->getRootDir(), + 'domains' => $sorted($names(array_map(static fn (Domain $domain): string => $domain->getAddress(), $site->getDomains()->toArray()))), + ]; + } + ksort($installations); + ksort($sites); + + return ['installations' => $installations, 'sites' => $sites]; + } + + /** + * @return list + */ + private function installationsOf(GitRepo $repo): array + { + $installations = []; + foreach ($repo->getGitTags() as $gitTag) { + array_push($installations, ...$gitTag->getInstallations()); + } + + return $installations; + } + + /** + * @return list + */ + private function advisoryIdsOf(Installation $installation): array + { + $ids = []; + foreach ($installation->getPackageVersions() as $packageVersion) { + foreach ($packageVersion->getAdvisories() as $advisory) { + $ids[] = $advisory->getAdvisoryId(); + } + } + + return $ids; + } +} diff --git a/tests/Handler/DirectoryHandlerTest.php b/tests/Handler/DirectoryHandlerTest.php index 1c5e13b5..bd15c665 100644 --- a/tests/Handler/DirectoryHandlerTest.php +++ b/tests/Handler/DirectoryHandlerTest.php @@ -29,7 +29,8 @@ protected function setUp(): void { self::bootKernel(); $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); - $this->serverId = (string) $this->entityManager->getRepository(Server::class)->findOneBy([])->getId(); + // A server without results of its own, so the tests decide which dir result is the latest. + $this->serverId = (string) $this->entityManager->getRepository(Server::class)->findOneBy(['name' => 'gpu-01.example.com'])->getId(); } public function testRemovesInstallationsNoLongerInDirResult(): void From 6b42c157fa41de7170c602c6e889a50d34276618 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 21 Oct 2026 15:22:13 +0200 Subject: [PATCH 17/35] docs: update changelog entry for #168 --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6aeb855..29ce446d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - [#168](https://github.com/itk-dev/devops_itksites/pull/168) - Add fixtures for advisories, git repos and Drupal modules + Rewrite the fixtures to cover all content with made-up data shaped like + production - [#167](https://github.com/itk-dev/devops_itksites/pull/167) Let the latest dir result decide which sites exist, so replays no longer revive removed sites or delete live ones, and purge detection results not From 638e4337506e2ca8974773b993c1e4655a723674 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 21 Oct 2026 16:37:35 +0200 Subject: [PATCH 18/35] feat: mark advisories as not affected per git repo On a site's detail page, an advisory can be marked as not affected with a reason, one at a time or all current ones at once, and a mark can be removed. A mark belongs to the git repo, so it covers every checkout of it, and sites without a repo cannot be marked. Marked advisories no longer count on the site list, in its sort or on the repo advisories page. Marks are stored by repo and advisory name, so truncating derived data and replays leave them in place. --- fixtures/package.yaml | 10 + migrations/Version20261021140107.php | 26 +++ src/Admin/AdvisoryCountSort.php | 8 +- .../Admin/AdvisoryAssessmentController.php | 79 +++++++ src/Controller/Admin/SiteCrudController.php | 14 +- src/Entity/AdvisoryAssessment.php | 106 +++++++++ .../AdvisoryAssessmentRepository.php | 45 ++++ src/Repository/GitRepoRepository.php | 7 +- src/Service/AdvisoryAssessor.php | 99 +++++++++ .../Fields/site_advisories.html.twig | 78 +++++++ .../AdvisoryAssessmentControllerTest.php | 207 ++++++++++++++++++ tests/DataFixtures/FixturesTest.php | 2 + tests/Service/AdvisoryAssessorTest.php | 113 ++++++++++ 13 files changed, 789 insertions(+), 5 deletions(-) create mode 100644 migrations/Version20261021140107.php create mode 100644 src/Controller/Admin/AdvisoryAssessmentController.php create mode 100644 src/Entity/AdvisoryAssessment.php create mode 100644 src/Repository/AdvisoryAssessmentRepository.php create mode 100644 src/Service/AdvisoryAssessor.php create mode 100644 templates/EasyAdminBundle/Fields/site_advisories.html.twig create mode 100644 tests/Controller/Admin/AdvisoryAssessmentControllerTest.php create mode 100644 tests/Service/AdvisoryAssessorTest.php diff --git a/fixtures/package.yaml b/fixtures/package.yaml index 0adca1db..8a589cf1 100644 --- a/fixtures/package.yaml +++ b/fixtures/package.yaml @@ -6,6 +6,7 @@ # - drupal/core has a drupal.org and a Packagist advisory for the same CVE. # - league/commonmark and drupal/webform have advisories without a CVE. # - guzzle has no advisories. +# - example-org/intranet has the commonmark advisory marked as not affected. App\Entity\Package: package/symfony-http-foundation: @@ -106,6 +107,15 @@ App\Entity\Advisory: - name: "Drupal" remoteId: "SA-CONTRIB-2099-001" +# Advisories a developer has marked as not affected, per git repo. +App\Entity\AdvisoryAssessment: + advisory_assessment/intranet-commonmark: + advisoryId: "PKSA-m3r8-5tyv-1h6p" + reason: "Markdown is only rendered from editor input" + assessedBy: "user@example.com" + __calls: + - setGitRepo: ["@git_repo/intranet"] + App\Entity\PackageVersion: package_version/symfony-http-foundation-6.4.10: package: "@package/symfony-http-foundation" diff --git a/migrations/Version20261021140107.php b/migrations/Version20261021140107.php new file mode 100644 index 00000000..93981a7e --- /dev/null +++ b/migrations/Version20261021140107.php @@ -0,0 +1,26 @@ +addSql('CREATE TABLE advisory_assessment (id BINARY(16) NOT NULL, created_at DATETIME NOT NULL, modified_at DATETIME NOT NULL, created_by VARCHAR(255) DEFAULT \'\' NOT NULL, modified_by VARCHAR(255) DEFAULT \'\' NOT NULL, repo_provider VARCHAR(255) NOT NULL, repo_organization VARCHAR(255) NOT NULL, repo_name VARCHAR(255) NOT NULL, advisory_id VARCHAR(255) NOT NULL, reason LONGTEXT NOT NULL, assessed_by VARCHAR(180) NOT NULL, UNIQUE INDEX repo_advisory (repo_provider, repo_organization, repo_name, advisory_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE advisory_assessment'); + } +} diff --git a/src/Admin/AdvisoryCountSort.php b/src/Admin/AdvisoryCountSort.php index 5812d5e5..0d379c77 100644 --- a/src/Admin/AdvisoryCountSort.php +++ b/src/Admin/AdvisoryCountSort.php @@ -5,6 +5,8 @@ namespace App\Admin; use App\Entity\Advisory; +use App\Entity\AdvisoryAssessment; +use App\Entity\GitRepo; use Doctrine\ORM\QueryBuilder; use EasyCorp\Bundle\EasyAdminBundle\Config\Option\SortOrder; use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto; @@ -34,10 +36,14 @@ public static function apply(QueryBuilder $qb, SearchDto $searchDto): void // An advisory belongs to a single package and an installation holds a // single version of it, so the advisory cannot be counted twice. + // Advisories marked as not affected for the installation's git repo + // do not count, as in AdvisoryAssessor::openCount(). $qb->addSelect(sprintf( - '(SELECT COUNT(adv.id) FROM %s adv JOIN adv.packageVersions advPackageVersion JOIN advPackageVersion.installations advInstallation WHERE advInstallation.id = IDENTITY(%s.installation)) AS HIDDEN %s', + '(SELECT COUNT(adv.id) FROM %1$s adv JOIN adv.packageVersions advPackageVersion JOIN advPackageVersion.installations advInstallation WHERE advInstallation.id = IDENTITY(%2$s.installation) AND NOT EXISTS (SELECT advAssessment.id FROM %3$s advAssessment JOIN %4$s advRepo WITH advRepo.provider = advAssessment.repoProvider AND advRepo.organization = advAssessment.repoOrganization AND advRepo.repo = advAssessment.repoName JOIN advRepo.gitTags advTag JOIN advTag.installations advTagInstallation WHERE advTagInstallation.id = advInstallation.id AND advAssessment.advisoryId = adv.advisoryId)) AS HIDDEN %5$s', Advisory::class, $rootAlias, + AdvisoryAssessment::class, + GitRepo::class, self::PROPERTY )); diff --git a/src/Controller/Admin/AdvisoryAssessmentController.php b/src/Controller/Admin/AdvisoryAssessmentController.php new file mode 100644 index 00000000..3d6870d3 --- /dev/null +++ b/src/Controller/Admin/AdvisoryAssessmentController.php @@ -0,0 +1,79 @@ +getId(); + } + + #[AdminRoute(path: '/site/{id}/advisories/not-affected', name: 'site_advisories_not_affected', options: ['methods' => ['POST']])] + public function markNotAffected(#[MapEntity(id: 'id')] Site $site, Request $request): RedirectResponse + { + $this->denyUnlessTokenIsValid($site, $request); + + try { + $marked = $this->assessor->markNotAffected( + $site, + array_values(array_map(strval(...), $request->request->all('advisoryIds'))), + $request->request->getString('reason'), + (string) $this->getUser()?->getUserIdentifier(), + ); + $this->addFlash('success', sprintf( + 'Marked %d %s as not affected for all checkouts of %s.', + $marked, + 1 === $marked ? 'advisory' : 'advisories', + $this->assessor->gitRepo($site), + )); + } catch (\InvalidArgumentException $exception) { + $this->addFlash('danger', $exception->getMessage()); + } + + return $this->redirectToSite($site); + } + + #[AdminRoute(path: '/site/{id}/advisories/remove-mark', name: 'site_advisories_remove_mark', options: ['methods' => ['POST']])] + public function removeMark(#[MapEntity(id: 'id')] Site $site, Request $request): RedirectResponse + { + $this->denyUnlessTokenIsValid($site, $request); + + $advisoryId = $request->request->getString('advisoryId'); + $this->assessor->removeMark($site, $advisoryId); + $this->addFlash('success', sprintf('Removed the mark on %s, so it counts again.', $advisoryId)); + + return $this->redirectToSite($site); + } + + private function denyUnlessTokenIsValid(Site $site, Request $request): void + { + if (!$this->isCsrfTokenValid(self::csrfIntent($site), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + } + + private function redirectToSite(Site $site): RedirectResponse + { + return $this->redirectToRoute('admin_site_detail', ['entityId' => $site->getId()]); + } +} diff --git a/src/Controller/Admin/SiteCrudController.php b/src/Controller/Admin/SiteCrudController.php index 5c333cb4..8665aee4 100644 --- a/src/Controller/Admin/SiteCrudController.php +++ b/src/Controller/Admin/SiteCrudController.php @@ -16,6 +16,7 @@ use App\Form\Type\Admin\HostingProviderFilter; use App\Form\Type\Admin\SemverFilter; use App\Form\Type\Admin\ServerTypeFilter; +use App\Service\AdvisoryAssessor; use App\Trait\ExportCrudControllerTrait; use App\Trait\SemverSortableCrudControllerTrait; use Doctrine\ORM\QueryBuilder; @@ -43,8 +44,9 @@ class SiteCrudController extends AbstractCrudController createIndexQueryBuilder as private semverIndexQueryBuilder; } - public function __construct() - { + public function __construct( + private readonly AdvisoryAssessor $assessor, + ) { } public static function getEntityFqcn(): string @@ -71,7 +73,13 @@ public function configureActions(Actions $actions): Actions public function configureFields(string $pageName): iterable { yield DomainField::new('primaryDomain')->setColumns(12); - yield AdvisoryCountField::new('advisoryCount')->setLabel('Adv.')->setSortable(true); + // Advisories marked as not affected do not count. The detail page + // lists them all, with their marks. + yield AdvisoryCountField::new('advisoryCount')->setLabel('Adv.')->setSortable(true) + ->setTemplatePath('EasyAdminBundle/Fields/site_advisories.html.twig') + ->formatValue(Crud::PAGE_DETAIL === $pageName + ? fn (mixed $value, Site $site): array => ['gitRepo' => $this->assessor->gitRepo($site), 'advisories' => $this->assessor->advisories($site)] + : fn (mixed $value, Site $site): int => $this->assessor->openCount($site)); yield AssociationField::new('domains')->hideOnIndex(); yield SiteTypeField::new('type')->setLabel('Stack'); yield ConfigFilePathField::new('configFilePath')->setColumns(12)->hideOnIndex(); diff --git a/src/Entity/AdvisoryAssessment.php b/src/Entity/AdvisoryAssessment.php new file mode 100644 index 00000000..8ce0be40 --- /dev/null +++ b/src/Entity/AdvisoryAssessment.php @@ -0,0 +1,106 @@ +repoOrganization.'/'.$this->repoName.': '.$this->advisoryId; + } + + public function setGitRepo(GitRepo $gitRepo): self + { + $this->repoProvider = (string) $gitRepo->getProvider(); + $this->repoOrganization = (string) $gitRepo->getOrganization(); + $this->repoName = (string) $gitRepo->getRepo(); + + return $this; + } + + public function getRepoProvider(): string + { + return $this->repoProvider; + } + + public function getRepoOrganization(): string + { + return $this->repoOrganization; + } + + public function getRepoName(): string + { + return $this->repoName; + } + + public function getAdvisoryId(): string + { + return $this->advisoryId; + } + + public function setAdvisoryId(string $advisoryId): self + { + $this->advisoryId = $advisoryId; + + return $this; + } + + public function getReason(): string + { + return $this->reason; + } + + public function setReason(string $reason): self + { + $this->reason = $reason; + + return $this; + } + + public function getAssessedBy(): string + { + return $this->assessedBy; + } + + public function setAssessedBy(string $assessedBy): self + { + $this->assessedBy = $assessedBy; + + return $this; + } +} diff --git a/src/Repository/AdvisoryAssessmentRepository.php b/src/Repository/AdvisoryAssessmentRepository.php new file mode 100644 index 00000000..76cf0804 --- /dev/null +++ b/src/Repository/AdvisoryAssessmentRepository.php @@ -0,0 +1,45 @@ + + * + * @method AdvisoryAssessment|null find($id, $lockMode = null, $lockVersion = null) + * @method AdvisoryAssessment|null findOneBy(array $criteria, array $orderBy = null) + * @method AdvisoryAssessment[] findAll() + * @method AdvisoryAssessment[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class AdvisoryAssessmentRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, AdvisoryAssessment::class); + } + + /** + * The repo's assessments, keyed by advisory id. + * + * @return array + */ + public function findByGitRepo(GitRepo $gitRepo): array + { + $assessments = []; + foreach ($this->findBy([ + 'repoProvider' => $gitRepo->getProvider(), + 'repoOrganization' => $gitRepo->getOrganization(), + 'repoName' => $gitRepo->getRepo(), + ]) as $assessment) { + $assessments[$assessment->getAdvisoryId()] = $assessment; + } + + return $assessments; + } +} diff --git a/src/Repository/GitRepoRepository.php b/src/Repository/GitRepoRepository.php index d1dae337..eba1bb9e 100644 --- a/src/Repository/GitRepoRepository.php +++ b/src/Repository/GitRepoRepository.php @@ -4,8 +4,10 @@ namespace App\Repository; +use App\Entity\AdvisoryAssessment; use App\Entity\GitRepo; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\Query\Expr\Join; use Doctrine\Persistence\ManagerRegistry; use Symfony\Component\Uid\Ulid; @@ -25,7 +27,8 @@ public function __construct(ManagerRegistry $registry) } /** - * Repos reachable from any package-version advisory, with their advisory count. + * Repos reachable from any package-version advisory, with their count of + * advisories not marked as not affected for the repo. * * Path: GitRepo → GitTag → Installation → PackageVersion → Advisory. * @@ -40,6 +43,8 @@ public function findReposWithAdvisoryCount(): array ->innerJoin('gt.installations', 'i') ->innerJoin('i.packageVersions', 'pv') ->innerJoin('pv.advisories', 'a') + ->leftJoin(AdvisoryAssessment::class, 'aa', Join::WITH, 'aa.advisoryId = a.advisoryId AND aa.repoProvider = r.provider AND aa.repoOrganization = r.organization AND aa.repoName = r.repo') + ->andWhere('aa.id IS NULL') ->groupBy('r.id') ->having('COUNT(DISTINCT a.id) > 0') ->orderBy('r.organization', \SortDirection::Ascending) diff --git a/src/Service/AdvisoryAssessor.php b/src/Service/AdvisoryAssessor.php new file mode 100644 index 00000000..e9943b65 --- /dev/null +++ b/src/Service/AdvisoryAssessor.php @@ -0,0 +1,99 @@ +getInstallation()->getGitTag()?->getRepo(); + } + + /** + * The site's advisories, each with its assessment if it is marked. + * + * @return list + */ + public function advisories(Site $site): array + { + $gitRepo = $this->gitRepo($site); + $assessments = null === $gitRepo ? [] : $this->repository->findByGitRepo($gitRepo); + + return array_values(array_map( + static fn (Advisory $advisory): array => ['advisory' => $advisory, 'assessment' => $assessments[$advisory->getAdvisoryId()] ?? null], + $site->getAdvisories()->toArray(), + )); + } + + /** + * Advisories that are not marked as not affected. + */ + public function openCount(Site $site): int + { + return count(array_filter($this->advisories($site), static fn (array $row): bool => null === $row['assessment'])); + } + + /** + * Mark those of the advisories that are open on the site. + * + * @param list $advisoryIds + * + * @return int the number of advisories marked + * + * @throws \InvalidArgumentException when the site has no git repo or the reason is empty + */ + public function markNotAffected(Site $site, array $advisoryIds, string $reason, string $assessedBy): int + { + $gitRepo = $this->gitRepo($site) ?? throw new \InvalidArgumentException('No git repo linked, so advisories cannot be marked as not affected.'); + $reason = trim($reason); + if ('' === $reason) { + throw new \InvalidArgumentException('A reason is required.'); + } + + $marked = 0; + foreach ($this->advisories($site) as ['advisory' => $advisory, 'assessment' => $assessment]) { + if (null !== $assessment || !in_array($advisory->getAdvisoryId(), $advisoryIds, true)) { + continue; + } + $this->entityManager->persist(new AdvisoryAssessment() + ->setGitRepo($gitRepo) + ->setAdvisoryId($advisory->getAdvisoryId()) + ->setReason($reason) + ->setAssessedBy($assessedBy)); + ++$marked; + } + $this->entityManager->flush(); + + return $marked; + } + + public function removeMark(Site $site, string $advisoryId): void + { + $gitRepo = $this->gitRepo($site); + $assessment = null === $gitRepo ? null : ($this->repository->findByGitRepo($gitRepo)[$advisoryId] ?? null); + if (null !== $assessment) { + $this->entityManager->remove($assessment); + $this->entityManager->flush(); + } + } +} diff --git a/templates/EasyAdminBundle/Fields/site_advisories.html.twig b/templates/EasyAdminBundle/Fields/site_advisories.html.twig new file mode 100644 index 00000000..637bd435 --- /dev/null +++ b/templates/EasyAdminBundle/Fields/site_advisories.html.twig @@ -0,0 +1,78 @@ +{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #} +{# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #} +{# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #} +{% if ea().crud.currentAction == 'detail' %} + {% set git_repo = field.formattedValue.gitRepo %} + {% set advisories = field.formattedValue.advisories %} + {% set open = advisories|filter(row => row.assessment is null) %} + {% set site_id = entity.instance.id %} + {% set token = csrf_token('site_advisories_' ~ site_id) %} +
+ {% if git_repo %} +

Marks apply to all checkouts of {{ git_repo }}.

+ {% else %} +
No git repo linked, so advisories cannot be marked as not affected.
+ {% endif %} + {% if advisories|length > 0 %} + + + + + + + + + + + {% for row in advisories %} + {% set advisory = row.advisory %} + {% set assessment = row.assessment %} + {% set url = ea_url() + .unsetAll() + .setController('App\\Controller\\Admin\\AdvisoryCrudController') + .setAction('detail') + .setEntityId(advisory.id) %} + + + + + + + {% endfor %} + +
PackageCVETitleStatus
{{ advisory.package }}{{ advisory.cve ?? advisory.advisoryId }}{{ advisory.title }} + {% if assessment %} + Not affected + {{ assessment.reason }} + ({{ assessment.assessedBy }}, {{ assessment.createdAt|date('Y-m-d') }}) + {% if git_repo %} +
+ + + +
+ {% endif %} + {% elseif git_repo %} +
+ + + + +
+ {% endif %} +
+ {% endif %} + {% if git_repo and open|length > 0 %} +
+ + {% for row in open %} + + {% endfor %} + + +
+ {% endif %} +
+{% elseif field.formattedValue != 0 %} + {{ field.formattedValue }} +{% endif %} diff --git a/tests/Controller/Admin/AdvisoryAssessmentControllerTest.php b/tests/Controller/Admin/AdvisoryAssessmentControllerTest.php new file mode 100644 index 00000000..d44cd6a4 --- /dev/null +++ b/tests/Controller/Admin/AdvisoryAssessmentControllerTest.php @@ -0,0 +1,207 @@ +loggedInClient(); + + $crawler = $client->request('GET', $this->detailUrl('shop.example.com')); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorTextContains('.advisory-assessments', 'Marks apply to all checkouts of example-org/shop'); + $this->assertCount(2, $crawler->filter('form.advisory-mark'), 'One per open advisory'); + $this->assertSelectorExists('form.advisory-accept-all'); + } + + public function testMarkingAnAdvisoryAsNotAffected(): void + { + $client = $this->loggedInClient(); + $crawler = $client->request('GET', $this->detailUrl('shop.example.com')); + + $client->submit($this->markForm($crawler, self::TWIG), ['reason' => 'Templates are not user editable']); + + $this->assertResponseRedirects($this->detailUrl('shop.example.com')); + $crawler = $client->followRedirect(); + $this->assertSelectorTextContains('#flash-messages', 'Marked 1 advisory as not affected for all checkouts of example-org/shop'); + $row = $this->row($crawler, self::TWIG); + $this->assertStringContainsString('Not affected', $row->text()); + $this->assertStringContainsString('Templates are not user editable', $row->text()); + $this->assertStringContainsString('admin@example.com', $row->text()); + $this->assertCount(1, $row->filter('form.advisory-remove')); + + $assessment = $this->entityManager()->getRepository(AdvisoryAssessment::class)->findOneBy(['advisoryId' => self::TWIG]); + $this->assertSame('admin@example.com', $assessment?->getAssessedBy()); + } + + public function testAcceptingAllCurrentAdvisories(): void + { + $client = $this->loggedInClient(); + $crawler = $client->request('GET', $this->detailUrl('shop.example.com')); + + $client->submit($crawler->filter('form.advisory-accept-all')->form(), ['reason' => 'Reviewed with the client']); + + $crawler = $client->followRedirect(); + $this->assertSelectorTextContains('#flash-messages', 'Marked 2 advisories as not affected'); + $this->assertCount(0, $crawler->filter('form.advisory-mark')); + $this->assertCount(0, $crawler->filter('form.advisory-accept-all'), 'Nothing left to accept'); + } + + public function testAReasonIsRequired(): void + { + $client = $this->loggedInClient(); + $crawler = $client->request('GET', $this->detailUrl('shop.example.com')); + + $client->submit($this->markForm($crawler, self::TWIG), ['reason' => ' ']); + + $client->followRedirect(); + $this->assertSelectorTextContains('#flash-messages', 'A reason is required'); + $this->assertSame(0, $this->entityManager()->getRepository(AdvisoryAssessment::class)->count(['advisoryId' => self::TWIG])); + } + + public function testRemovingAMark(): void + { + $client = $this->loggedInClient(); + $crawler = $client->request('GET', $this->detailUrl('intranet.example.com')); + + $client->submit($this->row($crawler, 'PKSA-m3r8-5tyv-1h6p')->filter('form.advisory-remove')->form()); + + $crawler = $client->followRedirect(); + $this->assertSelectorTextContains('#flash-messages', 'Removed the mark'); + $this->assertCount(1, $this->row($crawler, 'PKSA-m3r8-5tyv-1h6p')->filter('form.advisory-mark')); + } + + public function testAnInvalidTokenIsRejected(): void + { + $client = $this->loggedInClient(); + $crawler = $client->request('GET', $this->detailUrl('shop.example.com')); + $form = $this->markForm($crawler, self::TWIG); + + $client->request('POST', $form->getUri(), ['advisoryIds' => [self::TWIG], 'reason' => 'Reviewed', '_token' => 'invalid']); + + $this->assertResponseStatusCodeSame(403); + } + + public function testASiteWithoutAGitRepoExplainsWhyItCannotBeMarked(): void + { + $client = $this->loggedInClient(); + + $crawler = $client->request('GET', $this->detailUrl('release.example.com')); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorTextContains('.advisory-assessments', 'No git repo linked'); + $this->assertCount(0, $crawler->filter('form.advisory-mark, form.advisory-accept-all')); + $this->assertGreaterThan(0, $crawler->filter('.advisory-assessments tbody tr')->count(), 'The advisories are still listed'); + } + + public function testTheIndexCountsOnlyOpenAdvisories(): void + { + $client = $this->loggedInClient(); + $crawler = $client->request('GET', $this->detailUrl('intranet.example.com')); + $this->assertSame('3', $this->indexBadge($client, 'intranet.example.com'), 'Four advisories, one marked in the fixtures'); + + // With all four marked, intranet has the most advisories but none open. + $client->submit($crawler->filter('form.advisory-accept-all')->form(), ['reason' => 'Reviewed']); + + $this->assertSame('', $this->indexBadge($client, 'intranet.example.com')); + $url = static::getContainer()->get(AdminUrlGenerator::class) + ->setController(SiteCrudController::class) + ->setAction(Crud::PAGE_INDEX) + ->set('sort', ['advisoryCount' => 'DESC']) + ->generateUrl(); + + $crawler = $client->request('GET', $url); + + $this->assertResponseIsSuccessful(); + $counts = $crawler->filter('tbody tr[data-id]')->each(static fn (Crawler $row): int => (int) ($row->filter(self::BADGE)->count() > 0 ? $row->filter(self::BADGE)->text() : 0)); + $sorted = $counts; + rsort($sorted); + $this->assertSame($sorted, $counts, 'Sorted by the open count'); + } + + public function testTheRepoAdvisoriesPageCountsOnlyOpenAdvisories(): void + { + $client = $this->loggedInClient(); + + $crawler = $client->request('GET', static::getContainer()->get('router')->generate('admin_repo_advisories')); + + $this->assertResponseIsSuccessful(); + $row = $crawler->filter('tr')->reduce(static fn (Crawler $row): bool => str_contains($row->text(), 'example-org/intranet')); + $this->assertSame('3', $row->filter('.badge-danger')->text()); + } + + private function loggedInClient(): KernelBrowser + { + $client = static::createClient(); + // Keep the rows written by one request for the next. + $client->disableReboot(); + $client->loginUser($this->entityManager()->getRepository(User::class)->findOneBy(['email' => 'admin@example.com'])); + + return $client; + } + + private function entityManager(): EntityManagerInterface + { + return static::getContainer()->get(EntityManagerInterface::class); + } + + private function detailUrl(string $primaryDomain): string + { + $site = $this->entityManager()->getRepository(Site::class)->findOneBy(['primaryDomain' => $primaryDomain]); + + return static::getContainer()->get(AdminUrlGenerator::class) + ->setController(SiteCrudController::class) + ->setAction(Crud::PAGE_DETAIL) + ->setEntityId($site?->getId()) + ->generateUrl(); + } + + private function row(Crawler $crawler, string $advisoryId): Crawler + { + return $crawler->filter('.advisory-assessments tbody tr[data-advisory-id="'.$advisoryId.'"]'); + } + + private function markForm(Crawler $crawler, string $advisoryId): Form + { + return $this->row($crawler, $advisoryId)->filter('form.advisory-mark')->form(); + } + + /** + * The site's advisory badge on the index page, or '' without one. + */ + private function indexBadge(KernelBrowser $client, string $primaryDomain): string + { + $url = static::getContainer()->get(AdminUrlGenerator::class) + ->setController(SiteCrudController::class) + ->setAction(Crud::PAGE_INDEX) + ->generateUrl(); + $badge = $client->request('GET', $url)->filter('tbody tr[data-id]') + ->reduce(static fn (Crawler $row): bool => str_contains($row->text(), $primaryDomain)) + ->filter(self::BADGE); + + return $badge->count() > 0 ? $badge->text() : ''; + } +} diff --git a/tests/DataFixtures/FixturesTest.php b/tests/DataFixtures/FixturesTest.php index 99b7f635..b2991513 100644 --- a/tests/DataFixtures/FixturesTest.php +++ b/tests/DataFixtures/FixturesTest.php @@ -5,6 +5,7 @@ namespace App\Tests\DataFixtures; use App\Entity\Advisory; +use App\Entity\AdvisoryAssessment; use App\Entity\CodeOwner; use App\Entity\DetectionResult; use App\Entity\DockerImage; @@ -199,6 +200,7 @@ public function testFixturesUseExampleDataOnly(): void ...array_map(static fn (User $user): string => (string) $user->getEmail(), $this->entityManager->getRepository(User::class)->findAll()), ...array_map(static fn (CodeOwner $owner): string => $owner->getEmail(), $this->entityManager->getRepository(CodeOwner::class)->findAll()), ...array_filter(array_map(static fn (SecurityContract $contract): ?string => $contract->getClientContactEmail(), $this->entityManager->getRepository(SecurityContract::class)->findAll())), + ...array_map(static fn (AdvisoryAssessment $assessment): string => $assessment->getAssessedBy(), $this->entityManager->getRepository(AdvisoryAssessment::class)->findAll()), ]; foreach ($emails as $email) { $this->assertMatchesRegularExpression('/@example\.com$/', $email); diff --git a/tests/Service/AdvisoryAssessorTest.php b/tests/Service/AdvisoryAssessorTest.php new file mode 100644 index 00000000..6aad741d --- /dev/null +++ b/tests/Service/AdvisoryAssessorTest.php @@ -0,0 +1,113 @@ +entityManager = self::getContainer()->get(EntityManagerInterface::class); + $this->assessor = self::getContainer()->get(AdvisoryAssessor::class); + } + + public function testAMarkAppliesToEveryCheckoutOfTheRepo(): void + { + $this->assertSame(2, $this->assessor->openCount($this->site('shop.example.com'))); + $this->assertSame(1, $this->assessor->openCount($this->site('stg.shop.example.com'))); + + $this->assessor->markNotAffected($this->site('shop.example.com'), [self::TWIG], 'Templates are not user editable', 'user@example.com'); + + // A new advisory, here http-foundation, still counts. + $this->assertSame(1, $this->assessor->openCount($this->site('shop.example.com'))); + $this->assertSame(0, $this->assessor->openCount($this->site('stg.shop.example.com'))); + } + + public function testOnlyTheSitesOpenAdvisoriesAreMarked(): void + { + $marked = $this->assessor->markNotAffected($this->site('stg.shop.example.com'), [self::TWIG, self::HTTP_FOUNDATION], 'Reviewed', 'user@example.com'); + + $this->assertSame(1, $marked); + $this->assertSame(1, $this->assessor->openCount($this->site('shop.example.com')), 'http-foundation is not on stg'); + } + + public function testAMarkRecordsTheReasonAndWhoMadeIt(): void + { + $this->assessor->markNotAffected($this->site('shop.example.com'), [self::TWIG], ' Templates are not user editable ', 'user@example.com'); + + $assessment = $this->entityManager->getRepository(AdvisoryAssessment::class)->findOneBy(['advisoryId' => self::TWIG]); + $this->assertSame(['github.com', 'example-org', 'shop'], [$assessment->getRepoProvider(), $assessment->getRepoOrganization(), $assessment->getRepoName()]); + $this->assertSame('Templates are not user editable', $assessment->getReason()); + $this->assertSame('user@example.com', $assessment->getAssessedBy()); + } + + public function testAReasonIsRequired(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->assessor->markNotAffected($this->site('shop.example.com'), [self::TWIG], ' ', 'user@example.com'); + } + + public function testASiteWithoutAGitRepoCannotBeMarked(): void + { + $site = $this->site('release.example.com'); + $this->assertNull($this->assessor->gitRepo($site)); + + $this->expectException(\InvalidArgumentException::class); + + $this->assessor->markNotAffected($site, ['SA-CORE-2099-001'], 'Reviewed', 'user@example.com'); + } + + public function testRemovingAMarkCountsTheAdvisoryAgain(): void + { + $site = $this->site('intranet.example.com'); + $this->assertSame(3, $this->assessor->openCount($site), 'The fixtures mark commonmark'); + + $this->assessor->removeMark($site, 'PKSA-m3r8-5tyv-1h6p'); + + $this->assertSame(4, $this->assessor->openCount($site)); + } + + public function testAdvisoriesListTheirMark(): void + { + $marks = []; + foreach ($this->assessor->advisories($this->site('intranet.example.com')) as ['advisory' => $advisory, 'assessment' => $assessment]) { + $marks[$advisory->getAdvisoryId()] = $assessment?->getReason(); + } + ksort($marks); + + $this->assertSame([ + 'PKSA-d8k2-6vnp-4j9t' => null, + 'PKSA-m3r8-5tyv-1h6p' => 'Markdown is only rendered from editor input', + 'SA-CONTRIB-2099-001' => null, + 'SA-CORE-2099-001' => null, + ], $marks); + } + + private function site(string $primaryDomain): Site + { + return $this->entityManager->getRepository(Site::class)->findOneBy(['primaryDomain' => $primaryDomain]); + } +} From 30c41d1cdddebbef9ccfe7ffe6cee09aa9c12e5b Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 21 Oct 2026 17:41:19 +0200 Subject: [PATCH 19/35] feat: ask for the not affected reason in a modal --- .../Fields/_advisory_reason_modal.html.twig | 15 +++++++++++++++ .../Fields/site_advisories.html.twig | 10 ++++------ 2 files changed, 19 insertions(+), 6 deletions(-) create mode 100644 templates/EasyAdminBundle/Fields/_advisory_reason_modal.html.twig diff --git a/templates/EasyAdminBundle/Fields/_advisory_reason_modal.html.twig b/templates/EasyAdminBundle/Fields/_advisory_reason_modal.html.twig new file mode 100644 index 00000000..f20c4f75 --- /dev/null +++ b/templates/EasyAdminBundle/Fields/_advisory_reason_modal.html.twig @@ -0,0 +1,15 @@ +{# A button opening a modal that asks for the reason and submits the enclosing form. #} +{{ label|default('Mark') }} + + + {# Blocks do not see the include's variables, so the label wraps the field instead of naming its id. #} + + + + Cancel + Mark as not affected + + diff --git a/templates/EasyAdminBundle/Fields/site_advisories.html.twig b/templates/EasyAdminBundle/Fields/site_advisories.html.twig index 637bd435..ed5b0ad2 100644 --- a/templates/EasyAdminBundle/Fields/site_advisories.html.twig +++ b/templates/EasyAdminBundle/Fields/site_advisories.html.twig @@ -49,11 +49,10 @@ {% endif %} {% elseif git_repo %} -
+ - - + {{ include('EasyAdminBundle/Fields/_advisory_reason_modal.html.twig', {id: 'advisory-mark-' ~ loop.index, title: 'Mark ' ~ (advisory.cve ?? advisory.advisoryId) ~ ' as not affected?'}) }}
{% endif %} @@ -63,13 +62,12 @@ {% endif %} {% if git_repo and open|length > 0 %} -
+ {% for row in open %} {% endfor %} - - + {{ include('EasyAdminBundle/Fields/_advisory_reason_modal.html.twig', {id: 'advisory-accept-all', title: 'Mark all ' ~ open|length ~ ' open advisories as not affected?', label: 'Mark all'}) }}
{% endif %} From 9daac1ce8d4d66d54e2231d868245f9fbbccb966 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 21 Oct 2026 17:47:15 +0200 Subject: [PATCH 20/35] fix: submit advisory marks once and report repeat marks --- assets/admin.js | 10 ++++++++++ .../Admin/AdvisoryAssessmentController.php | 16 ++++++++++------ .../Fields/site_advisories.html.twig | 6 +++--- .../Admin/AdvisoryAssessmentControllerTest.php | 14 ++++++++++++++ 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/assets/admin.js b/assets/admin.js index 7bd14d4e..5f1ac6c2 100644 --- a/assets/admin.js +++ b/assets/admin.js @@ -21,6 +21,16 @@ const tomSelectHandler = () => { window.addEventListener("DOMContentLoaded", tomSelectHandler); document.addEventListener("ea.collection.item-added", tomSelectHandler); +// Disable the buttons of a [data-submit-once] form once it submits, so a +// slow page load does not invite a second click. +document.addEventListener("submit", (event) => { + if (event.target.matches("[data-submit-once]")) { + event.target + .querySelectorAll("button") + .forEach((button) => (button.disabled = true)); + } +}); + // Copy the text of a [data-copy-text] button to the clipboard, and show a // check mark for a moment, like GitHub's copy buttons. document.addEventListener("click", async (event) => { diff --git a/src/Controller/Admin/AdvisoryAssessmentController.php b/src/Controller/Admin/AdvisoryAssessmentController.php index 3d6870d3..01f2ca77 100644 --- a/src/Controller/Admin/AdvisoryAssessmentController.php +++ b/src/Controller/Admin/AdvisoryAssessmentController.php @@ -40,12 +40,16 @@ public function markNotAffected(#[MapEntity(id: 'id')] Site $site, Request $requ $request->request->getString('reason'), (string) $this->getUser()?->getUserIdentifier(), ); - $this->addFlash('success', sprintf( - 'Marked %d %s as not affected for all checkouts of %s.', - $marked, - 1 === $marked ? 'advisory' : 'advisories', - $this->assessor->gitRepo($site), - )); + if (0 === $marked) { + $this->addFlash('info', 'These advisories are already marked as not affected.'); + } else { + $this->addFlash('success', sprintf( + 'Marked %d %s as not affected for all checkouts of %s.', + $marked, + 1 === $marked ? 'advisory' : 'advisories', + $this->assessor->gitRepo($site), + )); + } } catch (\InvalidArgumentException $exception) { $this->addFlash('danger', $exception->getMessage()); } diff --git a/templates/EasyAdminBundle/Fields/site_advisories.html.twig b/templates/EasyAdminBundle/Fields/site_advisories.html.twig index ed5b0ad2..09de2456 100644 --- a/templates/EasyAdminBundle/Fields/site_advisories.html.twig +++ b/templates/EasyAdminBundle/Fields/site_advisories.html.twig @@ -42,14 +42,14 @@ {{ assessment.reason }} ({{ assessment.assessedBy }}, {{ assessment.createdAt|date('Y-m-d') }}) {% if git_repo %} -
+
{% endif %} {% elseif git_repo %} -
+ {{ include('EasyAdminBundle/Fields/_advisory_reason_modal.html.twig', {id: 'advisory-mark-' ~ loop.index, title: 'Mark ' ~ (advisory.cve ?? advisory.advisoryId) ~ ' as not affected?'}) }} @@ -62,7 +62,7 @@ {% endif %} {% if git_repo and open|length > 0 %} - + {% for row in open %} diff --git a/tests/Controller/Admin/AdvisoryAssessmentControllerTest.php b/tests/Controller/Admin/AdvisoryAssessmentControllerTest.php index d44cd6a4..ec7185ee 100644 --- a/tests/Controller/Admin/AdvisoryAssessmentControllerTest.php +++ b/tests/Controller/Admin/AdvisoryAssessmentControllerTest.php @@ -57,6 +57,20 @@ public function testMarkingAnAdvisoryAsNotAffected(): void $this->assertSame('admin@example.com', $assessment?->getAssessedBy()); } + public function testResubmittingAMarkSaysItIsAlreadyMarked(): void + { + $client = $this->loggedInClient(); + $crawler = $client->request('GET', $this->detailUrl('shop.example.com')); + $form = $this->markForm($crawler, self::TWIG); + $client->submit($form, ['reason' => 'Reviewed']); + + $client->submit($form, ['reason' => 'Reviewed']); + + $client->followRedirect(); + $this->assertSelectorTextContains('#flash-messages', 'already marked as not affected'); + $this->assertSelectorTextNotContains('#flash-messages', 'Marked 0'); + } + public function testAcceptingAllCurrentAdvisories(): void { $client = $this->loggedInClient(); From 7ac2d7c484e9ecc0cd763eeb92c5f2e0522f19c7 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 21 Oct 2026 18:42:43 +0200 Subject: [PATCH 21/35] style: show the missing git repo as a badge --- templates/EasyAdminBundle/Fields/site_advisories.html.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/EasyAdminBundle/Fields/site_advisories.html.twig b/templates/EasyAdminBundle/Fields/site_advisories.html.twig index 09de2456..42f7b405 100644 --- a/templates/EasyAdminBundle/Fields/site_advisories.html.twig +++ b/templates/EasyAdminBundle/Fields/site_advisories.html.twig @@ -11,7 +11,7 @@ {% if git_repo %}

Marks apply to all checkouts of {{ git_repo }}.

{% else %} -
No git repo linked, so advisories cannot be marked as not affected.
+

No git repo linked Advisories cannot be marked as not affected.

{% endif %} {% if advisories|length > 0 %} From e70fcaa60e1a37d1b7fea5ade7935ea4d8819195 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 21 Oct 2026 18:56:09 +0200 Subject: [PATCH 22/35] style: move the mark details under the advisory title --- .../Fields/site_advisories.html.twig | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/templates/EasyAdminBundle/Fields/site_advisories.html.twig b/templates/EasyAdminBundle/Fields/site_advisories.html.twig index 42f7b405..2d54fc08 100644 --- a/templates/EasyAdminBundle/Fields/site_advisories.html.twig +++ b/templates/EasyAdminBundle/Fields/site_advisories.html.twig @@ -34,18 +34,21 @@ .setEntityId(advisory.id) %} - - + + - + @@ -38,12 +38,11 @@ + - - - + + +
{{ advisory.package }}{{ advisory.cve ?? advisory.advisoryId }}{{ advisory.title }}{{ advisory.cve ?? advisory.advisoryId }} + {{ advisory.title }} + {% if assessment %} +
{{ assessment.reason }} ({{ assessment.assessedBy }}, {{ assessment.createdAt|date('Y-m-d') }})
+ {% endif %} +
{% if assessment %} Not affected - {{ assessment.reason }} - ({{ assessment.assessedBy }}, {{ assessment.createdAt|date('Y-m-d') }}) {% if git_repo %} - + Unmark {% endif %} {% elseif git_repo %} From dc1502e9843a0c2b0682d13c752067ecaac5eadd Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 21 Oct 2026 18:57:55 +0200 Subject: [PATCH 23/35] style: put the not affected pill on the mark details line --- templates/EasyAdminBundle/Fields/site_advisories.html.twig | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/templates/EasyAdminBundle/Fields/site_advisories.html.twig b/templates/EasyAdminBundle/Fields/site_advisories.html.twig index 2d54fc08..40f24f5f 100644 --- a/templates/EasyAdminBundle/Fields/site_advisories.html.twig +++ b/templates/EasyAdminBundle/Fields/site_advisories.html.twig @@ -20,7 +20,7 @@ Package CVE TitleStatusAction
{{ advisory.title }} {% if assessment %} -
{{ assessment.reason }} ({{ assessment.assessedBy }}, {{ assessment.createdAt|date('Y-m-d') }})
+
Not affected {{ assessment.reason }} ({{ assessment.assessedBy }}, {{ assessment.createdAt|date('Y-m-d') }})
{% endif %}
{% if assessment %} - Not affected {% if git_repo %}
From d9a8428215a8cf107bd52e79a037b2ac3205d07f Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 21 Oct 2026 19:07:18 +0200 Subject: [PATCH 24/35] style: space out the mark details line --- templates/EasyAdminBundle/Fields/site_advisories.html.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/EasyAdminBundle/Fields/site_advisories.html.twig b/templates/EasyAdminBundle/Fields/site_advisories.html.twig index 40f24f5f..2b0fa4df 100644 --- a/templates/EasyAdminBundle/Fields/site_advisories.html.twig +++ b/templates/EasyAdminBundle/Fields/site_advisories.html.twig @@ -38,7 +38,7 @@
{{ advisory.title }} {% if assessment %} -
Not affected {{ assessment.reason }} ({{ assessment.assessedBy }}, {{ assessment.createdAt|date('Y-m-d') }})
+
Not affected{{ assessment.reason }} ({{ assessment.assessedBy }}, {{ assessment.createdAt|date('Y-m-d') }})
{% endif %}
From 1776586457bc1c579e78235e6723fb62c50cb67f Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 21 Oct 2026 19:08:06 +0200 Subject: [PATCH 25/35] style: fade the mark details line --- templates/EasyAdminBundle/Fields/site_advisories.html.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/EasyAdminBundle/Fields/site_advisories.html.twig b/templates/EasyAdminBundle/Fields/site_advisories.html.twig index 2b0fa4df..38ff7393 100644 --- a/templates/EasyAdminBundle/Fields/site_advisories.html.twig +++ b/templates/EasyAdminBundle/Fields/site_advisories.html.twig @@ -38,7 +38,7 @@ {{ advisory.title }} {% if assessment %} -
Not affected{{ assessment.reason }} ({{ assessment.assessedBy }}, {{ assessment.createdAt|date('Y-m-d') }})
+
Not affected{{ assessment.reason }} ({{ assessment.assessedBy }}, {{ assessment.createdAt|date('Y-m-d') }})
{% endif %}
From 34517cf38e2cfcfe2c7c9726087085579fa6db30 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 21 Oct 2026 19:08:59 +0200 Subject: [PATCH 26/35] style: fade the whole row of a marked advisory --- templates/EasyAdminBundle/Fields/site_advisories.html.twig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/EasyAdminBundle/Fields/site_advisories.html.twig b/templates/EasyAdminBundle/Fields/site_advisories.html.twig index 38ff7393..753823d3 100644 --- a/templates/EasyAdminBundle/Fields/site_advisories.html.twig +++ b/templates/EasyAdminBundle/Fields/site_advisories.html.twig @@ -32,13 +32,13 @@ .setController('App\\Controller\\Admin\\AdvisoryCrudController') .setAction('detail') .setEntityId(advisory.id) %} -
{{ advisory.package }} {{ advisory.cve ?? advisory.advisoryId }} {{ advisory.title }} {% if assessment %} -
Not affected{{ assessment.reason }} ({{ assessment.assessedBy }}, {{ assessment.createdAt|date('Y-m-d') }})
+
Not affected{{ assessment.reason }} ({{ assessment.assessedBy }}, {{ assessment.createdAt|date('Y-m-d') }})
{% endif %}
From 8fbcce81f27e5b38460e240f811e0e43c071aaca Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 21 Oct 2026 19:09:36 +0200 Subject: [PATCH 27/35] style: keep the unmark button out of the fade --- .../EasyAdminBundle/Fields/site_advisories.html.twig | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/templates/EasyAdminBundle/Fields/site_advisories.html.twig b/templates/EasyAdminBundle/Fields/site_advisories.html.twig index 753823d3..d6b8cdb7 100644 --- a/templates/EasyAdminBundle/Fields/site_advisories.html.twig +++ b/templates/EasyAdminBundle/Fields/site_advisories.html.twig @@ -32,10 +32,12 @@ .setController('App\\Controller\\Admin\\AdvisoryCrudController') .setAction('detail') .setEntityId(advisory.id) %} -
{{ advisory.package }}{{ advisory.cve ?? advisory.advisoryId }} + {# Fades the cells, not the row, so the Unmark button stays at full strength. #} + {% set faded = assessment ? 'text-body-secondary opacity-50' %} +
{{ advisory.package }}{{ advisory.cve ?? advisory.advisoryId }} {{ advisory.title }} {% if assessment %}
Not affected{{ assessment.reason }} ({{ assessment.assessedBy }}, {{ assessment.createdAt|date('Y-m-d') }})
From 0bb2405f4d4e3d2af7729221654b47fc469d1fe9 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 21 Oct 2026 19:20:16 +0200 Subject: [PATCH 28/35] style: space the reason field from the modal description --- .../EasyAdminBundle/Fields/_advisory_reason_modal.html.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/EasyAdminBundle/Fields/_advisory_reason_modal.html.twig b/templates/EasyAdminBundle/Fields/_advisory_reason_modal.html.twig index f20c4f75..7437ead2 100644 --- a/templates/EasyAdminBundle/Fields/_advisory_reason_modal.html.twig +++ b/templates/EasyAdminBundle/Fields/_advisory_reason_modal.html.twig @@ -3,7 +3,7 @@ {# Blocks do not see the include's variables, so the label wraps the field instead of naming its id. #} -