├── phpstan.neon.dist ├── code-of-conduct.md ├── .github ├── ISSUE_TEMPLATE │ ├── 3_blank_issue.md │ ├── config.yml │ ├── 2_feature_request.yml │ └── 1_bug_report.yml ├── workflows │ ├── keepalive.yml │ ├── dispatch-ci.yml │ ├── merge-up.yml │ ├── add-prs-to-project.yml │ ├── ci.yml │ └── tag-patch-release.yml └── PULL_REQUEST_TEMPLATE.md ├── _config ├── session-manager.yml ├── mfa.yml └── config.yml ├── code ├── Extensions │ └── DatabaseExtension.php ├── RealIPProcessor.php ├── AuditHookSessionManager.php ├── AuditFactory.php ├── AuditHookMemberGroupSet.php ├── AuditHookManyManyList.php ├── AuditHookMFA.php └── AuditHook.php ├── phpcs.xml.dist ├── .editorconfig ├── phpunit.xml.dist ├── composer.json ├── LICENSE └── README.md /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | parameters: 2 | paths: 3 | - code 4 | -------------------------------------------------------------------------------- /code-of-conduct.md: -------------------------------------------------------------------------------- 1 | When having discussions about this module in issues or pull request please adhere to the [SilverStripe Community Code of Conduct](https://docs.silverstripe.org/en/contributing/code_of_conduct). 2 | 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3_blank_issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Blank issue 3 | about: Only for use by maintainers 4 | --- 5 | 6 | -------------------------------------------------------------------------------- /_config/session-manager.yml: -------------------------------------------------------------------------------- 1 | --- 2 | Name: auditor-session-manager 3 | Only: 4 | moduleexists: silverstripe/session-manager 5 | --- 6 | SilverStripe\SessionManager\Controllers\LoginSessionController: 7 | extensions: 8 | - SilverStripe\Auditor\AuditHookSessionManager 9 | -------------------------------------------------------------------------------- /_config/mfa.yml: -------------------------------------------------------------------------------- 1 | --- 2 | Name: auditormfa 3 | Only: 4 | moduleexists: silverstripe/mfa 5 | --- 6 | SilverStripe\MFA\Authenticator\LoginHandler: 7 | extensions: 8 | - SilverStripe\Auditor\AuditHookMFA 9 | 10 | SilverStripe\MFA\Service\RegisteredMethodManager: 11 | extensions: 12 | - SilverStripe\Auditor\AuditHookMFA 13 | -------------------------------------------------------------------------------- /code/Extensions/DatabaseExtension.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | CodeSniffer ruleset for SilverStripe coding conventions. 4 | 5 | code 6 | tests 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /code/RealIPProcessor.php: -------------------------------------------------------------------------------- 1 | create(HTTPRequest::class, null, null); 14 | $record['extra']['real_ip'] = $req->getIP(); 15 | return $record; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # For more information about the properties used in 2 | # this file, please see the EditorConfig documentation: 3 | # http://editorconfig.org/ 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 4 9 | indent_style = space 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | 16 | [*.{yml,js,json,css,scss,eslintrc}] 17 | indent_size = 2 18 | 19 | [composer.json] 20 | indent_size = 4 21 | -------------------------------------------------------------------------------- /.github/workflows/keepalive.yml: -------------------------------------------------------------------------------- 1 | name: Keepalive 2 | 3 | on: 4 | # At 7:35 AM UTC, on day 28 of the month 5 | schedule: 6 | - cron: '35 7 28 * *' 7 | workflow_dispatch: 8 | 9 | permissions: {} 10 | 11 | jobs: 12 | keepalive: 13 | name: Keepalive 14 | # Only run cron on the silverstripe account 15 | if: (github.event_name == 'schedule' && github.repository_owner == 'silverstripe') || (github.event_name != 'schedule') 16 | runs-on: ubuntu-latest 17 | permissions: 18 | actions: write 19 | steps: 20 | - name: Keepalive 21 | uses: silverstripe/gha-keepalive@v1 22 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | tests/ 6 | 7 | 8 | 9 | 10 | code/ 11 | 12 | tests/ 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Security Vulnerability 4 | url: https://docs.silverstripe.org/en/contributing/issues_and_bugs/#reporting-security-issues 5 | about: ⚠️ We do not use GitHub issues to track security vulnerability reports. Click "open" on the right to see how to report security vulnerabilities. 6 | - name: Support Question 7 | url: https://www.silverstripe.org/community/ 8 | about: We use GitHub issues only to discuss bugs and new features. For support questions, please use one of the support options available in our community channels. 9 | -------------------------------------------------------------------------------- /.github/workflows/dispatch-ci.yml: -------------------------------------------------------------------------------- 1 | name: Dispatch CI 2 | 3 | on: 4 | # At 10:50 AM UTC, only on Saturday and Sunday 5 | schedule: 6 | - cron: '50 10 * * 6,0' 7 | 8 | permissions: {} 9 | 10 | jobs: 11 | dispatch-ci: 12 | name: Dispatch CI 13 | # Only run cron on the silverstripe account 14 | if: (github.event_name == 'schedule' && github.repository_owner == 'silverstripe') || (github.event_name != 'schedule') 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: read 18 | actions: write 19 | steps: 20 | - name: Dispatch CI 21 | uses: silverstripe/gha-dispatch-ci@v1 22 | -------------------------------------------------------------------------------- /.github/workflows/merge-up.yml: -------------------------------------------------------------------------------- 1 | name: Merge-up 2 | 3 | on: 4 | # At 10:50 AM UTC, only on Wednesday 5 | schedule: 6 | - cron: '50 10 * * 3' 7 | workflow_dispatch: 8 | 9 | permissions: {} 10 | 11 | jobs: 12 | merge-up: 13 | name: Merge-up 14 | # Only run cron on the silverstripe account 15 | if: (github.event_name == 'schedule' && github.repository_owner == 'silverstripe') || (github.event_name != 'schedule') 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: write 19 | actions: write 20 | steps: 21 | - name: Merge-up 22 | uses: silverstripe/gha-merge-up@v1 23 | -------------------------------------------------------------------------------- /.github/workflows/add-prs-to-project.yml: -------------------------------------------------------------------------------- 1 | name: Add new PRs to github project 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - ready_for_review 8 | 9 | permissions: {} 10 | 11 | jobs: 12 | addprtoproject: 13 | name: Add PR to GitHub Project 14 | # Only run on the silverstripe account 15 | if: github.repository_owner == 'silverstripe' 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Add PR to github project 19 | uses: silverstripe/gha-add-pr-to-project@v1 20 | with: 21 | app_id: ${{ vars.PROJECT_PERMISSIONS_APP_ID }} 22 | private_key: ${{ secrets.PROJECT_PERMISSIONS_APP_PRIVATE_KEY }} 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | ci: 10 | name: CI 11 | # Do not run if this is a pull-request from same repo i.e. not a fork repo 12 | if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository 13 | uses: silverstripe/gha-ci/.github/workflows/ci.yml@v1 14 | with: 15 | extra_jobs: | 16 | - composer_require_extra: silverstripe/mfa:^6 17 | name_suffix: 'mfa' 18 | phpunit: true 19 | - composer_require_extra: silverstripe/session-manager:^3 20 | name_suffix: 'session-manager' 21 | phpunit: true 22 | - composer_require_extra: silverstripe/subsites:^4 23 | name_suffix: 'subsites' 24 | -------------------------------------------------------------------------------- /.github/workflows/tag-patch-release.yml: -------------------------------------------------------------------------------- 1 | name: Tag patch release 2 | 3 | on: 4 | # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch 5 | workflow_dispatch: 6 | inputs: 7 | latest_local_sha: 8 | description: The latest local sha 9 | required: true 10 | type: string 11 | 12 | permissions: {} 13 | 14 | jobs: 15 | tagpatchrelease: 16 | name: Tag patch release 17 | # Only run cron on the silverstripe account 18 | if: (github.event_name == 'schedule' && github.repository_owner == 'silverstripe') || (github.event_name != 'schedule') 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: write 22 | steps: 23 | - name: Tag release 24 | uses: silverstripe/gha-tag-release@v2 25 | with: 26 | latest_local_sha: ${{ inputs.latest_local_sha }} 27 | -------------------------------------------------------------------------------- /_config/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: auditor 3 | --- 4 | SilverStripe\Core\Injector\Injector: 5 | AuditLogger: 6 | class: AuditLogger 7 | factory: SilverStripe\Auditor\AuditFactory 8 | SilverStripe\ORM\ManyManyList: 9 | class: SilverStripe\Auditor\AuditHookManyManyList 10 | SilverStripe\Security\Member_GroupSet: 11 | class: SilverStripe\Auditor\AuditHookMemberGroupSet 12 | 13 | SilverStripe\Control\Controller: 14 | extensions: 15 | - SilverStripe\Auditor\AuditHook 16 | 17 | SilverStripe\Security\Member: 18 | extensions: 19 | - SilverStripe\Auditor\AuditHook 20 | 21 | SilverStripe\ORM\Connect\Database: 22 | extensions: 23 | - SilverStripe\Auditor\Extensions\DatabaseExtension 24 | 25 | --- 26 | Name: auditorcms 27 | Only: 28 | moduleexists: silverstripe/cms 29 | --- 30 | SilverStripe\CMS\Model\SiteTree: 31 | extensions: 32 | - SilverStripe\Auditor\AuditHook 33 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "silverstripe/auditor", 3 | "description": "Adds security audit trail to SilverStripe.", 4 | "type": "silverstripe-vendormodule", 5 | "keywords": [ 6 | "silverstripe", 7 | "audit" 8 | ], 9 | "license": "BSD-3-Clause", 10 | "require": { 11 | "php": "^8.3", 12 | "silverstripe/framework": "^6", 13 | "monolog/monolog": "^3.2", 14 | "psr/log": "^3" 15 | }, 16 | "require-dev": { 17 | "phpunit/phpunit": "^11.3", 18 | "squizlabs/php_codesniffer": "^3", 19 | "silverstripe/versioned": "^3", 20 | "silverstripe/standards": "^1", 21 | "phpstan/extension-installer": "^1.3" 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "SilverStripe\\Auditor\\": "code/", 26 | "SilverStripe\\Auditor\\Tests\\": "tests/" 27 | } 28 | }, 29 | "extra": [], 30 | "minimum-stability": "dev", 31 | "prefer-stable": true 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Silverstripe Limited 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 13 | -------------------------------------------------------------------------------- /code/AuditHookSessionManager.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class AuditHookSessionManager extends Extension 18 | { 19 | /** 20 | * Login session for a member is being removed 21 | * 22 | * @param LoginSession $loginSession 23 | */ 24 | protected function onBeforeRemoveLoginSession(LoginSession $loginSession) 25 | { 26 | $member = $loginSession->Member(); 27 | $currentUser = Security::getCurrentUser(); 28 | if (is_null($member) || $member->ID === 0 || is_null($currentUser) || $currentUser->ID === 0) { 29 | return; 30 | } 31 | $this->getAuditLogger()->info(sprintf( 32 | 'Login session (ID: %s) for Member "%s" (ID: %s) is being removed by Member "%s" (ID: %s)', 33 | $loginSession->ID, 34 | $member->Email ?: $member->Title, 35 | $member->ID, 36 | $currentUser->Email ?: $currentUser->Title, 37 | $currentUser->ID 38 | )); 39 | } 40 | 41 | /** 42 | * @return LoggerInterface 43 | */ 44 | protected function getAuditLogger() 45 | { 46 | return Injector::inst()->get('AuditLogger'); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2_feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature Request 2 | description: Submit a feature request (but only if you're planning on implementing it) 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | Please only submit feature requests if you plan on implementing the feature yourself. 8 | See the [contributing code documentation](https://docs.silverstripe.org/en/contributing/code/#make-or-find-a-github-issue) for more guidelines about submitting feature requests. 9 | - type: textarea 10 | id: description 11 | attributes: 12 | label: Description 13 | description: A clear and concise description of the new feature, and why it belongs in core 14 | validations: 15 | required: true 16 | - type: textarea 17 | id: more-info 18 | attributes: 19 | label: Additional context or points of discussion 20 | description: | 21 | *Optional: Any additional context, points of discussion, etc that might help validate and refine your idea* 22 | - type: checkboxes 23 | id: validations 24 | attributes: 25 | label: Validations 26 | description: "Before submitting the issue, please confirm the following:" 27 | options: 28 | - label: You intend to implement the feature yourself 29 | required: true 30 | - label: You have read the [contributing guide](https://docs.silverstripe.org/en/contributing/code/) 31 | required: true 32 | - label: You strongly believe this feature should be in core, rather than being its own community module 33 | required: true 34 | - label: You have checked for existing issues or pull requests related to this feature (and didn't find any) 35 | required: true 36 | -------------------------------------------------------------------------------- /code/AuditFactory.php: -------------------------------------------------------------------------------- 1 | pushProcessor(new WebProcessor($_SERVER, [ 31 | 'url' => 'REQUEST_URI', 32 | 'http_method' => 'REQUEST_METHOD', 33 | 'server' => 'SERVER_NAME', 34 | 'referrer' => 'HTTP_REFERER', 35 | ])); 36 | 37 | $syslog->pushProcessor(new RealIPProcessor()); 38 | $formatter = new LineFormatter("%level_name%: %message% %context% %extra%"); 39 | $syslog->setFormatter($formatter); 40 | $log->pushHandler($syslog); 41 | 42 | return $log; 43 | default: 44 | throw new Exception(sprintf("AuditFactory does not support creation of '%s'.", $service)); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /code/AuditHookMemberGroupSet.php: -------------------------------------------------------------------------------- 1 | Groups()->getJoinTable(); 26 | 27 | if ($this->getJoinTable() === $memberGroupJoinTable) { 28 | $currentMember = Security::getCurrentUser(); 29 | if (!$currentMember || !$currentMember->exists()) { 30 | return; 31 | } 32 | 33 | $group = Group::get()->byId($itemID); 34 | $member = Member::get()->byId($this->getForeignID()); 35 | 36 | if (!$group) { 37 | return; 38 | } 39 | if (!$member) { 40 | return; 41 | } 42 | 43 | $this->getAuditLogger()->info(sprintf( 44 | '"%s" (ID: %s) removed Member "%s" (ID: %s) from Group "%s" (ID: %s)', 45 | $currentMember->Email ?: $currentMember->Title, 46 | $currentMember->ID, 47 | $member->Email ?: $member->Title, 48 | $member->ID, 49 | $group->Title, 50 | $group->ID 51 | )); 52 | } 53 | } 54 | 55 | protected function getAuditLogger() 56 | { 57 | // See note on AuditHook::getAuditLogger 58 | return Injector::inst()->get('AuditLogger'); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /code/AuditHookManyManyList.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class AuditHookManyManyList extends ManyManyList 20 | { 21 | /** 22 | * Overload {@link ManyManyList::removeByID()} so we can log 23 | * when a Member is removed from a Group. 24 | */ 25 | public function removeByID($itemID) 26 | { 27 | parent::removeByID($itemID); 28 | 29 | $memberGroupJoinTable = Member::singleton()->Groups()->getJoinTable(); 30 | 31 | if ($this->getJoinTable() === $memberGroupJoinTable) { 32 | $currentMember = Security::getCurrentUser(); 33 | if (!($currentMember && $currentMember->exists())) { 34 | return; 35 | } 36 | 37 | $member = Member::get()->byId($itemID); 38 | $group = Group::get()->byId($this->getForeignID()); 39 | 40 | if (!$group) { 41 | return; 42 | } 43 | if (!$member) { 44 | return; 45 | } 46 | 47 | $this->getAuditLogger()->info(sprintf( 48 | '"%s" (ID: %s) removed Member "%s" (ID: %s) from Group "%s" (ID: %s)', 49 | $currentMember->Email ?: $currentMember->Title, 50 | $currentMember->ID, 51 | $member->Email ?: $member->Title, 52 | $member->ID, 53 | $group->Title, 54 | $group->ID 55 | )); 56 | } 57 | } 58 | 59 | protected function getAuditLogger() 60 | { 61 | // See note on AuditHook::getAuditLogger 62 | return Injector::inst()->get('AuditLogger'); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 6 | ## Description 7 | 11 | 12 | ## Manual testing steps 13 | 17 | 18 | ## Issues 19 | 23 | - # 24 | 25 | ## Pull request checklist 26 | 30 | - [ ] The target branch is correct 31 | - See [picking the right version](https://docs.silverstripe.org/en/contributing/code/#picking-the-right-version) 32 | - [ ] All commits are relevant to the purpose of the PR (e.g. no debug statements, unrelated refactoring, or arbitrary linting) 33 | - Small amounts of additional linting are usually okay, but if it makes it hard to concentrate on the relevant changes, ask for the unrelated changes to be reverted, and submitted as a separate PR. 34 | - [ ] The commit messages follow our [commit message guidelines](https://docs.silverstripe.org/en/contributing/code/#commit-messages) 35 | - [ ] The PR follows our [contribution guidelines](https://docs.silverstripe.org/en/contributing/code/) 36 | - [ ] Code changes follow our [coding conventions](https://docs.silverstripe.org/en/contributing/coding_conventions/) 37 | - [ ] This change is covered with tests (or tests aren't necessary for this change) 38 | - [ ] Any relevant User Help/Developer documentation is updated; for impactful changes, information is added to the changelog for the intended release 39 | - [ ] CI is green 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Silverstripe Auditor 2 | 3 | [![CI](https://github.com/silverstripe/silverstripe-auditor/actions/workflows/ci.yml/badge.svg)](https://github.com/silverstripe/silverstripe-auditor/actions/workflows/ci.yml) 4 | 5 | Auditor module installs a series of extension hooks into the Framework to monitor activity of authenticated users. Audit 6 | trail is written into `LOG_AUTH` syslog facility through [Monolog](https://github.com/Seldaek/monolog/), and includes: 7 | 8 | * Login attempts (failed and successful) 9 | * Logouts 10 | * Page manipulations that may potentially affect the live site 11 | * Security-related changes such as Members being added to groups or permission changes. 12 | 13 | ## Installation 14 | 15 | ```sh 16 | composer require silverstripe/auditor 17 | ``` 18 | 19 | ## Custom audit trail 20 | 21 | You can add your own logs to the audit trail by accessing the `AuditLogger`, which is easiest done through the Injector: 22 | 23 | ```php 24 | use SilverStripe\CMS\Controllers\ContentController; 25 | 26 | class MyPageController extends ContentController 27 | { 28 | private static $dependencies = [ 29 | 'auditLogger' => '%$AuditLogger' 30 | ]; 31 | } 32 | ``` 33 | 34 | AuditLogger is guaranteed to implement the [PSR-3 LoggerInterface](https://github.com/php-fig/log/blob/1.0.2/Psr/Log/LoggerInterface.php), 35 | events can be logged at multiple levels, with arbitrary context: 36 | 37 | ```php 38 | public function dostuff() 39 | { 40 | $this->auditLogger->info('stuff happened'); 41 | // You can also pass an arbitrary context array which will be included in the log. 42 | $this->auditLogger->warn('stuff happened', ['defcon' => 'amber']); 43 | } 44 | ``` 45 | 46 | Here is what will appear in the audit log on your dev machine (the exact format will depend on your operating system): 47 | 48 | ``` 49 | Aug 24 11:09:02 SilverStripe_audit[80615]: stuff happened [] {"real_ip":"127.0.0.1","url":"/do-stuff/","http_method":"GET","server":"localhost","referrer":null} 50 | Aug 24 11:09:02 SilverStripe_audit[80615]: stuff happened {"defcon":"amber"} {"real_ip":"127.0.0.1","url":"/do-stuff/","http_method":"GET","server":"localhost","referrer":null} 51 | ``` 52 | 53 | ## Troubleshooting 54 | 55 | We are using a dynamically generated class for capturing database manipulation events. This class is cached, and in 56 | some cases it may retain an old, incorrect version of the class. You can wipe it by removing your cache, specifically 57 | the file called `//.cache.CLC.SearchManipulateCapture_SilverStripeORMConnectMySQLDatabase`. 58 | 59 | ## Contributing 60 | 61 | Submitting a pull-request gives the highest likelihood of getting a bug fixed or a feature added. 62 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1_bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🪳 Bug Report 2 | description: Tell us if something isn't working the way it's supposed to 3 | 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | We strongly encourage you to [submit a pull request](https://docs.silverstripe.org/en/contributing/code/) which fixes the issue. 9 | Bug reports which are accompanied with a pull request are a lot more likely to be resolved quickly. 10 | - type: input 11 | id: affected-versions 12 | attributes: 13 | label: Module version(s) affected 14 | description: | 15 | What version of _this module_ have you reproduced this bug on? 16 | Run `composer info` to see the specific version of each module installed in your project. 17 | If you don't have access to that, check inside the help menu in the bottom left of the CMS. 18 | placeholder: x.y.z 19 | validations: 20 | required: true 21 | - type: textarea 22 | id: description 23 | attributes: 24 | label: Description 25 | description: A clear and concise description of the problem 26 | validations: 27 | required: true 28 | - type: textarea 29 | id: how-to-reproduce 30 | attributes: 31 | label: How to reproduce 32 | description: | 33 | ⚠️ This is the most important part of the report ⚠️ 34 | Without a way to easily reproduce your issue, there is little chance we will be able to help you and work on a fix. 35 | - Please, take the time to show us some code and/or configuration that is needed for others to reproduce the problem easily. 36 | - If the bug is too complex to reproduce with some short code samples, please reproduce it in a public repository and provide a link to the repository along with steps for setting up and reproducing the bug using that repository. 37 | - If part of the bug includes an error or exception, please provide a full stack trace. 38 | - If any user interaction is required to reproduce the bug, please add an ordered list of steps that are required to reproduce it. 39 | - Be as clear as you can, but don't miss any steps out. Simply saying "create a page" is less useful than guiding us through the steps you're taking to create a page, for example. 40 | placeholder: | 41 | 42 | #### Code sample 43 | ```php 44 | 45 | ``` 46 | 47 | #### Reproduction steps 48 | 1. 49 | validations: 50 | required: true 51 | - type: textarea 52 | id: possible-solution 53 | attributes: 54 | label: Possible Solution 55 | description: | 56 | *Optional: only if you have suggestions on a fix/reason for the bug* 57 | Please consider [submitting a pull request](https://docs.silverstripe.org/en/contributing/code/) with your solution! It helps get faster feedback and greatly increases the chance of the bug being fixed. 58 | - type: textarea 59 | id: additional-context 60 | attributes: 61 | label: Additional Context 62 | description: "*Optional: any other context about the problem: log messages, screenshots, etc.*" 63 | - type: checkboxes 64 | id: validations 65 | attributes: 66 | label: Validations 67 | description: "Before submitting the issue, please make sure you do the following:" 68 | options: 69 | - label: Check that there isn't already an issue that reports the same bug 70 | required: true 71 | - label: Double check that your reproduction steps work in a fresh installation of [`silverstripe/installer`](https://github.com/silverstripe/silverstripe-installer) (with any code examples you've provided) 72 | required: true 73 | -------------------------------------------------------------------------------- /code/AuditHookMFA.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | class AuditHookMFA extends Extension 19 | { 20 | /** 21 | * A successful login using an MFA method 22 | * 23 | * @param Member $member 24 | * @param MethodInterface $method 25 | */ 26 | protected function onMethodVerificationSuccess(Member $member, $method) 27 | { 28 | $this->getAuditLogger()->info( 29 | sprintf( 30 | '"%s" (ID: %s) successfully verified using MFA method', 31 | $member->Email ?: $member->Title, 32 | $member->ID 33 | ), 34 | ['method' => get_class($method)] 35 | ); 36 | } 37 | 38 | /** 39 | * A failed login using an MFA method 40 | * 41 | * @param Member $member 42 | * @param MethodInterface $method 43 | */ 44 | protected function onMethodVerificationFailure(Member $member, $method) 45 | { 46 | $context = [ 47 | 'method' => get_class($method), 48 | ]; 49 | if ($lockOutAfterCount = $member->config()->get('lock_out_after_incorrect_logins')) { 50 | // Add information about how many attempts have been made 51 | $context['attempts'] = $member->FailedLoginCount; 52 | $context['attempt_limit'] = $lockOutAfterCount; 53 | } 54 | 55 | $this->getAuditLogger()->info(sprintf( 56 | '"%s" (ID: %s) failed to verify using MFA method', 57 | $member->Email ?: $member->Title, 58 | $member->ID 59 | ), $context); 60 | } 61 | 62 | /** 63 | * A user has skipped MFA registration when it is enabled but optional, or within a grace period 64 | * 65 | * @param Member $member 66 | */ 67 | protected function onSkipRegistration(Member $member) 68 | { 69 | $this->getAuditLogger()->info(sprintf( 70 | '"%s" (ID: %s) skipped MFA registration', 71 | $member->Email ?: $member->Title, 72 | $member->ID 73 | )); 74 | } 75 | 76 | /** 77 | * @param Member $member 78 | * @param MethodInterface $method 79 | */ 80 | protected function onRegisterMethod(Member $member, $method) 81 | { 82 | $context = [ 83 | 'method' => get_class($method), 84 | ]; 85 | 86 | $this->getAuditLogger()->info(sprintf( 87 | '"%s" (ID: %s) registered MFA method', 88 | $member->Email ?: $member->Title, 89 | $member->ID 90 | ), $context); 91 | } 92 | 93 | /** 94 | * A user has failed to register an MFA method against their account 95 | * 96 | * @param Member $member 97 | * @param MethodInterface $method 98 | */ 99 | protected function onRegisterMethodFailure(Member $member, $method) 100 | { 101 | $context = [ 102 | 'method' => get_class($method), 103 | ]; 104 | 105 | $this->getAuditLogger()->info(sprintf( 106 | '"%s" (ID: %s) failed registering new MFA method', 107 | $member->Email ?: $member->Title, 108 | $member->ID 109 | ), $context); 110 | } 111 | 112 | /** 113 | * @return LoggerInterface 114 | */ 115 | protected function getAuditLogger() 116 | { 117 | return Injector::inst()->get('AuditLogger'); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /code/AuditHook.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | class AuditHook extends Extension 25 | { 26 | protected function getAuditLogger() 27 | { 28 | // We cannot use the 'dependencies' private property, because this will prevent us 29 | // from injecting a mock logger for testing. This is because by the time the testing framework 30 | // is instantiated, the part of the object graph where AuditLogger lives has already been created. 31 | // In other words, Framework does not permit hooking in early enough to adjust the graph when 32 | // 'dependencies' is used :-( 33 | return Injector::inst()->get('AuditLogger'); 34 | } 35 | 36 | public static function handle_manipulation($manipulation) 37 | { 38 | $auditLogger = Injector::inst()->get('AuditLogger'); 39 | 40 | $currentMember = Security::getCurrentUser(); 41 | if (!($currentMember && $currentMember->exists())) { 42 | return false; 43 | } 44 | 45 | $schema = DataObject::getSchema(); 46 | 47 | // The tables that we watch for manipulation on 48 | $watchedTables = [ 49 | $schema->tableName(Member::class), 50 | $schema->tableName(Group::class), 51 | $schema->tableName(PermissionRole::class), 52 | $schema->tableName(PermissionRoleCode::class), 53 | ]; 54 | 55 | foreach ($manipulation as $table => $details) { 56 | if (!in_array($details['command'], ['update', 'insert'])) { 57 | continue; 58 | } 59 | 60 | // logging writes to specific tables (just not when logging in, as it's noise) 61 | if (in_array($table, $watchedTables ?? []) 62 | && !preg_match('/Security/', @$_SERVER['REQUEST_URI']) 63 | && isset($details['id']) 64 | ) { 65 | $className = $schema->tableClass($table); 66 | 67 | $data = $className::get()->byID($details['id']); 68 | if (!$data) { 69 | continue; 70 | } 71 | $actionText = 'modified '.$table; 72 | 73 | $extendedText = ''; 74 | if ($table === $schema->tableName(Group::class)) { 75 | $extendedText = sprintf( 76 | 'Effective permissions: %s', 77 | implode(', ', $data->Permissions()->column('Code')) 78 | ); 79 | } 80 | if ($table === $schema->tableName(PermissionRole::class)) { 81 | $extendedText = sprintf( 82 | 'Effective groups: %s, Effective permissions: %s', 83 | implode(', ', $data->Groups()->column('Title')), 84 | implode(', ', $data->Codes()->column('Code')) 85 | ); 86 | } 87 | if ($table === $schema->tableName(PermissionRoleCode::class)) { 88 | $extendedText = sprintf( 89 | 'Effective code: %s', 90 | $data->Code 91 | ); 92 | } 93 | if ($table === $schema->tableName(Member::class)) { 94 | $extendedText = sprintf( 95 | 'Effective groups: %s', 96 | implode(', ', $data->Groups()->column('Title')) 97 | ); 98 | } 99 | 100 | $auditLogger->info(sprintf( 101 | '"%s" (ID: %s) %s (ID: %s, ClassName: %s, Title: "%s", %s)', 102 | $currentMember->Email ?: $currentMember->Title, 103 | $currentMember->ID, 104 | $actionText, 105 | $details['id'], 106 | $data->ClassName, 107 | $data->Title, 108 | $extendedText 109 | )); 110 | } 111 | 112 | // log PermissionRole being added to a Group 113 | if ($table === $schema->tableName(Group::class) . '_Roles') { 114 | $role = PermissionRole::get()->byId($details['fields']['PermissionRoleID']); 115 | $group = Group::get()->byId($details['fields']['GroupID']); 116 | 117 | // if the permission role isn't already applied to the group 118 | if (!DB::query(sprintf( 119 | 'SELECT "ID" FROM "Group_Roles" WHERE "GroupID" = %s AND "PermissionRoleID" = %s', 120 | $details['fields']['GroupID'], 121 | $details['fields']['PermissionRoleID'] 122 | ))->value()) { 123 | $auditLogger->info(sprintf( 124 | '"%s" (ID: %s) added PermissionRole "%s" (ID: %s) to Group "%s" (ID: %s)', 125 | $currentMember->Email ?: $currentMember->Title, 126 | $currentMember->ID, 127 | $role->Title, 128 | $role->ID, 129 | $group->Title, 130 | $group->ID 131 | )); 132 | } 133 | } 134 | 135 | // log Member added to a Group 136 | if ($table === $schema->tableName(Group::class) . '_Members') { 137 | $member = Member::get()->byId($details['fields']['MemberID']); 138 | $group = Group::get()->byId($details['fields']['GroupID']); 139 | 140 | // if the user isn't already in the group, log they've been added 141 | if (!DB::query(sprintf( 142 | 'SELECT "ID" FROM "Group_Members" WHERE "GroupID" = %s AND "MemberID" = %s', 143 | $details['fields']['GroupID'], 144 | $details['fields']['MemberID'] 145 | ))->value()) { 146 | $auditLogger->info(sprintf( 147 | '"%s" (ID: %s) added Member "%s" (ID: %s) to Group "%s" (ID: %s)', 148 | $currentMember->Email ?: $currentMember->Title, 149 | $currentMember->ID, 150 | $member->Email ?: $member->Title, 151 | $member->ID, 152 | $group->Title, 153 | $group->ID 154 | )); 155 | } 156 | } 157 | } 158 | } 159 | 160 | /** 161 | * Log a record being published. 162 | */ 163 | protected function onAfterPublish(&$original) 164 | { 165 | $member = Security::getCurrentUser(); 166 | if (!$member || !$member->exists()) { 167 | return false; 168 | } 169 | 170 | $effectiveViewerGroups = ''; 171 | if ($this->owner->CanViewType === 'OnlyTheseUsers') { 172 | $originalViewerGroups = $original ? $original->ViewerGroups()->column('Title') : []; 173 | $effectiveViewerGroups = implode(', ', $originalViewerGroups); 174 | } 175 | if (!$effectiveViewerGroups) { 176 | $effectiveViewerGroups = $this->owner->CanViewType; 177 | } 178 | 179 | $effectiveEditorGroups = ''; 180 | if ($this->owner->CanEditType === 'OnlyTheseUsers') { 181 | $originalEditorGroups = $original ? $original->EditorGroups()->column('Title') : []; 182 | $effectiveEditorGroups = implode(', ', $originalEditorGroups); 183 | } 184 | if (!$effectiveEditorGroups) { 185 | $effectiveEditorGroups = $this->owner->CanEditType; 186 | } 187 | 188 | $this->getAuditLogger()->info(sprintf( 189 | '"%s" (ID: %s) published %s "%s" (ID: %s, Version: %s, ClassName: %s, Effective ViewerGroups: %s, ' 190 | . 'Effective EditorGroups: %s)', 191 | $member->Email ?: $member->Title, 192 | $member->ID, 193 | $this->owner->singular_name(), 194 | $this->owner->Title, 195 | $this->owner->ID, 196 | $this->owner->Version, 197 | $this->owner->ClassName, 198 | $effectiveViewerGroups, 199 | $effectiveEditorGroups 200 | )); 201 | } 202 | 203 | /** 204 | * Log a record being unpublished. 205 | */ 206 | protected function onAfterUnpublish() 207 | { 208 | $member = Security::getCurrentUser(); 209 | if (!$member || !$member->exists()) { 210 | return false; 211 | } 212 | 213 | $this->getAuditLogger()->info(sprintf( 214 | '"%s" (ID: %s) unpublished %s "%s" (ID: %s)', 215 | $member->Email ?: $member->Title, 216 | $member->ID, 217 | $this->owner->singular_name(), 218 | $this->owner->Title, 219 | $this->owner->ID 220 | )); 221 | } 222 | 223 | /** 224 | * Log a record being reverted to live. 225 | */ 226 | protected function onAfterRevertToLive() 227 | { 228 | $member = Security::getCurrentUser(); 229 | if (!$member || !$member->exists()) { 230 | return false; 231 | } 232 | 233 | $this->getAuditLogger()->info(sprintf( 234 | '"%s" (ID: %s) reverted %s "%s" (ID: %s) to it\'s live version (#%d)', 235 | $member->Email ?: $member->Title, 236 | $member->ID, 237 | $this->owner->singular_name(), 238 | $this->owner->Title, 239 | $this->owner->ID, 240 | $this->owner->Version 241 | )); 242 | } 243 | 244 | /** 245 | * Log a record being duplicated. 246 | */ 247 | protected function onAfterDuplicate() 248 | { 249 | $member = Security::getCurrentUser(); 250 | if (!$member || !$member->exists()) { 251 | return false; 252 | } 253 | 254 | $this->getAuditLogger()->info(sprintf( 255 | '"%s" (ID: %s) duplicated %s "%s" (ID: %s)', 256 | $member->Email ?: $member->Title, 257 | $member->ID, 258 | $this->owner->singular_name(), 259 | $this->owner->Title, 260 | $this->owner->ID 261 | )); 262 | } 263 | 264 | /** 265 | * Log a record being deleted. 266 | */ 267 | protected function onAfterDelete() 268 | { 269 | $member = Security::getCurrentUser(); 270 | if (!$member || !$member->exists()) { 271 | return false; 272 | } 273 | 274 | $this->getAuditLogger()->info(sprintf( 275 | '"%s" (ID: %s) deleted %s "%s" (ID: %s)', 276 | $member->Email ?: $member->Title, 277 | $member->ID, 278 | $this->owner->singular_name(), 279 | $this->owner->Title, 280 | $this->owner->ID 281 | )); 282 | } 283 | 284 | /** 285 | * Log a record being restored to stage. 286 | */ 287 | protected function onAfterRestoreToStage() 288 | { 289 | $member = Security::getCurrentUser(); 290 | if (!$member || !$member->exists()) { 291 | return false; 292 | } 293 | 294 | $this->getAuditLogger()->info(sprintf( 295 | '"%s" (ID: %s) restored %s "%s" to stage (ID: %s)', 296 | $member->Email ?: $member->Title, 297 | $member->ID, 298 | $this->owner->singular_name(), 299 | $this->owner->Title, 300 | $this->owner->ID 301 | )); 302 | } 303 | 304 | /** 305 | * Log successful login attempts. 306 | */ 307 | protected function onAfterMemberLoggedIn() 308 | { 309 | $this->getAuditLogger()->info(sprintf( 310 | '"%s" (ID: %s) successfully logged in', 311 | $this->owner->Email ?: $this->owner->Title, 312 | $this->owner->ID 313 | )); 314 | } 315 | 316 | /** 317 | * Log successfully restored sessions from "remember me" cookies ("auto login"). 318 | */ 319 | protected function memberAutoLoggedIn() 320 | { 321 | $this->getAuditLogger()->info(sprintf( 322 | '"%s" (ID: %s) successfully restored autologin session', 323 | $this->owner->Email ?: $this->owner->Title, 324 | $this->owner->ID 325 | )); 326 | } 327 | 328 | /** 329 | * Log failed login attempts. 330 | */ 331 | protected function onAuthenticationFailed($data) 332 | { 333 | // LDAP authentication uses a "Login" POST field instead of Email. 334 | $login = isset($data['Login']) 335 | ? $data['Login'] 336 | : (isset($data['Email']) ? $data['Email'] : ''); 337 | 338 | if (empty($login)) { 339 | return $this->getAuditLogger()->warning( 340 | 'Could not determine username/email of failed authentication. '. 341 | 'This could be due to login form not using Email or Login field for POST data.' 342 | ); 343 | } 344 | 345 | $this->getAuditLogger()->info(sprintf('Failed login attempt using email "%s"', $login)); 346 | } 347 | 348 | /** 349 | * Log failed login attempts when the email address doesn't map to an existing member record 350 | */ 351 | protected function onAuthenticationFailedUnknownUser($data) 352 | { 353 | $this->onAuthenticationFailed($data); 354 | } 355 | 356 | /** 357 | * Log permission failures (where the status is set after init of page). 358 | */ 359 | protected function onAfterInit() 360 | { 361 | // Suppress errors if the database needs to be built 362 | if (!Security::database_is_ready()) { 363 | return false; 364 | } 365 | $currentMember = Security::getCurrentUser(); 366 | if (!$currentMember || !$currentMember->exists()) { 367 | return false; 368 | } 369 | 370 | $statusCode = $this->owner->getResponse()->getStatusCode(); 371 | 372 | if (substr($statusCode ?? '', 0, 1) == '4') { 373 | $this->logPermissionDenied($statusCode, $currentMember); 374 | } 375 | } 376 | 377 | protected function logPermissionDenied($statusCode, $member) 378 | { 379 | $this->getAuditLogger()->info(sprintf( 380 | 'HTTP code %s - "%s" (ID: %s) is denied access to %s', 381 | $statusCode, 382 | $member->Email ?: $member->Title, 383 | $member->ID, 384 | $_SERVER['REQUEST_URI'] 385 | )); 386 | } 387 | 388 | /** 389 | * Log successful logout. 390 | */ 391 | protected function onAfterMemberLoggedOut() 392 | { 393 | $this->getAuditLogger()->info(sprintf( 394 | '"%s" (ID: %s) successfully logged out', 395 | $this->owner->Email ?: $this->owner->Title, 396 | $this->owner->ID 397 | )); 398 | } 399 | } 400 | --------------------------------------------------------------------------------