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/CHANGELOG.md b/CHANGELOG.md index f52e3d0d..d18583f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.19.0] - 2026-10-26 + +- [#170](https://github.com/itk-dev/devops_itksites/pull/170) + Update the docker compose files from the itkdev-docker templates, which + use the maintained `nginx/nginx-unprivileged` image +- [#169](https://github.com/itk-dev/devops_itksites/pull/169) + Mark advisories as not affected per git repo, so they stop counting towards + a site's advisories, and sort sites by their open advisories +- [#168](https://github.com/itk-dev/devops_itksites/pull/168) + 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 + 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 +- [#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 + ## [1.18.0] - 2026-10-19 - [#162](https://github.com/itk-dev/devops_itksites/pull/162) @@ -439,7 +464,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.0] - 2022-09-15 -[Unreleased]: https://github.com/itk-dev/devops_itksites/compare/1.18.0...HEAD +[Unreleased]: https://github.com/itk-dev/devops_itksites/compare/1.19.0...HEAD +[1.19.0]: https://github.com/itk-dev/devops_itksites/compare/1.18.0...1.19.0 [1.18.0]: https://github.com/itk-dev/devops_itksites/compare/1.17.0...1.18.0 [1.17.0]: https://github.com/itk-dev/devops_itksites/compare/1.16.0...1.17.0 [1.16.0]: https://github.com/itk-dev/devops_itksites/compare/1.15.1...1.16.0 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/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/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/composer.json b/composer.json index 5df317da..053673da 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", @@ -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_default -vvv" ], "tests": [ "bin/console --env=test doctrine:database:drop --if-exists --force --quiet", diff --git a/composer.lock b/composer.lock index d51f730d..5fbffc39 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "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", - "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", @@ -1421,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": { @@ -1483,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", @@ -1519,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": [ { @@ -1527,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", @@ -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/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 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. diff --git a/docker-compose.override.yml b/docker-compose.override.yml index d7ed0471..2044e97f 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -1,4 +1,11 @@ services: + nginx: + labels: + # Detailed health check protection. /health/live and /health/ready stay + # public; only /health/detail discloses internals. + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-health.rule=Host(`${COMPOSE_DOMAIN:?}`) && PathPrefix(`/health/detail`)" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-health.middlewares=ITKBasicAuth@file" + # A mock identity provider, so the OpenID Connect login can be exercised locally. # The real one has no redirect URI registered for a developer machine. # diff --git a/docker-compose.server.override.yml b/docker-compose.server.override.yml index 8eb7ce73..86717971 100644 --- a/docker-compose.server.override.yml +++ b/docker-compose.server.override.yml @@ -1,4 +1,12 @@ services: + nginx: + labels: + # Detailed health check protection. /health/live and /health/ready stay + # public; only /health/detail discloses internals. + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-health.rule=Host(`${COMPOSE_SERVER_DOMAIN:?}`) && PathPrefix(`/health/detail`)" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-health.entrypoints=websecure" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-health.middlewares=ITKBasicAuth@file" + rabbit: image: rabbitmq:4-management-alpine restart: unless-stopped @@ -24,7 +32,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 async scheduler_default - APP_SUPERVISOR_WORKERS=1 - APP_SUPERVISOR_USER=deploy - PHP_MEMORY_LIMIT=256M diff --git a/docker-compose.server.yml b/docker-compose.server.yml index 45f2720c..4ecac634 100644 --- a/docker-compose.server.yml +++ b/docker-compose.server.yml @@ -21,7 +21,7 @@ services: - .:/app nginx: - image: nginxinc/nginx-unprivileged:alpine + image: nginx/nginx-unprivileged:alpine restart: unless-stopped networks: - app @@ -50,8 +50,3 @@ services: # Cron-metrics protection. - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.rule=Host(`${COMPOSE_SERVER_DOMAIN:?}`) && PathPrefix(`/cron-metrics`) " - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.middlewares=ITKMetricsAuth@file" - # Detailed health check protection. /health/live and /health/ready stay - # public; only /health/detail discloses internals. - - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-health.rule=Host(`${COMPOSE_SERVER_DOMAIN:?}`) && PathPrefix(`/health/detail`)" - - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-health.entrypoints=websecure" - - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-health.middlewares=ITKBasicAuth@file" diff --git a/docker-compose.yml b/docker-compose.yml index 444ce64d..e30ba146 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,7 +48,7 @@ services: - .:/app nginx: - image: nginxinc/nginx-unprivileged:alpine + image: nginx/nginx-unprivileged:alpine networks: - app - frontend @@ -75,10 +75,6 @@ services: # Cron-metrics protection. - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.rule=Host(`${COMPOSE_DOMAIN:?}`) && PathPrefix(`/cron-metrics`) " - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.middlewares=ITKMetricsAuth@file" - # Detailed health check protection. /health/live and /health/ready stay - # public; only /health/detail discloses internals. - - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-health.rule=Host(`${COMPOSE_DOMAIN:?}`) && PathPrefix(`/health/detail`)" - - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-health.middlewares=ITKBasicAuth@file" mail: image: axllent/mailpit @@ -103,7 +99,7 @@ services: - ./:/md prettier: - # Prettier does not (yet, fcf. + # Prettier does not (yet, cf. # https://github.com/prettier/prettier/issues/15206) have an official # docker image. # https://hub.docker.com/r/jauderho/prettier is good candidate (cf. https://hub.docker.com/search?q=prettier&sort=updated_at&order=desc) 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..8a589cf1 --- /dev/null +++ b/fixtures/package.yaml @@ -0,0 +1,197 @@ +# 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. +# - example-org/intranet has the commonmark advisory marked as not affected. + +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" + +# 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" + 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/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/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/Admin/AdvisoryCountSort.php b/src/Admin/AdvisoryCountSort.php index 3dee35b3..b4692443 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; @@ -15,32 +17,66 @@ final class AdvisoryCountSort /** * Order the index by Site::getAdvisoryCount(), which is computed in PHP and - * so has no column to sort on. EasyAdmin drops the sort before it reaches - * the query — isValidCustomSort() only accepts mapped fields and - * associations — so read it off the SearchDto and order by a scalar sub - * query instead. Intended for use from a CRUD controller's - * createIndexQueryBuilder() after the parent has built the QB. + * so has no column to sort on. EasyAdmin drops the sort from the URL before + * it reaches the query — isValidCustomSort() only accepts mapped fields and + * associations — and orders by a non-existent entity.advisoryCount column + * when it is the default sort. So read it off the SearchDto, drop that + * column, and order by a scalar sub query instead. Intended for use from a + * CRUD controller's createIndexQueryBuilder() after the parent has built + * the QB. */ public static function apply(QueryBuilder $qb, SearchDto $searchDto): void { + $rootAlias = (string) current($qb->getRootAliases()); + self::removeColumnOrder($qb, $rootAlias); + // The property name is ours, but the direction comes from the URL and // reaches DQL as a string, so it is whitelisted rather than trusted. - $direction = strtoupper((string) ($searchDto->getCustomSort()[self::PROPERTY] ?? '')); + $direction = strtoupper((string) ($searchDto->getSort()[self::PROPERTY] ?? '')); if (SortOrder::ASC !== $direction && SortOrder::DESC !== $direction) { return; } - $rootAlias = (string) current($qb->getRootAliases()); + $qb->addSelect(sprintf('(%s) AS HIDDEN %s', self::subQuery($rootAlias), self::PROPERTY)); + $qb->addOrderBy(self::PROPERTY, SortOrder::DESC === $direction ? \SortDirection::Descending : \SortDirection::Ascending); + } - // An advisory belongs to a single package and an installation holds a - // single version of it, so the advisory cannot be counted twice. - $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', + /** + * DQL counting the open advisories of the site aliased $siteAlias. + * + * 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(). + */ + public static function subQuery(string $siteAlias): string + { + return sprintf( + '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)', Advisory::class, - $rootAlias, - self::PROPERTY - )); + $siteAlias, + AdvisoryAssessment::class, + GitRepo::class, + ); + } + + private static function removeColumnOrder(QueryBuilder $qb, string $rootAlias): void + { + $column = $rootAlias.'.'.self::PROPERTY.' '; + $parts = []; + foreach ($qb->getDQLPart('orderBy') as $orderBy) { + foreach ($orderBy->getParts() as $part) { + if (!str_starts_with($part, $column)) { + $parts[] = $part; + } + } + } - $qb->addOrderBy(self::PROPERTY, $direction); + // A part reads " ", and addOrderBy() wants them apart. + $qb->resetDQLPart('orderBy'); + foreach ($parts as $part) { + $at = (int) strrpos($part, ' '); + $qb->addOrderBy(substr($part, 0, $at), substr($part, $at + 1)); + } } } 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/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/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/Controller/Admin/AdvisoryAssessmentController.php b/src/Controller/Admin/AdvisoryAssessmentController.php new file mode 100644 index 00000000..01f2ca77 --- /dev/null +++ b/src/Controller/Admin/AdvisoryAssessmentController.php @@ -0,0 +1,83 @@ +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(), + ); + 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()); + } + + 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/DashboardController.php b/src/Controller/Admin/DashboardController.php index 2a107170..2b172c90 100644 --- a/src/Controller/Admin/DashboardController.php +++ b/src/Controller/Admin/DashboardController.php @@ -5,6 +5,7 @@ namespace App\Controller\Admin; use App\Repository\AdvisoryRepository; +use App\Repository\SiteRepository; use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminDashboard; use EasyCorp\Bundle\EasyAdminBundle\Config\Assets; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; @@ -22,6 +23,7 @@ class DashboardController extends AbstractDashboardController public function __construct( private readonly AdminUrlGenerator $adminUrlGenerator, private readonly AdvisoryRepository $advisoryRepository, + private readonly SiteRepository $siteRepository, ) { } @@ -57,7 +59,7 @@ public function configureMenuItems(): iterable yield MenuItem::linkToDashboard('Dashboard', 'fa fa-home'); yield MenuItem::linkTo(ServerCrudController::class, 'Servers', 'fas fa-server'); yield MenuItem::linkTo(InstallationCrudController::class, 'Installations', 'fas fa-folder'); - yield MenuItem::linkTo(SiteCrudController::class, 'Sites', 'fas fa-bookmark'); + yield MenuItem::linkTo(SiteCrudController::class, 'Sites', 'fas fa-bookmark')->setBadge($this->siteRepository->countWithOpenAdvisories() ?: null, 'danger'); yield MenuItem::linkTo(DomainCrudController::class, 'Domains', 'fas fa-link'); // OIDC and Service certificates are deprecated. The controllers and // entities are kept so existing rows stay reachable by URL, but the diff --git a/src/Controller/Admin/SiteCrudController.php b/src/Controller/Admin/SiteCrudController.php index 5c333cb4..79c8165e 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 @@ -55,7 +57,9 @@ public static function getEntityFqcn(): string #[\Override] public function configureCrud(Crud $crud): Crud { - return $crud->showEntityActionsInlined(); + return $crud + ->showEntityActionsInlined() + ->setDefaultSort([AdvisoryCountSort::PROPERTY => 'DESC']); } #[\Override] @@ -71,7 +75,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/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/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/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/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/GitRepoRepository.php b/src/Repository/GitRepoRepository.php index 58384d00..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,10 +43,12 @@ 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', '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(); diff --git a/src/Repository/SiteRepository.php b/src/Repository/SiteRepository.php index d4df5296..725f0865 100644 --- a/src/Repository/SiteRepository.php +++ b/src/Repository/SiteRepository.php @@ -4,7 +4,7 @@ namespace App\Repository; -use App\Entity\Server; +use App\Admin\AdvisoryCountSort; use App\Entity\Site; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\AbstractQuery; @@ -25,18 +25,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. */ @@ -49,4 +37,16 @@ public function getPrimaryDomains(): array ->getQuery() ->getResult(AbstractQuery::HYDRATE_SCALAR_COLUMN); } + + /** + * The number of sites with advisories not marked as not affected. + */ + public function countWithOpenAdvisories(): int + { + return (int) $this->createQueryBuilder('s') + ->select('COUNT(s.id)') + ->where(sprintf('(%s) > 0', AdvisoryCountSort::subQuery('s'))) + ->getQuery() + ->getSingleScalarResult(); + } } 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/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/_advisory_reason_modal.html.twig b/templates/EasyAdminBundle/Fields/_advisory_reason_modal.html.twig new file mode 100644 index 00000000..58d7b101 --- /dev/null +++ b/templates/EasyAdminBundle/Fields/_advisory_reason_modal.html.twig @@ -0,0 +1,16 @@ +{# A button opening a modal that asks for the reason and submits the enclosing form. #} +{{ label|default('Mark') }} +{# text-wrap: the modal sits in a text-nowrap table cell and would inherit it. #} + + + {# 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 new file mode 100644 index 00000000..d6b8cdb7 --- /dev/null +++ b/templates/EasyAdminBundle/Fields/site_advisories.html.twig @@ -0,0 +1,80 @@ +{# @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 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) %} + {# Fades the cells, not the row, so the Unmark button stays at full strength. #} + {% set faded = assessment ? 'text-body-secondary opacity-50' %} + + + + + + + {% endfor %} + +
PackageCVETitleAction
{{ advisory.package }}{{ advisory.cve ?? advisory.advisoryId }} + {{ advisory.title }} + {% if assessment %} +
Not affected{{ assessment.reason }} ({{ assessment.assessedBy }}, {{ assessment.createdAt|date('Y-m-d') }})
+ {% endif %} +
+ {% if assessment %} + {% if git_repo %} +
+ + + Unmark +
+ {% 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 %} +
+ {% 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 %} +
+{% elseif field.formattedValue != 0 %} + {{ field.formattedValue }} +{% endif %} diff --git a/tests/Admin/AdvisoryCountSortTest.php b/tests/Admin/AdvisoryCountSortTest.php index 3753e8d9..5eaa319d 100644 --- a/tests/Admin/AdvisoryCountSortTest.php +++ b/tests/Admin/AdvisoryCountSortTest.php @@ -28,6 +28,33 @@ public function testItOrdersByTheCountSubQuery(): void $this->assertNotEmpty($qb->getQuery()->getSQL()); } + /** + * As the default sort, EasyAdmin orders by the column, which does not + * exist, so the sub query has to replace it. + */ + public function testItReplacesTheColumnOrderOfTheDefaultSort(): void + { + $qb = $this->queryBuilder() + ->addOrderBy('entity.primaryDomain', 'ASC') + ->addOrderBy('entity.advisoryCount', 'DESC'); + + AdvisoryCountSort::apply($qb, $this->searchDto([], ['advisoryCount' => 'DESC'])); + + $dql = $qb->getQuery()->getDQL(); + $this->assertStringContainsString('ORDER BY entity.primaryDomain ASC, advisoryCount DESC', $dql); + $this->assertStringNotContainsString('entity.advisoryCount', $dql); + $this->assertNotEmpty($qb->getQuery()->getSQL()); + } + + public function testTheUrlSortWinsOverTheDefaultSort(): void + { + $qb = $this->queryBuilder()->addOrderBy('entity.advisoryCount', 'DESC'); + + AdvisoryCountSort::apply($qb, $this->searchDto(['advisoryCount' => 'ASC'], ['advisoryCount' => 'DESC'])); + + $this->assertStringContainsString('ORDER BY advisoryCount ASC', $qb->getQuery()->getDQL()); + } + /** * The direction reaches DQL as a string, so anything but ASC or DESC is * ignored rather than passed through. @@ -58,8 +85,8 @@ private function queryBuilder(): QueryBuilder ->createQueryBuilder('entity'); } - private function searchDto(array $customSort): SearchDto + private function searchDto(array $customSort, array $defaultSort = []): SearchDto { - return new SearchDto(new Request(), null, null, [], $customSort, null); + return new SearchDto(new Request(), null, null, $defaultSort, $customSort, null); } } 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 2d226659..7a6c99e7 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 { @@ -35,12 +35,17 @@ 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(); 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); @@ -64,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/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/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; + } +} diff --git a/tests/Controller/Admin/AdminSmokeTest.php b/tests/Controller/Admin/AdminSmokeTest.php index c7dcd6f2..04760862 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'); @@ -208,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/Controller/Admin/AdvisoryAssessmentControllerTest.php b/tests/Controller/Admin/AdvisoryAssessmentControllerTest.php new file mode 100644 index 00000000..b32c9426 --- /dev/null +++ b/tests/Controller/Admin/AdvisoryAssessmentControllerTest.php @@ -0,0 +1,255 @@ +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 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(); + $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 testTheIndexSortsByTheOpenCountByDefault(): void + { + $client = $this->loggedInClient(); + $url = static::getContainer()->get(AdminUrlGenerator::class) + ->setController(SiteCrudController::class) + ->setAction(Crud::PAGE_INDEX) + ->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); + $this->assertGreaterThan(0, $counts[0], 'A site with open advisories comes first'); + } + + public function testTheSitesMenuItemCountsSitesWithOpenAdvisories(): void + { + $client = $this->loggedInClient(); + $url = static::getContainer()->get(AdminUrlGenerator::class) + ->setController(SiteCrudController::class) + ->setAction(Crud::PAGE_INDEX) + ->generateUrl(); + $crawler = $client->request('GET', $url); + $withOpen = $crawler->filter('tbody tr[data-id]')->reduce(static fn (Crawler $row): bool => $row->filter(self::BADGE)->count() > 0)->count(); + + $badge = $crawler->filter('a.ea-sidebar-item-link') + ->reduce(static fn (Crawler $link): bool => 'Sites' === $link->filter('.ea-sidebar-item-label')->text()) + ->filter('.ea-sidebar-badge.badge-danger'); + $this->assertSame((string) $withOpen, $badge->text()); + } + + 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 new file mode 100644 index 00000000..b2991513 --- /dev/null +++ b/tests/DataFixtures/FixturesTest.php @@ -0,0 +1,291 @@ +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())), + ...array_map(static fn (AdvisoryAssessment $assessment): string => $assessment->getAssessedBy(), $this->entityManager->getRepository(AdvisoryAssessment::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 new file mode 100644 index 00000000..bd15c665 --- /dev/null +++ b/tests/Handler/DirectoryHandlerTest.php @@ -0,0 +1,150 @@ +entityManager = self::getContainer()->get(EntityManagerInterface::class); + // 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 + { + $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]); + } +} 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()); + } } 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'))); - } -} 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]); + } +} 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()); } /**