├── .editorconfig ├── .gitignore ├── .php-version ├── LICENSE ├── README.md ├── bitbucket-pipelines.yml ├── composer.json ├── docs ├── 00_homepage.png ├── 01_project_overview.png ├── 02_new_project.png ├── 03_project_page.png ├── 04_config.png ├── 05_new_entry.png ├── 06_updated_policies.png └── README.md ├── ecs.php ├── phpstan.neon.dist ├── phpunit.xml.dist ├── rector.php ├── registration.php ├── src ├── Block │ └── Adminhtml │ │ └── System │ │ └── Config │ │ └── Form │ │ ├── Buttons.php │ │ ├── Dates.php │ │ ├── ListPolicies.php │ │ └── ReportUriCheck.php ├── Console │ └── Command │ │ └── SansecWatchUpdateCommand.php ├── Controller │ └── Adminhtml │ │ └── Action │ │ └── Update.php ├── Cron │ └── UpdatePolicies.php ├── Mapper │ ├── PolicyMapper.php │ └── SansecWatchFlagMapper.php ├── Model │ ├── Command │ │ └── UpdatePolicies.php │ ├── Config.php │ ├── Config │ │ └── Source │ │ │ └── FpcMode.php │ ├── DTO │ │ ├── Policy.php │ │ └── SansecWatchFlag.php │ ├── Event │ │ └── FetchedPolicies.php │ ├── Exception │ │ ├── CouldNotFetchPoliciesException.php │ │ ├── CouldNotUpdatePoliciesException.php │ │ └── InvalidConfigurationException.php │ ├── FetchPolicyFactory.php │ ├── FpcMode.php │ ├── Query │ │ └── GetAllPolicies.php │ ├── SansecPolicyCollector.php │ ├── SansecWatchClient.php │ └── SansecWatchClientFactory.php ├── Service │ ├── PolicyUpdater.php │ └── UpdateFpc.php ├── etc │ ├── acl.xml │ ├── adminhtml │ │ ├── routes.xml │ │ └── system.xml │ ├── config.xml │ ├── crontab.xml │ ├── db_schema.xml │ ├── db_schema_whitelist.json │ ├── di.xml │ └── module.xml └── view │ └── adminhtml │ └── templates │ └── system │ └── config │ └── form │ ├── buttons.phtml │ ├── dates.phtml │ ├── list-policies.phtml │ └── report-uri-check.phtml └── tests ├── Model ├── Command │ └── UpdatePoliciesTest.php └── Query │ └── GetAllPoliciesTest.php └── Service ├── PolicyUpdaterTest.php └── UpdateFpcTest.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.{yml,yaml,json}] 15 | indent_size = 2 16 | 17 | [{composer,auth}.json] 18 | indent_size = 4 19 | 20 | [db_schema_whitelist.json] 21 | indent_size = 4 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /composer.lock 3 | /.phpunit.cache/ 4 | -------------------------------------------------------------------------------- /.php-version: -------------------------------------------------------------------------------- 1 | 8.3 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 integer_net GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IntegerNet_SansecWatch Module 2 | 3 | This module allows the integration CSP rules from Sansec Watch (https://sansec.watch/) into Magento without the need for file manipulations and a re-deployment 4 | 5 | ## Setup 6 | 7 | ```shell 8 | bin/composer require integer-net/magento2-sansec-watch 9 | bin/magento module:enable IntegerNet_SansecWatch 10 | bin/magento setup:upgrade 11 | ``` 12 | 13 | ## Configuration 14 | 15 | The configuration can be found under `Stores > Configuration > IntegerNet > Sansec Watch` 16 | Only the sansec watch project ID is needed, which can be found in the URL, if you navigate to https://sansec.watch/d/account/list and select a project 17 | (e.g. `685769a2-38a4-4d06-a19a-67a528197f51`) 18 | 19 | ## How it works 20 | 21 | The policies are fetched from the Sansec Watch API and saved into a database table (`integernet_sansecwatch_policies`) 22 | When Magento collects the CSP rules, it uses the `Magento\Csp\Model\CompositePolicyCollector` class and this module adds 23 | a collector to this class, which will read the policies from the database table and add them to the existing policies. 24 | 25 | Once policies are fetched from Sansec Watch, the result will be hashed and further updates are only executed, if the 26 | newly fetched policies differ from the existing ones. (This is handled via the entry `integernet_sansecwatch` in the `flag` table) 27 | 28 | ### Full Page Cache (FPC) 29 | 30 | After policies are updated (either by an updated list from sansec watch or by force), the FPC will be, depending on the configuration either cleared, invalidated or ignored. 31 | 32 | ## Usage 33 | 34 | ### Backend 35 | 36 | Directly below the configuration is a button, `Update Policies Now`, which will fetch and update the policies on demand. 37 | This will do a forced update, where rules are updated, even if the hashes of the old and new policies match. 38 | 39 | ### Command Line 40 | 41 | An update can be triggered via `bin/magento integer-net:sansec-watch:update` 42 | This will by default only update the policies if the hashes of the old and new policies doesn't match. 43 | 44 | A dry-run is possible by adding the `--dry-run` flag, which will only fetch and output the policies, but not update the 45 | database table. 46 | 47 | If an update should be force (regardless of the hashes), the `--force` flag can be added. 48 | 49 | ### Cronjob 50 | 51 | The policies are also fetched via the cronjob `integernet_sansecwatch_update`, which will run every hour (cron expression: `0 * * * *`) 52 | This will also only update the database, if the hashes of the old and new policies do not match 53 | -------------------------------------------------------------------------------- /bitbucket-pipelines.yml: -------------------------------------------------------------------------------- 1 | definitions: 2 | steps: 3 | - step: &normalize 4 | script: 5 | - composer install 6 | - composer normalize --dry-run 7 | caches: 8 | - composer 9 | - step: &lint 10 | script: 11 | - composer install 12 | - ./vendor/bin/parallel-lint --no-colors --no-progress --checkstyle src > checkstyle-lint.xml 13 | after-script: 14 | - pipe: atlassian/checkstyle-report:0.5.1 15 | variables: 16 | CHECKSTYLE_RESULT_PATTERN: '.*/checkstyle-lint.xml$' 17 | CHECKSTYLE_REPORT_TITLE: 'Lint' 18 | CHECKSTYLE_REPORT_ID: 'lint' 19 | caches: 20 | - composer 21 | - step: &ecs 22 | script: 23 | - composer install 24 | - ./vendor/bin/ecs --no-progress-bar --no-interaction --no-ansi --output-format=checkstyle check > checkstyle-ecs.xml 25 | after-script: 26 | - pipe: atlassian/checkstyle-report:0.5.1 27 | variables: 28 | CHECKSTYLE_RESULT_PATTERN: '.*/checkstyle-ecs.xml$' 29 | CHECKSTYLE_REPORT_TITLE: 'Easy Coding Standard' 30 | CHECKSTYLE_REPORT_ID: 'ECS' 31 | caches: 32 | - composer 33 | - step: &phpstan 34 | script: 35 | - composer install 36 | - ./vendor/bin/phpstan --no-progress --no-ansi --error-format=checkstyle > checkstyle-phpstan.xml 37 | after-script: 38 | - pipe: atlassian/checkstyle-report:0.5.1 39 | variables: 40 | CHECKSTYLE_RESULT_PATTERN: '.*/checkstyle-phpstan.xml$' 41 | CHECKSTYLE_REPORT_TITLE: 'PHPStan' 42 | CHECKSTYLE_REPORT_ID: 'phpstan' 43 | caches: 44 | - composer 45 | - step: &rector 46 | script: 47 | - composer install 48 | - ./vendor/bin/rector process --dry-run --no-ansi --no-progress-bar 49 | caches: 50 | - composer 51 | - step: &phpunit 52 | script: 53 | - composer install 54 | - ./vendor/bin/phpunit --no-progress 55 | caches: 56 | - composer 57 | 58 | pipelines: 59 | default: 60 | - parallel: 61 | - step: 62 | <<: *normalize 63 | name: PHP 8.1 Composer Normalize 64 | image: 'integerhub/php:8.1-fpm-local' 65 | - step: 66 | <<: *lint 67 | name: PHP 8.1 Lint 68 | image: 'integerhub/php:8.1-fpm-local' 69 | - step: 70 | <<: *ecs 71 | name: PHP 8.1 ECS 72 | image: 'integerhub/php:8.1-fpm-local' 73 | - step: 74 | <<: *phpstan 75 | name: PHP 8.1 PHPStan 76 | image: 'integerhub/php:8.1-fpm-local' 77 | - step: 78 | <<: *rector 79 | name: PHP 8.1 Rector 80 | image: 'integerhub/php:8.1-fpm-local' 81 | - step: 82 | <<: *phpunit 83 | name: PHP 8.1 PHPUnit 84 | image: 'integerhub/php:8.1-fpm-local' 85 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "integer-net/magento2-sansec-watch", 3 | "description": "Sansec Watch integration for Magento 2", 4 | "license": "MIT", 5 | "type": "magento2-module", 6 | "authors": [ 7 | { 8 | "name": "Julian Nuß", 9 | "email": "jn@integer-net.de" 10 | } 11 | ], 12 | "require": { 13 | "php": ">=8.1.0", 14 | "beberlei/assert": "^3.3", 15 | "cuyz/valinor": "^1.10", 16 | "magento/framework": ">=100.0.0", 17 | "magento/module-csp": "*", 18 | "symfony/clock": "^6.4", 19 | "symfony/http-client": "^5.0 || ^6.0 || ^7.0", 20 | "symfony/uid": "^6.2" 21 | }, 22 | "require-dev": { 23 | "bitexpert/phpstan-magento": "^0.31.0", 24 | "ergebnis/composer-normalize": "^2.43", 25 | "php-parallel-lint/php-console-highlighter": "^1.0", 26 | "php-parallel-lint/php-parallel-lint": "^1.4", 27 | "phpstan/extension-installer": "^1.4", 28 | "phpstan/phpstan": "^1.11", 29 | "phpstan/phpstan-deprecation-rules": "^1.2", 30 | "phpstan/phpstan-phpunit": "^1.4", 31 | "phpstan/phpstan-strict-rules": "^1.6", 32 | "phpunit/phpunit": "^10.5", 33 | "rector/rector": "^1.2", 34 | "roave/security-advisories": "dev-latest", 35 | "symplify/easy-coding-standard": "^12.3" 36 | }, 37 | "repositories": [ 38 | { 39 | "type": "composer", 40 | "url": "https://mirror.mage-os.org/", 41 | "only": [ 42 | "magento/*" 43 | ] 44 | } 45 | ], 46 | "autoload": { 47 | "psr-4": { 48 | "IntegerNet\\SansecWatch\\": "src/" 49 | }, 50 | "files": [ 51 | "registration.php" 52 | ] 53 | }, 54 | "autoload-dev": { 55 | "psr-4": { 56 | "IntegerNet\\SansecWatch\\Test\\": "tests/" 57 | } 58 | }, 59 | "config": { 60 | "allow-plugins": { 61 | "ergebnis/composer-normalize": true, 62 | "magento/composer-dependency-version-audit-plugin": false, 63 | "phpstan/extension-installer": true 64 | } 65 | }, 66 | "scripts": { 67 | "ecs": "ecs check src", 68 | "ecs-fix": "ecs check src --fix", 69 | "lint": "parallel-lint src", 70 | "phpstan": "phpstan", 71 | "phpunit": "phpunit", 72 | "rector": "rector process --dry-run", 73 | "test": [ 74 | "@lint", 75 | "@ecs", 76 | "@phpstan", 77 | "@phpunit", 78 | "@rector" 79 | ] 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /docs/00_homepage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/integer-net/magento2-sansec-watch/4b711f5b9228d9237c01dcd132df442c46fe1492/docs/00_homepage.png -------------------------------------------------------------------------------- /docs/01_project_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/integer-net/magento2-sansec-watch/4b711f5b9228d9237c01dcd132df442c46fe1492/docs/01_project_overview.png -------------------------------------------------------------------------------- /docs/02_new_project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/integer-net/magento2-sansec-watch/4b711f5b9228d9237c01dcd132df442c46fe1492/docs/02_new_project.png -------------------------------------------------------------------------------- /docs/03_project_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/integer-net/magento2-sansec-watch/4b711f5b9228d9237c01dcd132df442c46fe1492/docs/03_project_page.png -------------------------------------------------------------------------------- /docs/04_config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/integer-net/magento2-sansec-watch/4b711f5b9228d9237c01dcd132df442c46fe1492/docs/04_config.png -------------------------------------------------------------------------------- /docs/05_new_entry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/integer-net/magento2-sansec-watch/4b711f5b9228d9237c01dcd132df442c46fe1492/docs/05_new_entry.png -------------------------------------------------------------------------------- /docs/06_updated_policies.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/integer-net/magento2-sansec-watch/4b711f5b9228d9237c01dcd132df442c46fe1492/docs/06_updated_policies.png -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | ## How to set up 4 | 5 | ### 1. Create an account on sansec.io/watch 6 | 7 | Visit https://sansec.io/watch and click on Start Sansec Watch 8 | 9 | ![Sansec Watch: homepage](00_homepage.png "Sansec Watch: homepage") 10 | 11 | ### 2. Create a new project in Sansec Watch 12 | 13 | After the login, you should be on the page https://sansec.watch/d/account/list 14 | In the top section "your monitored sites", click on the "+" button 15 | 16 | ![Sansec Watch: project overview](01_project_overview.png "Sansec Watch: project overview") 17 | 18 | ### 3. Set the project url as CSP report url in Magento 2 19 | 20 | Copy the provided command and execute it on the magento 2 instance to set the CSP report url. 21 | 22 | ![Sansec Watch: new project](02_new_project.png "Sansec Watch: new project") 23 | 24 | Next visit any page on the magento 2 instance, where an external script is loaded. 25 | Once at least one report is sent to Sansec Watch, the project page should no look like this: 26 | 27 | ![Sansec Watch: project page](03_project_page.png "Sansec Watch: project page") 28 | 29 | Copy the project ID from the URL (the part after https://sansec.watch/d/, in the case of the screenshot: `61e018e6-fc0b-4cde-8c1a-2e60e29169fd`) 30 | 31 | ### 4. Configure the sansec project 32 | 33 | Go into your Magento 2 backend and navigate to `Shops > Configuration > Integernet > Sansec Watch`. 34 | (In case the RedChamps admin cleanup module is installed: `Shops > Configuration > Extensions > Integernet > Sansec Watch`) 35 | 36 | ![Magento 2: module configuration](04_config.png "module configuration") 37 | 38 | Set `Enabled` to `yes`, paste the previously copied project id into the `ID` config field and save the configuration. 39 | 40 | ### Allowing or blocking new entries / policies 41 | 42 | Once a new report is sent to Sansec Watch, you can see it on the project page 43 | 44 | ![Sansec Watch: new entry](05_new_entry.png "Sansec Watch: new entry") 45 | 46 | Now you have to decide to either allow or block this domain. 47 | You either have to block or allow this new domain. 48 | Head back into the Magento 2 backend onto the modules configuration page and click on "Update Policies Now". 49 | This will trigger an update in the background and once it's done, the page will reload and show the updated policies int he table below the button. 50 | 51 | ![Magento 2: update policies in Magento 2](06_updated_policies.png "update policies in Magento 2") 52 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | withPaths([ 15 | __DIR__ . '/src', 16 | ]) 17 | ->withPreparedSets( 18 | psr12: true, 19 | symplify: true, 20 | arrays: true, 21 | comments: true, 22 | spaces: true, 23 | namespaces: true, 24 | controlStructures: true, 25 | strict: true, 26 | cleanCode: true, 27 | ) 28 | ->withSkip([ 29 | BinaryOperatorSpacesFixer::class, 30 | CastSpacesFixer::class, 31 | ClassAttributesSeparationFixer::class, 32 | GeneralPhpdocAnnotationRemoveFixer::class, 33 | LineLengthFixer::class, 34 | NotOperatorWithSuccessorSpaceFixer::class, 35 | ]); 36 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 8 3 | paths: 4 | - src 5 | - tests 6 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | tests 16 | 17 | 18 | 19 | 20 | src 21 | 22 | 23 | vendor 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withPaths([ 12 | __DIR__ . '/src', 13 | __DIR__ . '/tests', 14 | ]) 15 | ->withPhpSets( 16 | php81: true 17 | ) 18 | ->withPreparedSets( 19 | deadCode : true, 20 | codeQuality : true, 21 | codingStyle : true, 22 | typeDeclarations : true, 23 | privatization : true, 24 | instanceOf : true, 25 | earlyReturn : true, 26 | strictBooleans : true, 27 | phpunitCodeQuality: true, 28 | phpunit : true, 29 | ) 30 | ->withSkip([ 31 | NewlineAfterStatementRector::class, 32 | PreferPHPUnitThisCallRector::class, 33 | ]); 34 | -------------------------------------------------------------------------------- /registration.php: -------------------------------------------------------------------------------- 1 | htmlId = $element->getHtmlId(); 38 | 39 | return parent::render($element); 40 | } 41 | 42 | public function getAjaxUrl(): string 43 | { 44 | return $this->getUrl('integernet_sansecwatch/action/update'); 45 | } 46 | 47 | public function getHtmlId(): string 48 | { 49 | return $this->htmlId ?? ''; 50 | } 51 | 52 | public function getUpdatePoliciesButtonHtml(): string 53 | { 54 | try { 55 | $buttonData = [ 56 | 'id' => $this->getHtmlId() . '_update_policies_button', 57 | 'label' => __('Update Policies Now'), 58 | ]; 59 | 60 | return $this->createButton() 61 | ->setData($buttonData) 62 | ->toHtml(); 63 | } catch (Exception) { 64 | return ''; 65 | } 66 | } 67 | 68 | public function canShowVisitDashboardAction(): bool 69 | { 70 | try { 71 | $this->config->getId(); 72 | } catch (InvalidConfigurationException) { 73 | return false; 74 | } 75 | 76 | return true; 77 | } 78 | 79 | public function getVisitDashboardUrl(): string 80 | { 81 | return sprintf(self::SANSEC_WATCH_DASHBOARD_URL, $this->config->getId()); 82 | } 83 | 84 | protected function _getElementHtml(AbstractElement $element): string 85 | { 86 | return $this->_toHtml(); 87 | } 88 | 89 | /** 90 | * @throws LocalizedException 91 | */ 92 | private function createButton(): Button 93 | { 94 | /** @var Button $block */ 95 | $block = $this->getLayout() 96 | ->createBlock(Button::class); 97 | 98 | return $block; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Block/Adminhtml/System/Config/Form/Dates.php: -------------------------------------------------------------------------------- 1 | $data 27 | */ 28 | public function __construct( 29 | private readonly FlagManager $flagManager, 30 | private readonly SansecWatchFlagMapper $flagDataMapper, 31 | private readonly TimezoneInterface $timezone, 32 | Context $context, 33 | array $data = [], 34 | ?SecureHtmlRenderer $secureRenderer = null 35 | ) { 36 | parent::__construct($context, $data, $secureRenderer); 37 | } 38 | 39 | public function render(AbstractElement $element): string 40 | { 41 | $this->htmlId = $element->getHtmlId(); 42 | 43 | return parent::render($element); 44 | } 45 | 46 | public function getHtmlId(): string 47 | { 48 | return $this->htmlId ?? ''; 49 | } 50 | 51 | public function getLastCheckedAtDate(): string 52 | { 53 | $lastCheckedAt = $this->getFlagData()?->lastCheckedAt; 54 | 55 | return $lastCheckedAt instanceof DateTimeImmutable 56 | ? $this->formatDateTime($lastCheckedAt) 57 | : ''; 58 | } 59 | 60 | public function getLastUpdatedAtDate(): string 61 | { 62 | $lastUpdatedAt = $this->getFlagData()?->lastUpdatedAt; 63 | 64 | return $lastUpdatedAt instanceof DateTimeImmutable 65 | ? $this->formatDateTime($lastUpdatedAt) 66 | : ''; 67 | } 68 | 69 | protected function _getElementHtml(AbstractElement $element): string 70 | { 71 | return $this->_toHtml(); 72 | } 73 | 74 | private function formatDateTime(DateTimeImmutable $dateTime): string 75 | { 76 | return $this->timezone->formatDateTime($dateTime, timeType: IntlDateFormatter::MEDIUM); 77 | } 78 | 79 | private function getFlagData(): ?SansecWatchFlag 80 | { 81 | if (!$this->sansecWatchFlag instanceof SansecWatchFlag) { 82 | /** @var null|array{hash: string, lastCheckedAt: string, lastUpdatedAt: string} $flagData */ 83 | $flagData = $this->flagManager->getFlagData(SansecWatchFlag::CODE); 84 | 85 | if ($flagData === null) { 86 | return null; 87 | } 88 | 89 | $this->sansecWatchFlag = $this->flagDataMapper->map($flagData); 90 | } 91 | 92 | return $this->sansecWatchFlag; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Block/Adminhtml/System/Config/Form/ListPolicies.php: -------------------------------------------------------------------------------- 1 | $data 22 | */ 23 | public function __construct( 24 | private readonly GetAllPolicies $getAllPolicies, 25 | Context $context, 26 | array $data = [], 27 | ?SecureHtmlRenderer $secureRenderer = null 28 | ) { 29 | parent::__construct($context, $data, $secureRenderer); 30 | } 31 | 32 | public function render(AbstractElement $element): string 33 | { 34 | $this->htmlId = $element->getHtmlId(); 35 | 36 | return parent::render($element); 37 | } 38 | 39 | public function getHtmlId(): string 40 | { 41 | return $this->htmlId ?? ''; 42 | } 43 | 44 | /** 45 | * @return list 46 | */ 47 | public function getPolicies(): array 48 | { 49 | return $this->getAllPolicies->execute(); 50 | } 51 | 52 | protected function _getElementHtml(AbstractElement $element): string 53 | { 54 | return $this->_toHtml(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Block/Adminhtml/System/Config/Form/ReportUriCheck.php: -------------------------------------------------------------------------------- 1 | $data 33 | */ 34 | public function __construct( 35 | private readonly Config $config, 36 | Context $context, 37 | array $data = [], 38 | ?SecureHtmlRenderer $secureRenderer = null 39 | ) { 40 | parent::__construct($context, $data, $secureRenderer); 41 | } 42 | 43 | public function render(AbstractElement $element): string 44 | { 45 | $this->htmlId = $element->getHtmlId(); 46 | 47 | return parent::render($element); 48 | } 49 | 50 | public function getHtmlId(): string 51 | { 52 | return $this->htmlId ?? ''; 53 | } 54 | 55 | public function getConfigurationPaths(): array 56 | { 57 | // For now, we only check the generic storefront report uri 58 | return [ 59 | 'csp/mode/storefront/report_uri', 60 | ]; 61 | } 62 | 63 | public function getConfigurationStatus(string $path): bool 64 | { 65 | if (isset($this->configurationStatusMap[$path])) { 66 | return $this->configurationStatusMap[$path]; 67 | } 68 | 69 | $configuredReportUri = $this->getReportUri($path); 70 | if (!$configuredReportUri) { 71 | return false; 72 | } 73 | 74 | return str_contains( 75 | $configuredReportUri, 76 | $this->getConfiguredSansecId() . '.sansec.watch' 77 | ); 78 | } 79 | 80 | public function getReportUri(string $configPath): ?string 81 | { 82 | $configuredReportUri = $this->_scopeConfig->getValue($configPath); 83 | 84 | return is_string($configuredReportUri) 85 | ? $configuredReportUri 86 | : null; 87 | } 88 | 89 | public function canShowMessage(): bool 90 | { 91 | if (!$this->getConfiguredSansecId() instanceof Uuid) { 92 | return false; 93 | } 94 | 95 | foreach ($this->getConfigurationPaths() as $path) { 96 | if (!$this->getConfigurationStatus($path)) { 97 | return true; 98 | } 99 | } 100 | 101 | return false; 102 | } 103 | 104 | private function getConfiguredSansecId(): ?Uuid 105 | { 106 | if (!isset($this->sansecWatchId)) { 107 | try { 108 | $this->sansecWatchId = $this->config->getId(); 109 | } catch (InvalidConfigurationException) { 110 | $this->sansecWatchId = null; 111 | } 112 | } 113 | 114 | return $this->sansecWatchId; 115 | } 116 | 117 | protected function _getElementHtml(AbstractElement $element): string 118 | { 119 | return $this->_toHtml(); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Console/Command/SansecWatchUpdateCommand.php: -------------------------------------------------------------------------------- 1 | setName('integer-net:sansec-watch:update') 38 | ->setDescription('Update the CSP whitelist from sansec watch') 39 | ->addOption( 40 | name: self::OPTION_DRY_RUN, 41 | shortcut: null, 42 | mode: InputOption::VALUE_NONE, 43 | ) 44 | ->addOption( 45 | name: self::OPTION_FORCE, 46 | shortcut: null, 47 | mode: InputOption::VALUE_NONE, 48 | ); 49 | } 50 | 51 | protected function execute(InputInterface $input, OutputInterface $output): int 52 | { 53 | $isDryRun = (bool)$input->getOption(self::OPTION_DRY_RUN); 54 | $isForce = (bool)$input->getOption(self::OPTION_FORCE); 55 | 56 | $io = new SymfonyStyle($input, $output); 57 | 58 | if (!$this->config->isEnabled()) { 59 | $io->warning('Update is disabled'); 60 | return Cli::RETURN_SUCCESS; 61 | } 62 | 63 | try { 64 | $uuid = $this->config->getId(); 65 | } catch (InvalidConfigurationException $invalidConfigurationException) { 66 | $io->error($invalidConfigurationException->getMessage()); 67 | return Cli::RETURN_SUCCESS; 68 | } 69 | 70 | try { 71 | $policies = $this->sansecWatchClientFactory 72 | ->create() 73 | ->fetchPolicies($uuid); 74 | } catch (CouldNotFetchPoliciesException $couldNotFetchPoliciesException) { 75 | $io->error($couldNotFetchPoliciesException->getMessage()); 76 | return Cli::RETURN_SUCCESS; 77 | } 78 | 79 | if ($isDryRun || $output->isVerbose()) { 80 | $io->info('Fetched policies:'); 81 | 82 | $io->table( 83 | ['Directive', 'Host'], 84 | array_map(fn (Policy $policy): array => $policy->toArray(), $policies) 85 | ); 86 | } 87 | 88 | try { 89 | $this->policyUpdater->updatePolicies($policies, $isForce); 90 | } catch (CouldNotUpdatePoliciesException $couldNotUpdatePoliciesException) { 91 | $io->error($couldNotUpdatePoliciesException->getMessage()); 92 | return Cli::RETURN_SUCCESS; 93 | } 94 | 95 | return Cli::RETURN_SUCCESS; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Controller/Adminhtml/Action/Update.php: -------------------------------------------------------------------------------- 1 | resultJsonFactory->create(); 31 | 32 | if (!$this->config->isEnabled()) { 33 | return $result->setData([ 34 | 'success' => true, 35 | 'message' => __('Please enable updates first.'), 36 | ]); 37 | } 38 | 39 | try { 40 | $uuid = $this->config->getId(); 41 | 42 | $policies = $this->sansecWatchClientFactory 43 | ->create() 44 | ->fetchPolicies($uuid); 45 | 46 | $this->policyUpdater->updatePolicies($policies); 47 | } catch (LocalizedException $localizedException) { 48 | return $result->setData([ 49 | 'success' => false, 50 | 'message' => __('Could not update policies: %1', $localizedException->getMessage()), 51 | ]); 52 | } 53 | 54 | return $result->setData([ 55 | 'success' => true, 56 | ]); 57 | } 58 | 59 | protected function _isAllowed(): bool 60 | { 61 | return $this->_authorization->isAllowed('IntegerNet_SansecWatch::configuration'); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Cron/UpdatePolicies.php: -------------------------------------------------------------------------------- 1 | config->isEnabled()) { 26 | $this->logger->info('Update is disabled'); 27 | return; 28 | } 29 | 30 | try { 31 | $uuid = $this->config->getId(); 32 | 33 | $policies = $this->sansecWatchClientFactory 34 | ->create() 35 | ->fetchPolicies($uuid); 36 | 37 | $this->policyUpdater->updatePolicies($policies); 38 | } catch (LocalizedException $localizedException) { 39 | $this->logger->error('Could not update policies: ' . $localizedException->getMessage()); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Mapper/PolicyMapper.php: -------------------------------------------------------------------------------- 1 | 17 | * 18 | * @throws MappingError 19 | * @throws InvalidSource 20 | */ 21 | public function map(string $json): array 22 | { 23 | return (new MapperBuilder()) 24 | ->mapper() 25 | ->map( 26 | sprintf('list<%s>', Policy::class), 27 | Source::json($json) 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Mapper/SansecWatchFlagMapper.php: -------------------------------------------------------------------------------- 1 | allowSuperfluousKeys() 26 | ->supportDateFormats(DATE_ATOM) 27 | ->mapper() 28 | ->map( 29 | SansecWatchFlag::class, 30 | Source::array($flagData) 31 | ->camelCaseKeys() 32 | ); 33 | } catch (MappingError) { 34 | return null; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Model/Command/UpdatePolicies.php: -------------------------------------------------------------------------------- 1 | $policies 22 | * 23 | * @throws CouldNotUpdatePoliciesException 24 | */ 25 | public function execute(array $policies): void 26 | { 27 | $connection = $this->resourceConnection->getConnection('write'); 28 | $tableName = $this->resourceConnection->getTableName(Config::POLICY_TABLE); 29 | 30 | try { 31 | $connection->beginTransaction(); 32 | $connection->delete($tableName); 33 | 34 | if ($policies !== []) { 35 | $connection->insertMultiple($tableName, array_map(fn (Policy $p): array => $p->toArray(), $policies)); 36 | } 37 | 38 | $connection->commit(); 39 | } catch (Exception $exception) { 40 | $connection->rollBack(); 41 | 42 | throw CouldNotUpdatePoliciesException::withMessage( 43 | __('Could not update policies: %1', $exception->getMessage()) 44 | ); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Model/Config.php: -------------------------------------------------------------------------------- 1 | scopeConfig->isSetFlag(self::INTEGERNET_SANSEC_WATCH_GENERAL_ENABLED); 27 | } 28 | 29 | /** 30 | * @throws InvalidConfigurationException 31 | */ 32 | public function getId(): Uuid 33 | { 34 | $id = (string)$this->scopeConfig->getValue(self::INTEGERNET_SANSEC_WATCH_GENERAL_ID); 35 | 36 | if (!Uuid::isValid($id)) { 37 | throw InvalidConfigurationException::fromInvalidUuid($id); 38 | } 39 | 40 | return Uuid::fromString($id); 41 | } 42 | 43 | public function getFpcMode(): FpcMode 44 | { 45 | $mode = $this->scopeConfig->getValue(self::INTEGERNET_SANSEC_WATCH_FPC_MODE); 46 | 47 | return FpcMode::tryFrom($mode) ?? FpcMode::NONE; 48 | } 49 | 50 | public function getApiUrl(): string 51 | { 52 | return 'https://sansec.watch/api/magento/{id}.json'; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Model/Config/Source/FpcMode.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | public function toOptionArray(): array 17 | { 18 | return [ 19 | [ 20 | 'value' => FpcModeEnum::NONE->value, 21 | 'label' => FpcModeEnum::NONE->label(), 22 | ], 23 | [ 24 | 'value' => FpcModeEnum::INVALIDATE->value, 25 | 'label' => FpcModeEnum::INVALIDATE->label(), 26 | ], 27 | [ 28 | 'value' => FpcModeEnum::CLEAR->value, 29 | 'label' => FpcModeEnum::CLEAR->label(), 30 | ], 31 | ]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Model/DTO/Policy.php: -------------------------------------------------------------------------------- 1 | $this->directive, 33 | 'host' => $this->host, 34 | ]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Model/DTO/SansecWatchFlag.php: -------------------------------------------------------------------------------- 1 | $this->hash, 32 | 'last_checked_at' => $this->lastCheckedAt->format(DATE_ATOM), 33 | 'last_updated_at' => $this->lastUpdatedAt->format(DATE_ATOM), 34 | ]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Model/Event/FetchedPolicies.php: -------------------------------------------------------------------------------- 1 | $policies 15 | */ 16 | public function __construct( 17 | private array $policies, 18 | ) { 19 | $this->validateArrayItems($policies); 20 | } 21 | 22 | /** 23 | * @param list $policies 24 | * 25 | * @throws InvalidArgumentException 26 | */ 27 | public function setPolicies(array $policies): void 28 | { 29 | $this->validateArrayItems($policies); 30 | 31 | $this->policies = $policies; 32 | } 33 | 34 | /** 35 | * @return list 36 | */ 37 | public function getPolicies(): array 38 | { 39 | return $this->policies; 40 | } 41 | 42 | /** 43 | * @param list $policies 44 | * 45 | * @throws InvalidArgumentException 46 | */ 47 | private function validateArrayItems(array $policies): void 48 | { 49 | Assertion::allIsInstanceOf($policies, Policy::class, 'All Items must be of type ' . Policy::class); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Model/Exception/CouldNotFetchPoliciesException.php: -------------------------------------------------------------------------------- 1 | getMessage()), $invalidSource); 27 | } 28 | 29 | public static function fromMappingError(MappingError $mappingError): self 30 | { 31 | return new self(__('Could not map response JSON to DTO: %1', $mappingError->getMessage()), $mappingError); 32 | } 33 | 34 | public static function fromTransportException(TransportExceptionInterface $transportException): self 35 | { 36 | return new self(__('Could not fetch policies from sansec watch: %1', $transportException), $transportException); 37 | } 38 | 39 | public static function fromHttpException(HttpExceptionInterface $httpException): self 40 | { 41 | return new self(__('Could not fetch policies from sansec watch: %1', $httpException), $httpException); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Model/Exception/CouldNotUpdatePoliciesException.php: -------------------------------------------------------------------------------- 1 | directive, 26 | noneAllowed: $this->noneAllowed, 27 | hostSources: [$policy->host], 28 | schemeSources: [], 29 | selfAllowed: $this->selfAllowed, 30 | inlineAllowed: $this->inlineAllowed, 31 | evalAllowed: $this->evalAllowed, 32 | nonceValues: [], 33 | hashValues: [], 34 | dynamicAllowed: $this->dynamicAllowed, 35 | eventHandlersAllowed: $this->eventHandlersAllowed, 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Model/FpcMode.php: -------------------------------------------------------------------------------- 1 | __('Do Nothing'), 19 | FpcMode::INVALIDATE => __('Invalidate FPC'), 20 | FpcMode::CLEAR => __('Clear FPC'), 21 | }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Model/Query/GetAllPolicies.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | public function execute(): array 23 | { 24 | $connection = $this->resourceConnection->getConnection('read'); 25 | $tableName = $this->resourceConnection->getTableName(Config::POLICY_TABLE); 26 | 27 | try { 28 | $query = $connection->select() 29 | ->from($tableName); 30 | 31 | $policies = $connection->fetchAll($query); 32 | } catch (Exception) { 33 | return []; 34 | } 35 | 36 | return array_map(Policy::fromArray(...), $policies); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Model/SansecPolicyCollector.php: -------------------------------------------------------------------------------- 1 | state->getAreaCode() === Area::AREA_ADMINHTML) { 29 | return $defaultPolicies; 30 | } 31 | 32 | if (!$this->config->isEnabled()) { 33 | return $defaultPolicies; 34 | } 35 | 36 | $sansecWatchPolicies = array_map( 37 | $this->fetchPolicyFactory->fromPolicyDto(...), 38 | $this->getAllPolicies->execute() 39 | ); 40 | 41 | return array_merge($defaultPolicies, $sansecWatchPolicies); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Model/SansecWatchClient.php: -------------------------------------------------------------------------------- 1 | 31 | * @throws CouldNotFetchPoliciesException 32 | */ 33 | public function fetchPolicies(Uuid $uuid): array 34 | { 35 | try { 36 | $responseJson = $this->fetchData($uuid); 37 | 38 | $policies = $this->policyMapper->map($responseJson); 39 | } catch (InvalidSource $invalidSource) { 40 | throw CouldNotFetchPoliciesException::fromInvalidSource($invalidSource); 41 | } catch (MappingError $mappingError) { 42 | throw CouldNotFetchPoliciesException::fromMappingError($mappingError); 43 | } catch (TransportExceptionInterface $transportException) { 44 | throw CouldNotFetchPoliciesException::fromTransportException($transportException); 45 | } catch (HttpExceptionInterface $httpException) { 46 | throw CouldNotFetchPoliciesException::fromHttpException($httpException); 47 | } 48 | 49 | $fetchedPolicies = new FetchedPolicies($policies); 50 | $this->eventManager->dispatch( 51 | 'integernet_sansec_watch_fetched_policies', 52 | [ 53 | 'fetched_policies' => $fetchedPolicies, 54 | ] 55 | ); 56 | 57 | return $fetchedPolicies->getPolicies(); 58 | } 59 | 60 | /** 61 | * @throws TransportExceptionInterface 62 | * @throws HttpExceptionInterface 63 | */ 64 | private function fetchData(Uuid $uuid): string 65 | { 66 | $uri = str_replace( 67 | '{id}', 68 | $uuid->toRfc4122(), 69 | $this->config->getApiUrl() 70 | ); 71 | 72 | $options = [ 73 | 'headers' => [ 74 | 'Accept' => 'application/json', 75 | ], 76 | ]; 77 | 78 | $response = $this->httpClient->request('GET', $uri, $options); 79 | 80 | return $response->getContent(); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Model/SansecWatchClientFactory.php: -------------------------------------------------------------------------------- 1 | getEventManager(), 30 | $config ?? $this->getConfig(), 31 | ); 32 | } 33 | 34 | private function getEventManager(): ManagerInterface 35 | { 36 | return $this->objectManager->get(ManagerInterface::class); 37 | } 38 | 39 | private function getConfig(): Config 40 | { 41 | return $this->objectManager->get(Config::class); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Service/PolicyUpdater.php: -------------------------------------------------------------------------------- 1 | $policies 31 | * 32 | * @throws CouldNotUpdatePoliciesException 33 | */ 34 | public function updatePolicies(array $policies, bool $force = false): void 35 | { 36 | $fetchedPolicies = new FetchedPolicies($policies); 37 | $this->eventManager->dispatch('integernet_sansec_watch_update_policies_before', [ 38 | 'fetched_policies' => $fetchedPolicies, 39 | ]); 40 | $policies = $fetchedPolicies->getPolicies(); 41 | 42 | $newPoliciesHash = $this->calculateHash($policies); 43 | $existingFlagData = $this->getPoliciesFlagData(); 44 | 45 | if ($newPoliciesHash === $existingFlagData?->hash && $force === false) { 46 | $this->eventManager->dispatch('integernet_sansec_watch_update_policies_skipped', [ 47 | 'fetched_policies' => $fetchedPolicies, 48 | 'policies_hash' => $existingFlagData->hash, 49 | ]); 50 | 51 | $this->updateLastCheckedAt($existingFlagData); 52 | return; 53 | } 54 | 55 | $this->updatePoliciesCommand->execute($policies); 56 | $this->saveNewFlagData($newPoliciesHash); 57 | $this->updateFpc->execute(); 58 | 59 | $this->eventManager->dispatch('integernet_sansec_watch_update_policies_after', [ 60 | 'fetched_policies' => $fetchedPolicies, 61 | 'new_policies_hash' => $newPoliciesHash, 62 | ]); 63 | } 64 | 65 | private function saveNewFlagData(string $hash): void 66 | { 67 | $newFlagData = new SansecWatchFlag( 68 | hash : $hash, 69 | lastCheckedAt: $this->clock->now(), 70 | lastUpdatedAt: $this->clock->now(), 71 | ); 72 | 73 | $this->updateFlagData($newFlagData); 74 | } 75 | 76 | private function updateLastCheckedAt(SansecWatchFlag $sansecWatchFlag): void 77 | { 78 | $newFlagData = new SansecWatchFlag( 79 | hash : $sansecWatchFlag->hash, 80 | lastCheckedAt: $this->clock->now(), 81 | lastUpdatedAt: $sansecWatchFlag->lastUpdatedAt, 82 | ); 83 | 84 | $this->updateFlagData($newFlagData); 85 | } 86 | 87 | private function updateFlagData(SansecWatchFlag $flagData): void 88 | { 89 | $this->flagManager->saveFlag(SansecWatchFlag::CODE, $flagData->jsonSerialize()); 90 | } 91 | 92 | private function getPoliciesFlagData(): ?SansecWatchFlag 93 | { 94 | /** @var null|array{hash: string, lastCheckedAt: string, lastUpdatedAt: string} $flagData */ 95 | $flagData = $this->flagManager->getFlagData(SansecWatchFlag::CODE); 96 | 97 | if (!is_array($flagData)) { 98 | return null; 99 | } 100 | 101 | return $this->flagDataMapper->map($flagData); 102 | } 103 | 104 | /** 105 | * @param list $policies 106 | */ 107 | private function calculateHash(array $policies): string 108 | { 109 | usort($policies, static function (Policy $a, Policy $b) { 110 | return ($a->directive . $a->host) <=> ($b->directive . $b->host); 111 | }); 112 | 113 | return hash('sha256', serialize($policies)); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Service/UpdateFpc.php: -------------------------------------------------------------------------------- 1 | pageCacheConfig->isEnabled()) { 25 | return; 26 | } 27 | 28 | $mode = $this->config->getFpcMode(); 29 | if ($mode === FpcMode::NONE) { 30 | return; 31 | } 32 | 33 | /** @noinspection PhpUncoveredEnumCasesInspection */ 34 | match ($mode) { 35 | FpcMode::CLEAR => $this->cacheList->cleanType(Type::TYPE_IDENTIFIER), 36 | FpcMode::INVALIDATE => $this->cacheList->invalidate(Type::TYPE_IDENTIFIER), 37 | }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/etc/acl.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/etc/adminhtml/routes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/etc/adminhtml/system.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | integernet 10 | IntegerNet_SansecWatch::configuration 11 | 12 | 13 | 14 | 15 | Magento\Config\Model\Config\Source\Yesno 16 | 17 | 18 | 19 | 20 | 1 21 | 22 | 23 | 24 | 25 | IntegerNet\SansecWatch\Block\Adminhtml\System\Config\Form\Buttons 26 | 27 | 28 | 29 | IntegerNet\SansecWatch\Block\Adminhtml\System\Config\Form\ReportUriCheck 30 | 31 | 32 | 33 | IntegerNet\SansecWatch\Block\Adminhtml\System\Config\Form\Dates 34 | 35 | 36 | 37 | IntegerNet\SansecWatch\Block\Adminhtml\System\Config\Form\ListPolicies 38 | 39 | 40 | 41 | 42 | 43 | 44 | IntegerNet\SansecWatch\Model\Config\Source\FpcMode 45 | 46 | 47 |
48 |
49 |
50 | -------------------------------------------------------------------------------- /src/etc/config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 0 7 | 8 | 9 | invalidate 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/etc/crontab.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | */10 * * * * 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/etc/db_schema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 | -------------------------------------------------------------------------------- /src/etc/db_schema_whitelist.json: -------------------------------------------------------------------------------- 1 | { 2 | "integernet_sansecwatch_policies": { 3 | "column": { 4 | "policy_id": true, 5 | "directive": true, 6 | "host": true 7 | }, 8 | "constraint": { 9 | "PRIMARY": true 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /src/etc/di.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Symfony\Component\Clock\Clock 6 | 7 | 8 | 9 | 10 | 11 | 12 | IntegerNet\SansecWatch\Console\Command\SansecWatchUpdateCommand 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | IntegerNet\SansecWatch\Model\SansecPolicyCollector\Proxy 21 | 22 | 23 | 24 | 25 | 26 | 27 | /var/log/integernet_sansecwatch.log 28 | 29 | 30 | 31 | 32 | 33 | 34 | integerNetSansecWatchLogHandler 35 | 36 | 37 | 38 | 39 | 40 | 41 | integerNetSansecWatchLogger 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/etc/module.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/view/adminhtml/templates/system/config/form/buttons.phtml: -------------------------------------------------------------------------------- 1 | getHtmlId(); 14 | ?> 15 | 22 | escapeJs($htmlId)}_update_policies_button') 25 | const visitDashboardButton = document.querySelector('#{$escaper->escapeJs($htmlId)}_visit_dashboard_button') 26 | const loading = document.querySelector('#{$escaper->escapeJs($htmlId)}_loading') 27 | const messages = document.querySelector('#{$escaper->escapeJs($htmlId)}_messages') 28 | const url = new URL('{$escaper->escapeJs($block->getAjaxUrl())}') 29 | url.searchParams.set('isAjax', 'true') 30 | 31 | const showMessages = () => messages.style.display = 'block' 32 | const hideMessages = () => messages.style.display = 'none' 33 | const showLoading = () => loading.style.display = 'flex' 34 | const hideLoading = () => loading.style.display = 'none' 35 | const enableUpdatePoliciesButton = () => updatePoliciesButton.disabled = false 36 | const disableUpdatePoliciesButton = () => updatePoliciesButton.disabled = true 37 | const displayMessage = (message, error = false) => { 38 | messages.innerHTML = error 39 | ? `\${message}` 40 | : `\${message}` 41 | 42 | showMessages() 43 | } 44 | const clearMessages = () => { 45 | messages.innerHTML = '' 46 | hideMessages() 47 | } 48 | 49 | const handleResponse = (response) => { 50 | hideLoading() 51 | enableUpdatePoliciesButton() 52 | 53 | if (response.success !== true) { 54 | displayMessage(response.message, true) 55 | return 56 | } 57 | 58 | displayMessage('{$escaper->escapeJs(__('Policies updated'))}') 59 | setTimeout(() => window.location.reload(), 250); 60 | } 61 | 62 | updatePoliciesButton.addEventListener('click', () => { 63 | const body = new FormData() 64 | body.append('form_key', window.FORM_KEY); 65 | 66 | disableUpdatePoliciesButton() 67 | showLoading() 68 | clearMessages() 69 | 70 | const options = { 71 | method: 'POST', 72 | credentials: 'include', 73 | body: body, 74 | } 75 | 76 | fetch(url, options) 77 | .then(response => response.json()) 78 | .then(handleResponse) 79 | .catch(error => console.error(error)) 80 | }) 81 | }); 82 | SCRIPT; 83 | ?> 84 | renderTag('script', [], $scriptString, false); ?> 85 | 86 | getUpdatePoliciesButtonHtml() ?> 87 | 88 | canShowVisitDashboardAction()): ?> 89 | escapeHtml(__('Go to Dashboard')) ?> 93 | 94 | 95 | 113 | 114 | 116 | -------------------------------------------------------------------------------- /src/view/adminhtml/templates/system/config/form/dates.phtml: -------------------------------------------------------------------------------- 1 | getHtmlId(); 12 | ?> 13 | 21 | 22 |
23 | 24 | escapeHtml(__('Last checked for updates')) ?> 25 | 26 | 27 | escapeHtml($block->getLastCheckedAtDate()) ?> 28 | 29 |
30 |
31 | 32 | escapeHtml(__('Last update of policies')) ?> 33 | 34 | 35 | escapeHtml($block->getLastUpdatedAtDate()) ?> 36 | 37 |
38 | -------------------------------------------------------------------------------- /src/view/adminhtml/templates/system/config/form/list-policies.phtml: -------------------------------------------------------------------------------- 1 | getHtmlId(); 12 | ?> 13 | 28 | 29 | 30 | 31 | 32 | 33 | 36 | 39 | 40 | 41 | 42 | 43 | getPolicies() as $policy): ?> 44 | 45 | 48 | 51 | 52 | 53 | 54 | 55 |
34 | escapeHtml(__('Directive')) ?> 35 | 37 | escapeHtml(__('Host')) ?> 38 |
46 |
escapeHtml($policy->directive) ?>
47 |
49 |
escapeHtml($policy->host) ?>
50 |
56 | -------------------------------------------------------------------------------- /src/view/adminhtml/templates/system/config/form/report-uri-check.phtml: -------------------------------------------------------------------------------- 1 | getHtmlId(); 12 | ?> 13 | 41 | 42 | 43 |
44 | canShowMessage()): ?> 45 |
46 | Report URI is not configured to use sansec watch 47 |
48 | Configured Report URIs: 49 | getConfigurationPaths() as $path): ?> 50 | getConfigurationStatus($path); ?> 51 |
52 | - escapeHtml($path) ?> = escapeHtml(json_encode($block->getReportUri($path))) ?> 53 |
54 | 55 |
56 | 57 |
Report URI is configured correctly.
58 | 59 |
60 | -------------------------------------------------------------------------------- /tests/Model/Command/UpdatePoliciesTest.php: -------------------------------------------------------------------------------- 1 | connection = self::createMock(AdapterInterface::class); 27 | 28 | $resourceConnection = self::createStub(ResourceConnection::class); 29 | $resourceConnection 30 | ->method('getConnection') 31 | ->willReturn($this->connection); 32 | 33 | $this->updatePoliciesCommand = new UpdatePolicies($resourceConnection); 34 | } 35 | 36 | #[Test] 37 | public function doesNotExecuteInsertWhenNoPoliciesAreGiven(): void 38 | { 39 | $this->connection 40 | ->expects(self::never()) 41 | ->method('insertMultiple'); 42 | 43 | /** @noinspection PhpUnhandledExceptionInspection */ 44 | $this->updatePoliciesCommand->execute([]); 45 | } 46 | 47 | #[Test] 48 | public function deletesExistingRulesBeforeUpdating(): void 49 | { 50 | $this->connection 51 | ->expects(self::once()) 52 | ->method('delete'); 53 | 54 | /** @noinspection PhpUnhandledExceptionInspection */ 55 | $this->updatePoliciesCommand->execute([]); 56 | } 57 | 58 | #[Test] 59 | public function databaseQueriesAreRunInTransaction(): void 60 | { 61 | $this->connection 62 | ->expects(self::once()) 63 | ->method('beginTransaction'); 64 | 65 | $this->connection 66 | ->expects(self::once()) 67 | ->method('commit'); 68 | 69 | /** @noinspection PhpUnhandledExceptionInspection */ 70 | $this->updatePoliciesCommand->execute([]); 71 | } 72 | 73 | #[Test] 74 | public function transactionIsRolledBackInCaseOfAnyError(): void 75 | { 76 | self::expectException(CouldNotUpdatePoliciesException::class); 77 | 78 | $this->connection 79 | ->expects(self::once()) 80 | ->method('beginTransaction'); 81 | 82 | $this->connection 83 | ->method('commit') 84 | ->willThrowException(new \Exception('Something went wrong')); 85 | 86 | $this->connection 87 | ->expects(self::once()) 88 | ->method('rollBack'); 89 | 90 | $this->updatePoliciesCommand->execute([]); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /tests/Model/Query/GetAllPoliciesTest.php: -------------------------------------------------------------------------------- 1 | connection = self::createStub(AdapterInterface::class); 28 | $this->connection 29 | ->method('select') 30 | ->willReturn($select); 31 | 32 | $resourceConnection = self::createStub(ResourceConnection::class); 33 | $resourceConnection 34 | ->method('getConnection') 35 | ->willReturn($this->connection); 36 | 37 | $this->getAllPoliciesQuery = new GetAllPolicies($resourceConnection); 38 | } 39 | 40 | #[Test] 41 | public function returnEmptyListIfAnyExceptionIsThrown(): void 42 | { 43 | $this->connection 44 | ->method('fetchAll') 45 | ->willThrowException(new \Exception('Something went wrong')); 46 | 47 | self::assertCount(0, $this->getAllPoliciesQuery->execute()); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/Service/PolicyUpdaterTest.php: -------------------------------------------------------------------------------- 1 | flagDataMapper = self::createStub(SansecWatchFlagMapper::class); 42 | $this->clock = self::createStub(ClockInterface::class); 43 | $this->flagManager = self::createMock(FlagManager::class); 44 | $this->updatePolicies = self::createMock(UpdatePolicies::class); 45 | $this->updateFpc = self::createMock(UpdateFpc::class); 46 | $eventManager = self::createStub(ManagerInterface::class); 47 | 48 | $this->policyUpdater = new PolicyUpdater( 49 | $this->flagDataMapper, 50 | $this->flagManager, 51 | $this->updatePolicies, 52 | $this->clock, 53 | $this->updateFpc, 54 | $eventManager, 55 | ); 56 | } 57 | 58 | #[Test] 59 | public function pageCacheIsClearedIfPoliciesAreUpdated(): void 60 | { 61 | $this->flagDataIsReturned( 62 | new SansecWatchFlag( 63 | hash('sha256', serialize([])), 64 | new DateTimeImmutable(), 65 | new DateTimeImmutable(), 66 | ) 67 | ); 68 | 69 | $this->updateFpc 70 | ->expects(self::once()) 71 | ->method('execute'); 72 | 73 | /** @noinspection PhpUnhandledExceptionInspection */ 74 | $this->policyUpdater->updatePolicies([new Policy('script-src', '*.integer-net.de')]); 75 | } 76 | 77 | #[Test] 78 | public function pageCacheIsIgnoredIfPoliciesAreNotUpdated(): void 79 | { 80 | $policies = [new Policy('script-src', '*.integer-net.de')]; 81 | 82 | $this->flagDataIsReturned( 83 | new SansecWatchFlag( 84 | hash('sha256', serialize($policies)), 85 | new DateTimeImmutable(), 86 | new DateTimeImmutable(), 87 | ) 88 | ); 89 | 90 | $this->updateFpc 91 | ->expects(self::never()) 92 | ->method('execute'); 93 | 94 | /** @noinspection PhpUnhandledExceptionInspection */ 95 | $this->policyUpdater->updatePolicies($policies); 96 | } 97 | 98 | #[Test] 99 | public function policiesAreNotUpdatedIfHashDoesMatch(): void 100 | { 101 | $policies = [new Policy('script-src', '*.integer-net.de')]; 102 | 103 | $this->flagDataIsReturned( 104 | new SansecWatchFlag( 105 | hash('sha256', serialize($policies)), 106 | new DateTimeImmutable(), 107 | new DateTimeImmutable(), 108 | ) 109 | ); 110 | 111 | $this->updatePolicies 112 | ->expects(self::never()) 113 | ->method('execute'); 114 | 115 | /** @noinspection PhpUnhandledExceptionInspection */ 116 | $this->policyUpdater->updatePolicies($policies); 117 | } 118 | 119 | #[Test] 120 | public function lastCheckedUpIsUpdatedWhenHashMatches(): void 121 | { 122 | $policies = [new Policy('script-src', '*.integer-net.de')]; 123 | $hash = hash('sha256', serialize($policies)); 124 | $now = new DateTimeImmutable(); 125 | $yesterday = $now->sub(DateInterval::createFromDateString('1 day')); 126 | 127 | $this->clock 128 | ->method('now') 129 | ->willReturn($now); 130 | 131 | $this->flagDataIsReturned(new SansecWatchFlag($hash, $now, $yesterday)); 132 | 133 | $this->flagManager 134 | ->expects(self::once()) 135 | ->method('saveFlag') 136 | ->with( 137 | SansecWatchFlag::CODE, 138 | [ 139 | 'hash' => $hash, 140 | 'last_checked_at' => $now->format(DATE_ATOM), 141 | 'last_updated_at' => $yesterday->format(DATE_ATOM), 142 | ] 143 | ); 144 | 145 | /** @noinspection PhpUnhandledExceptionInspection */ 146 | $this->policyUpdater->updatePolicies($policies); 147 | } 148 | 149 | #[Test] 150 | public function flagDataIsAlwaysUpdatedIfForceIsTrue(): void 151 | { 152 | $policies = [new Policy('script-src', '*.integer-net.de')]; 153 | $hash = hash('sha256', serialize($policies)); 154 | $now = new DateTimeImmutable(); 155 | $yesterday = $now->sub(DateInterval::createFromDateString('1 day')); 156 | 157 | $this->clock 158 | ->method('now') 159 | ->willReturn($now); 160 | 161 | $this->flagDataIsReturned(new SansecWatchFlag($hash, $now, $yesterday)); 162 | 163 | $this->updatePolicies 164 | ->expects(self::once()) 165 | ->method('execute') 166 | ->with($policies); 167 | 168 | $this->flagManager 169 | ->expects(self::once()) 170 | ->method('saveFlag') 171 | ->with( 172 | SansecWatchFlag::CODE, 173 | [ 174 | 'hash' => $hash, 175 | 'last_checked_at' => $now->format(DATE_ATOM), 176 | 'last_updated_at' => $now->format(DATE_ATOM), 177 | ] 178 | ); 179 | 180 | /** @noinspection PhpUnhandledExceptionInspection */ 181 | $this->policyUpdater->updatePolicies($policies, true); 182 | } 183 | 184 | private function flagDataIsReturned(?SansecWatchFlag $flag): void 185 | { 186 | $this->flagManager 187 | ->method('getFlagData') 188 | ->willReturn([]); 189 | 190 | $this->flagDataMapper 191 | ->method('map') 192 | ->willReturn($flag); 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /tests/Service/UpdateFpcTest.php: -------------------------------------------------------------------------------- 1 | config = self::createMock(Config::class); 35 | $this->pageCacheConfig = self::createStub(PageCacheConfig::class); 36 | $this->cacheTypeList = self::createMock(TypeListInterface::class); 37 | 38 | $this->updateFpc = new UpdateFpc( 39 | $this->config, 40 | $this->pageCacheConfig, 41 | $this->cacheTypeList, 42 | ); 43 | } 44 | 45 | #[Test] 46 | public function nothingIsDoneIfFpcIsDisabled(): void 47 | { 48 | $this->pageCacheConfig 49 | ->method('isEnabled') 50 | ->willReturn(false); 51 | 52 | $this->config 53 | ->expects(self::never()) 54 | ->method(self::anything()); 55 | 56 | $this->updateFpc->execute(); 57 | } 58 | 59 | #[Test] 60 | public function nothingIsDoneIfFpcModeIsNone(): void 61 | { 62 | $this->pageCacheConfig 63 | ->method('isEnabled') 64 | ->willReturn(true); 65 | 66 | $this->config 67 | ->expects(self::once()) 68 | ->method('getFpcMode') 69 | ->willReturn(FpcMode::NONE); 70 | 71 | $this->cacheTypeList 72 | ->expects(self::never()) 73 | ->method(self::anything()); 74 | 75 | $this->updateFpc->execute(); 76 | } 77 | 78 | #[Test] 79 | public function invalidatesFpcIfModeIfInvalidate(): void 80 | { 81 | $this->pageCacheConfig 82 | ->method('isEnabled') 83 | ->willReturn(true); 84 | 85 | $this->config 86 | ->expects(self::once()) 87 | ->method('getFpcMode') 88 | ->willReturn(FpcMode::INVALIDATE); 89 | 90 | $this->cacheTypeList 91 | ->expects(self::once()) 92 | ->method('invalidate'); 93 | 94 | $this->updateFpc->execute(); 95 | } 96 | 97 | #[Test] 98 | public function clearsFpcIfModeIfClear(): void 99 | { 100 | $this->pageCacheConfig 101 | ->method('isEnabled') 102 | ->willReturn(true); 103 | 104 | $this->config 105 | ->expects(self::once()) 106 | ->method('getFpcMode') 107 | ->willReturn(FpcMode::CLEAR); 108 | 109 | $this->cacheTypeList 110 | ->expects(self::once()) 111 | ->method('cleanType'); 112 | 113 | $this->updateFpc->execute(); 114 | } 115 | } 116 | --------------------------------------------------------------------------------