feat: initial commit

This commit is contained in:
2025-07-28 15:00:37 +03:00
commit 8c67190431
38 changed files with 8541 additions and 0 deletions

19
.editorconfig Normal file
View File

@@ -0,0 +1,19 @@
# EditorConfig helps maintain consistent coding styles across editors and IDEs
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
max_line_length = 100
[*.md]
trim_trailing_whitespace = false
max_line_length = 120
[*.{md,json,yml,yaml,xml}]
indent_size = 2

2
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,2 @@
# Global owners for the repository
* @ivuorinen

145
.github/CODE_OF_CONDUCT.md vendored Normal file
View File

@@ -0,0 +1,145 @@
# Citizen Code of Conduct
## 1. Purpose
A primary goal of @ivuorinen's repositories is to be inclusive to the largest
number of contributors, with the most varied and diverse backgrounds possible.
As such, we are committed to providing a friendly, safe and welcoming
environment for all, regardless of gender, sexual orientation, ability,
ethnicity, socioeconomic status, and religion (or lack thereof).
This code of conduct outlines our expectations for all those who participate in
our community, as well as the consequences for unacceptable behavior.
We invite all those who participate in @ivuorinen's repositories to help us
create safe and positive experiences for everyone.
## 2. Open [Source/Culture/Tech] Citizenship
A supplemental goal of this Code of Conduct is to increase
open [source/culture/tech] citizenship by encouraging participants to recognize
and strengthen the relationships between our actions and their effects on our
community.
Communities mirror the societies in which they exist and positive action is
essential to counteract the many forms of inequality and abuses of power that
exist in society.
If you see someone who is making an extra effort to ensure our community is
welcoming, friendly, and encourages all participants to contribute to the
fullest extent, we want to know.
## 3. Expected Behavior
The following behaviors are expected and requested of all community members:
* Participate in an authentic and active way. In doing so, you contribute to the
health and longevity of this community.
* Exercise consideration and respect in your speech and actions.
* Attempt collaboration before conflict.
* Refrain from demeaning, discriminatory, or harassing behavior and speech.
* Be mindful of your surroundings and of your fellow participants. Alert
community leaders if you notice a dangerous situation, someone in distress, or
violations of this Code of Conduct, even if they seem inconsequential.
* Remember that community event venues may be shared with members of the public;
please be respectful to all patrons of these locations.
## 4. Unacceptable Behavior
The following behaviors are considered harassment and are unacceptable within
our community:
* Violence, threats of violence or violent language directed against another
person.
* Sexist, racist, homophobic, transphobic, ableist or otherwise discriminatory
jokes and language.
* Posting or displaying sexually explicit or violent material.
* Posting or threatening to post other people's personally identifying
information ("doxing").
* Personal insults, particularly those related to gender, sexual orientation,
race, religion, or disability.
* Inappropriate photography or recording.
* Inappropriate physical contact. You should have someone's consent before
touching them.
* Unwelcome sexual attention. This includes, sexualized comments or jokes;
inappropriate touching, groping, and unwelcomed sexual advances.
* Deliberate intimidation, stalking or following (online or in person).
* Advocating for, or encouraging, any of the above behavior.
* Sustained disruption of community events, including talks and presentations.
## 5. Weapons Policy
No weapons will be allowed at @ivuorinen's repositories events, community
spaces, or in other spaces covered by the scope of this Code of Conduct. Weapons
include but are not limited to guns, explosives (including fireworks), and large
knives such as those used for hunting or display, as well as any other item used
for the purpose of causing injury or harm to others. Anyone seen in possession
of one of these items will be asked to leave immediately, and will only be
allowed to return without the weapon. Community members are further expected to
comply with all state and local laws on this matter.
## 6. Consequences of Unacceptable Behavior
Unacceptable behavior from any community member, including sponsors and those
with decision-making authority, will not be tolerated.
Anyone asked to stop unacceptable behavior is expected to comply immediately.
If a community member engages in unacceptable behavior, the community organizers
may take any action they deem appropriate, up to and including a temporary ban
or permanent expulsion from the community without warning (and without refund in
the case of a paid event).
## 7. Reporting Guidelines
If you are subject to or witness unacceptable behavior, or have any other
concerns, please notify a community organizer as soon as possible:
<ismo@ivuorinen.net>
Additionally, community organizers are available to help community members
engage with local law enforcement or to otherwise help those experiencing
unacceptable behavior feel safe. In the context of in-person events, organizers
will also provide escorts as desired by the person experiencing distress.
## 8. Addressing Grievances
If you feel you have been falsely or unfairly accused of violating this Code of
Conduct, you should notify @ivuorinen with a concise description of your
grievance. Your grievance will be handled in accordance with our existing
governing policies.
## 9. Scope
We expect all community participants (contributors, paid or otherwise; sponsors;
and other guests) to abide by this Code of Conduct in all community
venues--online and in-person--as well as in all one-on-one communications
pertaining to community business.
This code of conduct and its related procedures also applies to unacceptable
behavior occurring outside the scope of community activities when such behavior
has the potential to adversely affect the safety and well-being of community
members.
## 10. Contact info
@ivuorinen
<ismo@ivuorinen.net>
## 11. License and attribution
The Citizen Code of Conduct is distributed by [Stumptown Syndicate][stumptown]
under a [Creative Commons Attribution-ShareAlike license][cc-by-sa].
Portions of text derived from the [Django Code of Conduct][django] and
the [Geek Feminism Anti-Harassment Policy][geek-feminism].
* _Revision 2.3. Posted 6 March 2017._
* _Revision 2.2. Posted 4 February 2016._
* _Revision 2.1. Posted 23 June 2014._
* _Revision 2.0, adopted by the [Stumptown Syndicate][stumptown] board on 10
January 2013. Posted 17 March 2013._
[stumptown]: https://github.com/stumpsyn
[cc-by-sa]: https://creativecommons.org/licenses/by-sa/3.0/
[django]: https://www.djangoproject.com/conduct/
[geek-feminism]: http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ivuorinen
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ivuorinen
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

79
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,79 @@
# GitHub Copilot Instructions for monolog-gdpr-filter
## Project Overview
This project is a PHP library that provides a Monolog processor for GDPR compliance.
It allows masking, removing, or replacing sensitive data in logs using regex patterns,
field-level configuration, and custom callbacks. It is designed for easy integration with Monolog and Laravel.
## Coding Conventions
- **Language:** PHP 8.2+
- **PHP Version:** Ensure compatibility with PHP 8.2 and above.
- **PSR Standards:** Follow PSR-12 for code style and autoloading.
- **Testing:** Use PHPUnit for all tests. Place tests in the `tests/` directory. Run `composer test` to execute tests.
- All tests should be written in a way that they can run independently.
- Use Attribute-based annotations for test methods (e.g., `#[Test]`).
- Use Attribute-based annotations for Covers (e.g., `#[CoversClass(GdprProcessor::class)]`).
- **PHPUnit Version:** Use PHPUnit 10.x or above.
- **PHPUnit Configuration:** Use `phpunit.xml` for configuration.
- **Code Coverage:** Use PHPUnit's code coverage features. Generate reports in the `build/` directory.
- **Type Declarations:** Use strict typing and type declarations for all functions and methods.
- **Namespaces:** Use appropriate namespaces for all classes
(e.g., `Ivuorinen\MonologGdprFilter`, or `Tests\` for tests).
- **Error Handling:** Use exceptions for error handling.
- **Static Analysis:**
- Use Psalm and PHPStan for static analysis.
- Config files: `psalm.xml`, `phpstan.neon` (if present).
- **Linting:** Use PHP_CodeSniffer with `phpcs.xml` for code style checks. All code must pass linting before merging.
- **Composer:** Use Composer for dependency management. Follow PSR-4 autoloading.
- Use `composer install` to install dependencies.
- Use `composer update` to update dependencies.
- **Formatting:** Use 4 spaces for indentation. No trailing whitespace. Use Unix line endings.
See `.editorconfig` and `phpcs.xml` for more details.
- **Version Control:**
- Use Git for version control.
- Follow semantic versioning (MAJOR.MINOR.PATCH).
- Do not commit anything, user will do it themselves.
- **Documentation:**
- Public classes and methods should have PHPDoc blocks.
- Update `README.md` for usage and installation changes.
## Pull Request Guidelines
- Ensure your branch is up-to-date with `main`.
- Use descriptive branch names (e.g., `feature/add-gdpr-processor`, `fix/issue-42`).
- Include a clear description of changes in the PR.
- Link to any relevant issues (e.g., `fixes #42`).
- Ensure all tests pass (`vendor/bin/phpunit`).
- Run static analysis (`vendor/bin/psalm` and/or `vendor/bin/phpstan`).
- Run code style checks (`vendor/bin/phpcs`).
- Add or update tests for new features or bug fixes.
- Update documentation as needed.
## Directory Structure
- `src/` — Main library source code
- `tests/` — PHPUnit tests
- `build/` — Build artifacts and coverage reports
- `vendor/` — Composer dependencies
## Commit Message Guidelines
- Use clear, concise messages in semantic commits style
(e.g., `fix: mask email addresses in context`, `feat(logger): add audit logger option`).
- Reference issues when relevant (e.g., `fix: #12 ...`).
## Security & Privacy
- Do not log or expose real sensitive data in tests or documentation.
- Ensure all masking/removal logic is covered by tests.
## Automation
- Use Composer scripts for automation if needed.
- CI will run tests, static analysis, and code style checks.
---
For questions, see the `README.md` or open an issue.

20
.github/renovate.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["github>ivuorinen/renovate-config"],
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch"],
"matchCurrentVersion": "!/^0/",
"automerge": true
},
{
"matchDepTypes": ["devDependencies"],
"automerge": true
}
],
"schedule": ["before 4am on monday"],
"vulnerabilityAlerts": {
"labels": ["security"],
"assignees": ["ivuorinen"]
}
}

46
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
---
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: 'CodeQL'
on:
push:
branches: ['main']
pull_request:
branches: ['main']
schedule:
- cron: '30 1 * * 0' # Run at 1:30 AM UTC every Sunday
merge_group:
permissions:
actions: read
contents: read
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
security-events: write
strategy:
fail-fast: false
matrix:
language: ['javascript'] # Add languages used in your actions
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Initialize CodeQL
uses: github/codeql-action/init@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4
with:
languages: ${{ matrix.language }}
queries: security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4
with:
category: '/language:${{matrix.language}}'

22
.github/workflows/phpcs.yaml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: Code Style Check
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
phpcs:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: shivammathur/setup-php@0f7f1d08e3e32076e51cae65eb0b0c871405b16e # 2.34.1
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Run PHP_CodeSniffer (PSR-12)
shell: bash
run: composer lint:tool:phpcs

30
.github/workflows/pr-lint.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
---
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: Lint Code Base
on:
push:
branches: [master, main]
pull_request:
branches: [master, main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: read-all
jobs:
Linter:
name: PR Lint
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
statuses: write
contents: read
packages: read
steps:
- name: Run PR Lint
# https://github.com/ivuorinen/actions
uses: ivuorinen/actions/pr-lint@8476cd4675ea8210eadf4a267bbeb13bddea4e75 # 25.7.21

26
.github/workflows/stale.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
---
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: Stale
on:
schedule:
- cron: '0 8 * * *' # Every day at 08:00
workflow_call:
workflow_dispatch:
permissions:
contents: read
packages: read
statuses: read
jobs:
stale:
name: 🧹 Clean up stale issues and PRs
runs-on: ubuntu-latest
permissions:
contents: write # only for delete-branch option
issues: write
pull-requests: write
steps:
- uses: ivuorinen/actions/stale@8476cd4675ea8210eadf4a267bbeb13bddea4e75 # 25.7.21

41
.github/workflows/sync-labels.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
---
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: Sync Labels
on:
push:
branches:
- main
- master
paths:
- '.github/labels.yml'
- '.github/workflows/sync-labels.yml'
schedule:
- cron: '34 5 * * *' # Run every day at 05:34 AM UTC
workflow_call:
workflow_dispatch:
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: read-all
jobs:
labels:
name: ♻️ Sync Labels
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
steps:
- name: ⤵️ Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: ⤵️ Sync Latest Labels Definitions
uses: ivuorinen/actions/sync-labels@8476cd4675ea8210eadf4a267bbeb13bddea4e75 # 25.7.21

59
.github/workflows/test-coverage.yaml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: Test & Coverage
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions: read-all
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup PHP
uses: shivammathur/setup-php@0f7f1d08e3e32076e51cae65eb0b0c871405b16e # 2.34.1
with:
coverage: pcov
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Configure matchers
uses: mheap/phpunit-matcher-action@5fe8d131daf5183c6137caea0d4e04a2b8afbb55 # v1.3.0
- name: Run Tests (teamcity format)
shell: bash
run: composer test:ci
- name: Upload coverage report
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: coverage-report
path: coverage.xml
- name: Code Coverage Summary Report
id: coverage-summary
uses: saschanowak/CloverCodeCoverageSummary@217593f67675e88fe1e6afeab0175018eb37deaa # 1.1.0
with:
filename: coverage.xml
- name: 'Add Code Coverage to Job Summary'
run: |
cat code-coverage-summary.md >> $GITHUB_STEP_SUMMARY
cat code-coverage-details.md >> $GITHUB_STEP_SUMMARY
- name: 'Add Code Coverage Summary as PR Comment'
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
if: github.event_name == 'pull_request'
with:
recreate: true
path: code-coverage-summary.md

20
.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
# Generated files
*.cache
*.log
*.tmp
*.swp
# Ignore IDE specific files
*.idea/
*.vscode/
# Composer files
/vendor/
/composer.lock
/composer.phar
# Build data
/build/
# Ignore test coverage reports
/coverage/
coverage.xml

13
.markdownlint.json Normal file
View File

@@ -0,0 +1,13 @@
{
"default": true,
"MD013": {
"line_length": 200,
"code_blocks": false,
"tables": false
},
"MD024": {
"siblings_only": true
},
"MD033": false,
"MD041": false
}

35
.mega-linter.yml Normal file
View File

@@ -0,0 +1,35 @@
---
# Configuration file for MegaLinter
# See all available variables at
# https://megalinter.io/configuration/ and in linters documentation
APPLY_FIXES: all
SHOW_ELAPSED_TIME: false # Show elapsed time at the end of MegaLinter run
PARALLEL: true
VALIDATE_ALL_CODEBASE: true
FILEIO_REPORTER: false # Generate file.io report
GITHUB_STATUS_REPORTER: true # Generate GitHub status report
IGNORE_GENERATED_FILES: true # Ignore generated files
JAVASCRIPT_DEFAULT_STYLE: prettier # Default style for JavaScript
PRINT_ALPACA: false # Print Alpaca logo in console
SARIF_REPORTER: true # Generate SARIF report
SHOW_SKIPPED_LINTERS: false # Show skipped linters in MegaLinter log
DISABLE_LINTERS:
- REPOSITORY_DEVSKIM
ENABLE_LINTERS:
- YAML_YAMLLINT
- MARKDOWN_MARKDOWNLINT
- YAML_PRETTIER
- JSON_PRETTIER
- JAVASCRIPT_ES
- TYPESCRIPT_ES
YAML_YAMLLINT_CONFIG_FILE: .yamllint.yml
MARKDOWN_MARKDOWNLINT_CONFIG_FILE: .markdownlint.json
JAVASCRIPT_ES_CONFIG_FILE: .eslintrc.json
TYPESCRIPT_ES_CONFIG_FILE: .eslintrc.json
FILTER_REGEX_EXCLUDE: >
(node_modules|\.automation/test|docs/json-schemas|\.github/workflows)

63
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,63 @@
---
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: requirements-txt-fixer
- id: detect-private-key
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
- id: check-case-conflict
- id: check-merge-conflict
- id: check-executables-have-shebangs
- id: check-shebang-scripts-are-executable
- id: check-symlinks
- id: check-toml
- id: check-xml
- id: check-yaml
args: [--allow-multiple-documents]
- id: end-of-file-fixer
- id: mixed-line-ending
args: [--fix=auto]
- id: pretty-format-json
args: [--autofix, --no-sort-keys]
- repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.44.0
hooks:
- id: markdownlint
args: [-c, .markdownlint.json, --fix]
- repo: https://github.com/adrienverge/yamllint
rev: v1.37.0
hooks:
- id: yamllint
- repo: https://github.com/scop/pre-commit-shfmt
rev: v3.11.0-1
hooks:
- id: shfmt
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.10.0
hooks:
- id: shellcheck
args: ['--severity=warning']
- repo: https://github.com/rhysd/actionlint
rev: v1.7.7
hooks:
- id: actionlint
args: ['-shellcheck=']
- repo: https://github.com/renovatebot/pre-commit-hooks
rev: 39.227.2
hooks:
- id: renovate-config-validator
- repo: https://github.com/bridgecrewio/checkov.git
rev: '3.2.400'
hooks:
- id: checkov
args:
- '--quiet'

1
.shellcheckrc Normal file
View File

@@ -0,0 +1 @@
disable=SC2129

0
.yamlignore Normal file
View File

13
.yamllint.yml Normal file
View File

@@ -0,0 +1,13 @@
---
extends: default
rules:
line-length:
max: 200
level: warning
truthy:
check-keys: false
comments:
min-spaces-from-content: 1
trailing-spaces:
level: warning

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Ismo Vuorinen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

21
LICENSE.md Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Ismo Vuorinen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

224
README.md Normal file
View File

@@ -0,0 +1,224 @@
# Monolog GDPR Filter
Monolog GDPR Filter is a PHP library that provides a Monolog processor for GDPR compliance. It allows masking, removing,
or replacing sensitive data in logs using regex patterns, field-level configuration, and custom callbacks. Designed for
easy integration with Monolog and Laravel.
## Features
- **Regex-based masking** for patterns like SSNs, credit cards, emails
- **Field-level masking/removal/replacement** using dot-notation paths
- **Custom callbacks** for advanced masking logic per field
- **Audit logging** for compliance tracking
- **Easy integration with Monolog and Laravel**
## Installation
Install via Composer:
```bash
composer require ivuorinen/monolog-gdpr-filter
```
## Usage
### Basic Monolog Setup
```php
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
$patterns = GdprProcessor::getDefaultPatterns();
$fieldPaths = [
'user.ssn' => GdprProcessor::removeField(),
'payment.card' => GdprProcessor::replaceWith('[CC]'),
'contact.email' => GdprProcessor::maskWithRegex(),
'metadata.session' => GdprProcessor::replaceWith('[SESSION]'),
];
// Optional: custom callback for advanced masking
$customCallbacks = [
'user.name' => fn($value) => strtoupper($value),
];
// Optional: audit logger for compliance
$auditLogger = function($path, $original, $masked) {
error_log("GDPR mask: $path: $original => $masked");
};
$logger = new Logger('app');
$logger->pushHandler(new StreamHandler('path/to/your.log', Logger::WARNING));
$logger->pushProcessor(
new GdprProcessor($patterns, $fieldPaths, $customCallbacks, $auditLogger)
);
$logger->warning('This is a warning message.', [
'user' => ['ssn' => '123456-900T'],
'contact' => ['email' => 'user@example.com'],
'payment' => ['card' => '1234567812345678'],
]);
```
### FieldMaskConfig Options
- `GdprProcessor::maskWithRegex()` — Mask field value using regex patterns
- `GdprProcessor::removeField()` — Remove field from context
- `GdprProcessor::replaceWith($value)` — Replace field value with static value
### Custom Callbacks
Provide custom callbacks for specific fields:
```php
$customCallbacks = [
'user.name' => fn($value) => strtoupper($value),
];
```
### Audit Logger
Optionally provide an audit logger callback to record masking actions:
```php
$auditLogger = function($path, $original, $masked) {
// Log or store audit info
};
```
> **IMPORTANT**: Be mindful what you send to your audit log. Passing the original value might defeat the whole purpose
> of this project.
## Laravel Integration
You can integrate the GDPR processor with Laravel logging in two ways:
### 1. Service Provider
```php
// app/Providers/AppServiceProvider.php
use Illuminate\Support\ServiceProvider;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
class AppServiceProvider extends ServiceProvider
{
public function boot()
{
$patterns = GdprProcessor::getDefaultPatterns();
$fieldPaths = [
'user.ssn' => '[GDPR]',
'payment.card' => '[CC]',
'contact.email' => '', // empty string = regex mask
'metadata.session' => '[SESSION]',
];
$this->app['log']->getLogger()
->pushProcessor(new GdprProcessor($patterns, $fieldPaths));
}
}
```
### 2. Tap Class (config/logging.php)
```php
// app/Logging/GdprTap.php
namespace App\Logging;
use Monolog\Logger;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
class GdprTap
{
public function __invoke(Logger $logger)
{
$patterns = GdprProcessor::getDefaultPatterns();
$fieldPaths = [
'user.ssn' => '[GDPR]',
'payment.card' => '[CC]',
'contact.email' => '',
'metadata.session' => '[SESSION]',
];
$logger->pushProcessor(new GdprProcessor($patterns, $fieldPaths));
}
}
```
Reference in `config/logging.php`:
```php
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['single'],
'tap' => [App\Logging\GdprTap::class],
],
// ...
],
```
## Configuration
You can configure the processor to filter out sensitive data by specifying:
- **Regex patterns:** Used for masking values in messages and context
- **Field paths:** Dot-notation paths for masking/removal/replacement
- **Custom callbacks:** For advanced per-field masking
- **Audit logger:** For compliance tracking
## Testing & Quality
This project uses PHPUnit for testing, Psalm and PHPStan for static analysis, and PHP_CodeSniffer for code style checks.
### Running Tests
To run the test suite:
```bash
composer test
```
To generate a code coverage report (HTML output in the `coverage/` directory):
```bash
composer test:coverage
```
### Linting & Static Analysis
To run all linters and static analysis:
```bash
composer lint
```
To automatically fix code style and static analysis issues:
```bash
composer lint:fix
```
## Notable Implementation Details
- If a regex replacement in `regExpMessage` results in an empty string or the string "0", the original message is
returned. This is covered by dedicated PHPUnit tests.
- If a regex pattern is invalid, the audit logger (if set) is called, and the original message is returned.
## Directory Structure
- `src/` — Main library source code
- `tests/` — PHPUnit tests
- `coverage/` — Code coverage reports
- `vendor/` — Composer dependencies
## Legal Disclaimer
> **CAUTION**: This library helps mask/filter sensitive data for GDPR compliance, but it is your responsibility to
> ensure your application fully complies with all legal requirements. Review your logging and data handling policies
> regularly.
## Contributing
If you would like to contribute to this project, please fork the repository and submit a pull request.
## License
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details.

59
composer.json Normal file
View File

@@ -0,0 +1,59 @@
{
"$ref": "https://getcomposer.org/schema.json",
"name": "ivuorinen/monolog-gdpr-filter",
"description": "Monolog processor for GDPR masking with regex and dot-notation paths",
"version": "1.0.0",
"type": "library",
"scripts": {
"lint": [
"@lint:tool:psalm",
"@lint:tool:phpcs"
],
"lint:fix": [
"@lint:tool:rector",
"@lint:tool:psalm:fix",
"@lint:tool:phpcbf"
],
"test": "XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-text",
"test:coverage": "XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-text --coverage-html=coverage",
"test:ci": "XDEBUG_MODE=coverage ./vendor/bin/phpunit --teamcity --coverage-clover=coverage.xml",
"lint:tool:phpcs": "./vendor/bin/phpcs src/ tests/ rector.php",
"lint:tool:phpcbf": "./vendor/bin/phpcbf src/ tests/ rector.php",
"lint:tool:psalm": "./vendor/bin/psalm --show-info=true",
"lint:tool:psalm:fix": "./vendor/bin/psalm --alter --issues=all",
"lint:tool:rector": "./vendor/bin/rector"
},
"require": {
"php": "^8.2",
"monolog/monolog": "^3.0",
"adbario/php-dot-notation": "^3.3"
},
"require-dev": {
"phpunit/phpunit": "^11",
"squizlabs/php_codesniffer": "^3.9",
"rector/rector": "^2.1",
"vimeo/psalm": "^6.13",
"psalm/plugin-phpunit": "^0.19.5",
"orklah/psalm-strict-equality": "^3.1",
"guuzen/psalm-enum-plugin": "^1.1",
"ergebnis/composer-normalize": "^2.47"
},
"autoload": {
"psr-4": {
"Ivuorinen\\MonologGdprFilter\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests"
}
},
"config": {
"allow-plugins": {
"ergebnis/composer-normalize": true
},
"sort-packages": true
},
"minimum-stability": "dev",
"prefer-stable": true
}

6070
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

10
phpcs.xml Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0"?>
<ruleset name="PSR12"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="vendor/squizlabs/php_codesniffer/phpcs.xsd">
<description>PHP_CodeSniffer configuration for PSR-12 coding standard.</description>
<rule ref="PSR12" />
<file>src/</file>
<file>tests/</file>
<file>rector.php</file>
<exclude-pattern>vendor/</exclude-pattern>
</ruleset>

28
phpunit.xml Normal file
View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8" ?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
beStrictAboutOutputDuringTests="true"
displayDetailsOnPhpunitDeprecations="true"
failOnPhpunitDeprecation="true"
failOnWarning="true">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>
<source ignoreIndirectDeprecations="true" restrictNotices="true" restrictWarnings="true">
<include>
<directory>src</directory>
</include>
</source>
<coverage/>
<php>
<ini name="xdebug.mode" value="coverage"/>
</php>
</phpunit>

23
psalm.xml Normal file
View File

@@ -0,0 +1,23 @@
<?xml version="1.0"?>
<psalm
errorLevel="3"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
phpVersion="8.2"
noCache="true"
ensureOverrideAttribute="false"
restrictReturnTypes="true"
>
<projectFiles>
<directory name="src"/>
<ignoreFiles>
<directory name="vendor"/>
</ignoreFiles>
</projectFiles>
<plugins>
<pluginClass class="Psalm\PhpUnitPlugin\Plugin"/>
<pluginClass class="Orklah\StrictEquality\Plugin"/>
<pluginClass class="Guuzen\PsalmEnumPlugin\Plugin"/>
</plugins>
</psalm>

28
rector.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Rector\Config\RectorConfig;
use Rector\Exception\Configuration\InvalidConfigurationException;
try {
return RectorConfig::configure()
->withPaths([
__DIR__ . '/src',
__DIR__ . '/tests',
])
->withPhpVersion(80200)
->withPhpSets(php82: true)
->withComposerBased(phpunit: true)
->withImportNames(removeUnusedImports: true)
->withPreparedSets(
deadCode: true,
codeQuality: true,
codingStyle: true,
earlyReturn: true,
phpunitCodeQuality: true
);
} catch (InvalidConfigurationException $e) {
echo "Configuration error: " . $e->getMessage() . PHP_EOL;
exit(1);
}

19
src/FieldMaskConfig.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
namespace Ivuorinen\MonologGdprFilter;
/**
* FieldMaskConfig: config for masking/removal per field path
*/
final class FieldMaskConfig
{
public const MASK_REGEX = 'mask_regex';
public const REMOVE = 'remove';
public const REPLACE = 'replace';
public function __construct(public string $type, public ?string $replacement = null)
{
}
}

275
src/GdprProcessor.php Normal file
View File

@@ -0,0 +1,275 @@
<?php
namespace Ivuorinen\MonologGdprFilter;
use Adbar\Dot;
use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;
/**
* GdprProcessor is a Monolog processor that masks sensitive information in log messages
* according to specified regex patterns and field paths.
*
* @psalm-api
*/
class GdprProcessor implements ProcessorInterface
{
/**
* @param array<string,string> $patterns Regex pattern => replacement
* @param array<string,FieldMaskConfig>|string[] $fieldPaths Dot-notation path => FieldMaskConfig
* @param array<string,?callable> $customCallbacks Dot-notation path => callback(value): string
* @param callable|null $auditLogger Opt. audit logger callback:
* fn(string $path, mixed $original, mixed $masked)
*/
public function __construct(
private readonly array $patterns,
private readonly array $fieldPaths = [],
private readonly array $customCallbacks = [],
private $auditLogger = null
) {
}
/**
* FieldMaskConfig: config for masking/removal per field path using regex.
*/
public static function maskWithRegex(): FieldMaskConfig
{
return new FieldMaskConfig(FieldMaskConfig::MASK_REGEX);
}
/**
* FieldMaskConfig: Remove field from context.
*/
public static function removeField(): FieldMaskConfig
{
return new FieldMaskConfig(FieldMaskConfig::REMOVE);
}
/**
* FieldMaskConfig: Replace field value with a static string.
*/
public static function replaceWith(string $replacement): FieldMaskConfig
{
return new FieldMaskConfig(FieldMaskConfig::REPLACE, $replacement);
}
/**
* Default GDPR regex patterns. Non-exhaustive, should be extended with your own.
*
* @return array<array-key, string>
*/
public static function getDefaultPatterns(): array
{
return [
// Finnish SSN (HETU)
'/\b\d{6}[-+A]?\d{3}[A-Z]\b/u' => '***HETU***',
// US Social Security Number (strict: 3-2-4 digits)
'/^\d{3}-\d{2}-\d{4}$/' => '***USSSN***',
// IBAN (strictly match Finnish IBAN with or without spaces, only valid groupings)
'/^FI\d{2}(?: ?\d{4}){3} ?\d{2}$/u' => '***IBAN***',
// Also match fully compact Finnish IBAN (no spaces)
'/^FI\d{16}$/u' => '***IBAN***',
// International phone numbers (E.164, +countrycode...)
'/^\+\d{1,3}[\s-]?\d{1,4}[\s-]?\d{1,4}[\s-]?\d{1,9}$/' => '***PHONE***',
// Email address
'/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/' => '***EMAIL***',
// Date of birth (YYYY-MM-DD)
'/^(19|20)\d{2}-[01]\d\-[0-3]\d$/' => '***DOB***',
// Date of birth (DD/MM/YYYY)
'/^[0-3]\d\/[01]\d\/(19|20)\d{2}$/' => '***DOB***',
// Passport numbers (A followed by 6 digits)
'/^A\d{6}$/' => '***PASSPORT***',
// Credit card numbers (Visa, MC, Amex, Discover test numbers)
'/^(4111 1111 1111 1111|5500-0000-0000-0004|340000000000009|6011000000000004)$/' => '***CC***',
// Generic 16-digit credit card (for test compatibility)
'/\b[0-9]{16}\b/u' => '***CC***',
// Bearer tokens (JWT, at least 10 chars after Bearer)
'/^Bearer [A-Za-z0-9\-\._~\+\/]{10,}$/' => '***TOKEN***',
// API keys (Stripe-like, 20+ chars, or sk_live|sk_test)
'/^(sk_(live|test)_[A-Za-z0-9]{16,}|[A-Za-z0-9\-_]{20,})$/' => '***APIKEY***',
// MAC addresses
'/^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/' => '***MAC***',
];
}
/**
* Process a log record to mask sensitive information.
*
* @param LogRecord $record The log record to process
* @return LogRecord The processed log record with masked message and context
*
* @psalm-suppress MissingOverrideAttribute Override is available from PHP 8.3
*/
public function __invoke(LogRecord $record): LogRecord
{
$message = $this->regExpMessage($record->message);
$context = $record->context;
$accessor = new Dot($context);
if ($this->fieldPaths !== []) {
$this->maskFieldPaths($accessor);
$context = $accessor->all();
} else {
$context = $this->recursiveMask($context);
}
return $record->with(message: $message, context: $context);
}
/**
* Mask a string using all regex patterns sequentially.
*/
public function regExpMessage(string $message = ''): string
{
foreach ($this->patterns as $regex => $replacement) {
/**
* @var array<array-key, non-empty-string> $regex
*/
$result = @preg_replace($regex, $replacement, $message);
if ($result === null) {
if (is_callable($this->auditLogger)) {
call_user_func($this->auditLogger, 'preg_replace_error', $message, $message);
}
continue;
}
if ($result === '' || $result === '0') {
// If the result is empty, we can skip further processing
return $message;
}
$message = $result;
}
return $message;
}
/**
* Mask only specified paths in context (fieldPaths)
*/
private function maskFieldPaths(Dot $accessor): void
{
foreach ($this->fieldPaths as $path => $config) {
if (!$accessor->has($path)) {
continue;
}
$value = $accessor->get($path, "");
$action = $this->maskValue($path, $value, $config);
if ($action['remove'] ?? false) {
$accessor->delete($path);
$this->logAudit($path, $value, null);
continue;
}
$masked = $action['masked'];
if ($masked !== null && $masked !== $value) {
$accessor->set($path, $masked);
$this->logAudit($path, $value, $masked);
}
}
}
/**
* Mask a single value according to config or callback
* Returns an array: ['masked' => value|null, 'remove' => bool]
*
* @psalm-return array{masked: string|null, remove: bool}
*/
private function maskValue(string $path, mixed $value, null|FieldMaskConfig|string $config): array
{
/** @noinspection PhpArrayIndexImmediatelyRewrittenInspection */
$result = ['masked' => null, 'remove' => false];
if (array_key_exists($path, $this->customCallbacks) && $this->customCallbacks[$path] !== null) {
$result['masked'] = call_user_func($this->customCallbacks[$path], $value);
return $result;
}
if ($config instanceof FieldMaskConfig) {
switch ($config->type) {
case FieldMaskConfig::MASK_REGEX:
$result['masked'] = $this->regExpMessage($value);
break;
case FieldMaskConfig::REMOVE:
$result['masked'] = null;
$result['remove'] = true;
break;
case FieldMaskConfig::REPLACE:
$result['masked'] = $config->replacement;
break;
default:
// Return the type as string for unknown types
$result['masked'] = $config->type;
break;
}
} else {
// Backward compatibility: treat string as replacement
$result['masked'] = $config;
}
return $result;
}
/**
* Audit logger helper
*
* @param string $path Dot-notation path of the field
* @param mixed $original Original value before masking
* @param null|string $masked Masked value after processing, or null if removed
*/
private function logAudit(string $path, mixed $original, string|null $masked): void
{
if (is_callable($this->auditLogger) && $original !== $masked) {
// Only log if the value was actually changed
call_user_func($this->auditLogger, $path, $original, $masked);
}
}
/**
* Recursively mask all string values in an array using regex patterns.
*/
protected function recursiveMask(string|array $data): string|array
{
if (is_string($data)) {
return $this->regExpMessage($data);
}
foreach ($data as $key => $value) {
$data[$key] = $this->recursiveMask($value);
}
return $data;
}
/**
* Mask a string using all regex patterns at once.
*/
public function maskMessage(string $value = ''): string
{
/** @var array<array-key, non-empty-string> $keys */
$keys = array_keys($this->patterns);
$values = array_values($this->patterns);
$result = @preg_replace($keys, $values, $value);
if ($result === null) {
if (is_callable($this->auditLogger)) {
call_user_func($this->auditLogger, 'preg_replace_error', $value, $value);
}
return $value;
}
return $result;
}
/**
* Set the audit logger callable.
*
* @param callable|null $auditLogger
* @return void
*/
public function setAuditLogger(?callable $auditLogger): void
{
$this->auditLogger = $auditLogger;
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Tests;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(GdprProcessor::class)]
class AdvancedRegexMaskProcessorTest extends TestCase
{
use TestHelpers;
private GdprProcessor $processor;
/**
* @psalm-suppress MissingOverrideAttribute
*/
protected function setUp(): void
{
parent::setUp();
$patterns = [
"/\b\d{6}[-+A]?\d{3}[A-Z]\b/u" => "***HETU***",
"/\b[0-9]{16}\b/u" => "***CC***",
"/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/" => "***EMAIL***",
];
$fieldPaths = [
"user.ssn" => "[GDPR]",
"payment.card" => "[CC]",
"contact.email" => GdprProcessor::maskWithRegex(), // use regex-masked
"metadata.session" => "[SESSION]",
];
$this->processor = new GdprProcessor($patterns, $fieldPaths);
}
public function testMaskCreditCardInMessage(): void
{
$record = $this->logEntry()->with(message: "Card: 1234567812345678");
$result = ($this->processor)($record);
$this->assertSame("Card: ***CC***", $result["message"]);
}
public function testMaskEmailInMessage(): void
{
$record = $this->logEntry()->with(message: "Email: user@example.com");
$result = ($this->processor)($record);
$this->assertSame("Email: ***EMAIL***", $result["message"]);
}
public function testContextFieldPathReplacements(): void
{
$record = $this->logEntry()->with(
message: "Mixed data",
context: [
"user" => ["ssn" => self::TEST_HETU],
"payment" => ["card" => self::TEST_CC],
"contact" => ["email" => self::TEST_EMAIL],
"metadata" => ["session" => "abc123xyz"],
],
extra: [],
);
$result = ($this->processor)($record);
$this->assertSame("[GDPR]", $result["context"]["user"]["ssn"]);
$this->assertSame("[CC]", $result["context"]["payment"]["card"]);
// empty replacement uses regex-masked value
$this->assertSame("***EMAIL***", $result["context"]["contact"]["email"]);
$this->assertSame("[SESSION]", $result["context"]["metadata"]["session"]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\TestCase;
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
#[CoversClass(className: FieldMaskConfig::class)]
#[CoversMethod(className: FieldMaskConfig::class, methodName: '__construct')]
class FieldMaskConfigTest extends TestCase
{
public function testMaskRegexConfig(): void
{
$config = new FieldMaskConfig(FieldMaskConfig::MASK_REGEX);
$this->assertSame(FieldMaskConfig::MASK_REGEX, $config->type);
$this->assertNull($config->replacement);
}
public function testRemoveConfig(): void
{
$config = new FieldMaskConfig(FieldMaskConfig::REMOVE);
$this->assertSame(FieldMaskConfig::REMOVE, $config->type);
$this->assertNull($config->replacement);
}
public function testReplaceConfig(): void
{
$config = new FieldMaskConfig(FieldMaskConfig::REPLACE, 'MASKED');
$this->assertSame(FieldMaskConfig::REPLACE, $config->type);
$this->assertSame('MASKED', $config->replacement);
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace Tests;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\TestCase;
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
#[CoversClass(FieldMaskConfig::class)]
#[CoversMethod(GdprProcessor::class, 'getDefaultPatterns')]
class GdprDefaultPatternsTest extends TestCase
{
public function testPatternIban(): void
{
$patterns = GdprProcessor::getDefaultPatterns();
$processor = new GdprProcessor($patterns);
// Finnish IBAN with spaces
$iban = 'FI21 1234 5600 0007 85';
$masked = $processor->maskMessage($iban);
$this->assertSame('***IBAN***', $masked);
// Finnish IBAN without spaces
$ibanWithoutSpaces = 'FI2112345600000785';
$this->assertSame('***IBAN***', $processor->maskMessage($ibanWithoutSpaces));
$this->assertNotSame($ibanWithoutSpaces, $processor->maskMessage($ibanWithoutSpaces));
// Edge: not an IBAN
$notIban = 'FI21 1234 5600 000 85A';
$this->assertSame($notIban, $processor->maskMessage($notIban));
}
public function testPatternPhone(): void
{
$patterns = GdprProcessor::getDefaultPatterns();
$processor = new GdprProcessor($patterns);
$phone = '+358 40 1234567';
$masked = $processor->maskMessage($phone);
$this->assertSame('***PHONE***', $masked);
// Edge: not a phone
$notPhone = 'Call me maybe';
$this->assertSame($notPhone, $processor->maskMessage($notPhone));
}
public function testPatternUsSsn(): void
{
$patterns = GdprProcessor::getDefaultPatterns();
$processor = new GdprProcessor($patterns);
$ssn = '123-45-6789';
$masked = $processor->maskMessage($ssn);
$this->assertSame('***USSSN***', $masked);
// Edge: not a SSN
$notSsn = '123456789';
$this->assertSame($notSsn, $processor->maskMessage($notSsn));
}
public function testPatternDob(): void
{
$patterns = GdprProcessor::getDefaultPatterns();
$processor = new GdprProcessor($patterns);
$dob1 = '1990-12-31';
$dob2 = '31/12/1990';
$masked1 = $processor->maskMessage($dob1);
$masked2 = $processor->maskMessage($dob2);
$this->assertSame('***DOB***', $masked1);
$this->assertSame('***DOB***', $masked2);
// Edge: not a DOB
$notDob = '1990/31/12';
$this->assertSame($notDob, $processor->maskMessage($notDob));
}
public function testPatternPassport(): void
{
$patterns = GdprProcessor::getDefaultPatterns();
$processor = new GdprProcessor($patterns);
$passport = 'A123456';
$masked = $processor->maskMessage($passport);
$this->assertSame('***PASSPORT***', $masked);
// Edge: too short
$notPassport = 'A1234';
$this->assertSame($notPassport, $processor->maskMessage($notPassport));
}
public function testPatternCreditCard(): void
{
$patterns = GdprProcessor::getDefaultPatterns();
$processor = new GdprProcessor($patterns);
$cc1 = '4111 1111 1111 1111'; // Visa
$cc2 = '5500-0000-0000-0004'; // MasterCard
$cc3 = '340000000000009'; // Amex (15 digits)
$cc4 = '6011000000000004'; // Discover
$masked1 = $processor->maskMessage($cc1);
$masked2 = $processor->maskMessage($cc2);
$masked3 = $processor->maskMessage($cc3);
$masked4 = $processor->maskMessage($cc4);
$this->assertSame('***CC***', $masked1);
$this->assertSame('***CC***', $masked2);
$this->assertSame('***CC***', $masked3);
$this->assertSame('***CC***', $masked4);
// Edge: not a CC
$notCc = '1234 5678 9012';
$this->assertSame($notCc, $processor->maskMessage($notCc));
}
public function testPatternBearerToken(): void
{
$patterns = GdprProcessor::getDefaultPatterns();
$processor = new GdprProcessor($patterns);
$token = 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9';
$masked = $processor->maskMessage($token);
$this->assertSame('***TOKEN***', $masked);
// Edge: not a token
$notToken = 'bearer token';
$this->assertSame($notToken, $processor->maskMessage($notToken));
}
public function testPatternApiKey(): void
{
$patterns = GdprProcessor::getDefaultPatterns();
$processor = new GdprProcessor($patterns);
$apiKey = 'sk_test_4eC39HqLyjWDarj';
$masked = $processor->maskMessage($apiKey);
$this->assertSame('***APIKEY***', $masked);
// Edge: short string
$notApiKey = 'shortkey';
$this->assertSame($notApiKey, $processor->maskMessage($notApiKey));
}
public function testPatternMac(): void
{
$patterns = GdprProcessor::getDefaultPatterns();
$processor = new GdprProcessor($patterns);
$mac = '00:1A:2B:3C:4D:5E';
$masked = $processor->maskMessage($mac);
$this->assertSame('***MAC***', $masked);
$mac2 = '00-1A-2B-3C-4D-5E';
$masked2 = $processor->maskMessage($mac2);
$this->assertSame('***MAC***', $masked2);
// Edge: not a MAC
$notMac = '001A2B3C4D5E';
$this->assertSame($notMac, $processor->maskMessage($notMac));
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
use PHPUnit\Framework\TestCase;
use Adbar\Dot;
#[CoversClass(className: GdprProcessor::class)]
#[CoversMethod(className: GdprProcessor::class, methodName: '__invoke')]
#[CoversMethod(className: GdprProcessor::class, methodName: 'maskFieldPaths')]
#[CoversMethod(className: GdprProcessor::class, methodName: 'maskValue')]
#[CoversMethod(className: GdprProcessor::class, methodName: 'logAudit')]
class GdprProcessorMethodsTest extends TestCase
{
use TestHelpers;
public function testMaskFieldPathsSetsMaskedValueAndRemovesField(): void
{
$patterns = [
'/john.doe/' => 'bar',
];
$fieldPaths = [
'user.email' => GdprProcessor::maskWithRegex(),
'user.ssn' => GdprProcessor::removeField(),
'user.card' => GdprProcessor::replaceWith('MASKED'),
];
$context = [
'user' => [
'email' => self::TEST_EMAIL,
'ssn' => self::TEST_HETU,
'card' => self::TEST_CC,
],
];
$accessor = new Dot($context);
$processor = new GdprProcessor($patterns, $fieldPaths);
$method = $this->getReflection($processor, 'maskFieldPaths');
$method->invoke($processor, $accessor);
$result = $accessor->all();
$this->assertSame('bar@example.com', $result['user']['email']);
$this->assertSame('MASKED', $result['user']['card']);
$this->assertArrayNotHasKey('ssn', $result['user']);
}
public function testMaskValueWithCustomCallback(): void
{
$patterns = [];
$fieldPaths = [
'user.name' => GdprProcessor::maskWithRegex(),
];
$customCallbacks = [
'user.name' => fn($value) => strtoupper((string) $value),
];
$processor = new GdprProcessor($patterns, $fieldPaths, $customCallbacks);
$method = $this->getReflection($processor, 'maskValue');
$result = $method->invoke($processor, 'user.name', 'john', $fieldPaths['user.name']);
$this->assertSame(['masked' => 'JOHN', 'remove' => false], $result);
}
public function testMaskValueWithRemove(): void
{
$patterns = [];
$fieldPaths = [
'user.ssn' => GdprProcessor::removeField(),
];
$processor = new GdprProcessor($patterns, $fieldPaths);
$method = $this->getReflection($processor, 'maskValue');
$result = $method->invoke($processor, 'user.ssn', self::TEST_HETU, $fieldPaths['user.ssn']);
$this->assertSame(['masked' => null, 'remove' => true], $result);
}
public function testMaskValueWithReplace(): void
{
$patterns = [];
$fieldPaths = [
'user.card' => GdprProcessor::replaceWith('MASKED'),
];
$processor = new GdprProcessor($patterns, $fieldPaths);
$method = $this->getReflection($processor, 'maskValue');
$result = $method->invoke($processor, 'user.card', self::TEST_CC, $fieldPaths['user.card']);
$this->assertSame(['masked' => 'MASKED', 'remove' => false], $result);
}
public function testLogAuditIsCalled(): void
{
$patterns = [];
$fieldPaths = [];
$calls = [];
$auditLogger = function ($path, $original, $masked) use (&$calls): void {
$calls[] = [$path, $original, $masked];
};
$processor = new GdprProcessor($patterns, $fieldPaths, [], $auditLogger);
$method = $this->getReflection($processor, 'logAudit');
$method->invoke($processor, 'user.email', self::TEST_EMAIL, 'MASKED');
$this->assertNotEmpty($calls);
$this->assertSame(['user.email', self::TEST_EMAIL, 'MASKED'], $calls[0]);
}
public function testMaskValueWithDefaultCase(): void
{
$patterns = [];
$fieldPaths = [
'user.unknown' => new FieldMaskConfig('999'), // unknown type
];
$processor = new GdprProcessor($patterns, $fieldPaths);
$method = $this->getReflection($processor, 'maskValue');
$result = $method->invoke($processor, 'user.unknown', 'foo', $fieldPaths['user.unknown']);
$this->assertSame(['masked' => '999', 'remove' => false], $result);
}
public function testMaskValueWithStringConfigBackwardCompatibility(): void
{
$patterns = [];
$fieldPaths = [
'user.simple' => 'MASKED',
];
$processor = new GdprProcessor($patterns, $fieldPaths);
$method = $this->getReflection($processor, 'maskValue');
$result = $method->invoke($processor, 'user.simple', 'foo', $fieldPaths['user.simple']);
$this->assertSame(['masked' => 'MASKED', 'remove' => false], $result);
}
}

330
tests/GdprProcessorTest.php Normal file
View File

@@ -0,0 +1,330 @@
<?php
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\TestCase;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Monolog\LogRecord;
use Monolog\Level;
use Monolog\JsonSerializableDateTimeImmutable;
#[CoversClass(GdprProcessor::class)]
#[CoversMethod(GdprProcessor::class, '__invoke')]
#[CoversMethod(GdprProcessor::class, 'getDefaultPatterns')]
#[CoversMethod(GdprProcessor::class, 'maskMessage')]
#[CoversMethod(GdprProcessor::class, 'maskWithRegex')]
#[CoversMethod(GdprProcessor::class, 'recursiveMask')]
#[CoversMethod(GdprProcessor::class, 'regExpMessage')]
#[CoversMethod(GdprProcessor::class, 'removeField')]
#[CoversMethod(GdprProcessor::class, 'replaceWith')]
class GdprProcessorTest extends TestCase
{
use TestHelpers;
public function testMaskWithRegexField(): void
{
$patterns = GdprProcessor::getDefaultPatterns();
$fieldPaths = [
'user.email' => GdprProcessor::maskWithRegex(),
];
$processor = new GdprProcessor($patterns, $fieldPaths);
$record = new LogRecord(
datetime: new JsonSerializableDateTimeImmutable(true),
channel: 'test',
level: Level::Info,
message: static::USER_REGISTERED,
context: ['user' => ['email' => self::TEST_EMAIL]],
extra: []
);
$processed = $processor($record);
$this->assertSame(self::MASKED_EMAIL, $processed->context['user']['email']);
}
public function testRemoveField(): void
{
$patterns = GdprProcessor::getDefaultPatterns();
$fieldPaths = [
'user.ssn' => GdprProcessor::removeField(),
];
$processor = new GdprProcessor($patterns, $fieldPaths);
$record = new LogRecord(
datetime: new JsonSerializableDateTimeImmutable(true),
channel: 'test',
level: Level::Info,
message: 'Sensitive info',
context: ['user' => ['ssn' => '123456-789A', 'name' => 'John']],
extra: []
);
$processed = $processor($record);
$this->assertArrayNotHasKey('ssn', $processed->context['user']);
$this->assertSame('John', $processed->context['user']['name']);
}
public function testReplaceWithField(): void
{
$patterns = GdprProcessor::getDefaultPatterns();
$fieldPaths = [
'user.card' => GdprProcessor::replaceWith('MASKED'),
];
$processor = new GdprProcessor($patterns, $fieldPaths);
$record = new LogRecord(
datetime: new JsonSerializableDateTimeImmutable(true),
channel: 'test',
level: Level::Info,
message: 'Payment processed',
context: ['user' => ['card' => '1234123412341234']],
extra: []
);
$processed = $processor($record);
$this->assertSame('MASKED', $processed->context['user']['card']);
}
public function testCustomCallback(): void
{
$patterns = GdprProcessor::getDefaultPatterns();
$fieldPaths = [
'user.name' => GdprProcessor::maskWithRegex(),
];
$customCallbacks = [
'user.name' => fn($value): string => strtoupper((string) $value),
];
$processor = new GdprProcessor($patterns, $fieldPaths, $customCallbacks);
$record = new LogRecord(
datetime: new JsonSerializableDateTimeImmutable(true),
channel: 'test',
level: Level::Info,
message: 'Name logged',
context: ['user' => ['name' => 'john']],
extra: []
);
$processed = $processor($record);
$this->assertSame('JOHN', $processed->context['user']['name']);
}
public function testAuditLoggerIsCalled(): void
{
$patterns = GdprProcessor::getDefaultPatterns();
$fieldPaths = [
'user.email' => GdprProcessor::maskWithRegex(),
];
$auditCalls = [];
$auditLogger = function ($path, $original, $masked) use (&$auditCalls): void {
$auditCalls[] = [$path, $original, $masked];
};
$processor = new GdprProcessor($patterns, $fieldPaths, [], $auditLogger);
$record = new LogRecord(
datetime: new JsonSerializableDateTimeImmutable(true),
channel: 'test',
level: Level::Info,
message: static::USER_REGISTERED,
context: ['user' => ['email' => static::TEST_EMAIL]],
extra: []
);
$processor($record);
$this->assertNotEmpty($auditCalls);
$this->assertSame(['user.email', 'john.doe@example.com', '***EMAIL***'], $auditCalls[0]);
}
public function testMaskMessage(): void
{
$patterns = [
'/foo/' => 'bar',
'/baz/' => 'qux',
];
$processor = new GdprProcessor($patterns);
$masked = $processor->maskMessage('foo and baz');
$this->assertSame('bar and qux', $masked);
}
public function testRecursiveMask(): void
{
$patterns = [
'/secret/' => self::MASKED_SECRET,
];
$processor = new class ($patterns) extends GdprProcessor {
public function callRecursiveMask($data)
{
return $this->recursiveMask($data);
}
};
$data = [
'a' => 'secret',
'b' => ['c' => 'secret'],
'd' => 123,
];
$masked = $processor->callRecursiveMask($data);
$this->assertSame([
'a' => self::MASKED_SECRET,
'b' => ['c' => self::MASKED_SECRET],
'd' => '123',
], $masked);
}
public function testStaticHelpers(): void
{
$regex = GdprProcessor::maskWithRegex();
$remove = GdprProcessor::removeField();
$replace = GdprProcessor::replaceWith('MASKED');
$this->assertSame('mask_regex', $regex->type);
$this->assertSame('remove', $remove->type);
$this->assertSame('replace', $replace->type);
$this->assertSame('MASKED', $replace->replacement);
}
public function testRecursiveMasking(): void
{
$patterns = GdprProcessor::getDefaultPatterns();
$processor = new GdprProcessor($patterns);
$record = new LogRecord(
datetime: new JsonSerializableDateTimeImmutable(true),
channel: 'test',
level: Level::Info,
message: 'Sensitive info',
context: [
'user' => [
'email' => self::TEST_EMAIL,
'ssn' => self::TEST_HETU,
'card' => self::TEST_CC,
],
'other' => 'plain',
],
extra: []
);
$processed = $processor($record);
$this->assertSame(self::MASKED_EMAIL, $processed->context['user']['email']);
$this->assertSame('***HETU***', $processed->context['user']['ssn']);
$this->assertSame('***CC***', $processed->context['user']['card']);
}
public function testStringReplacementBackwardCompatibility(): void
{
$patterns = GdprProcessor::getDefaultPatterns();
$fieldPaths = [
'user.email' => '[MASKED]', // string, not FieldMaskConfig
];
$processor = new GdprProcessor($patterns, $fieldPaths);
$record = new LogRecord(
datetime: new JsonSerializableDateTimeImmutable(true),
channel: 'test',
level: Level::Info,
message: static::USER_REGISTERED,
context: ['user' => ['email' => self::TEST_EMAIL]],
extra: []
);
$processed = $processor($record);
$this->assertSame('[MASKED]', $processed->context['user']['email']);
}
public function testNonStringValueInContext(): void
{
$patterns = GdprProcessor::getDefaultPatterns();
$fieldPaths = [
'user.id' => GdprProcessor::maskWithRegex(),
];
$processor = new GdprProcessor($patterns, $fieldPaths);
$record = new LogRecord(
datetime: new JsonSerializableDateTimeImmutable(true),
channel: 'test',
level: Level::Info,
message: 'User registered',
context: ['user' => ['id' => 12345]],
extra: []
);
$processed = $processor($record);
$this->assertSame('12345', $processed->context['user']['id']);
}
public function testMissingFieldInContext(): void
{
$patterns = GdprProcessor::getDefaultPatterns();
$fieldPaths = [
'user.missing' => GdprProcessor::maskWithRegex(),
];
$processor = new GdprProcessor($patterns, $fieldPaths);
$record = new LogRecord(
datetime: new JsonSerializableDateTimeImmutable(true),
channel: 'test',
level: Level::Info,
message: static::USER_REGISTERED,
context: ['user' => ['email' => self::TEST_EMAIL]],
extra: []
);
$processed = $processor($record);
$this->assertArrayNotHasKey('missing', $processed->context['user']);
}
public function testPregReplaceErrorInMaskMessage(): void
{
// Invalid pattern triggers preg_replace error
$patterns = [
self::INVALID_REGEX => 'MASKED',
];
$calls = [];
$auditLogger = function ($path, $original, $masked) use (&$calls): void {
$calls[] = [$path, $original, $masked];
};
$processor = new GdprProcessor($patterns, [], [], $auditLogger);
$result = $processor->maskMessage('test');
$this->assertSame('test', $result);
$this->assertNotEmpty($calls);
$this->assertSame(['preg_replace_error', 'test', 'test'], $calls[0]);
}
public function testPregReplaceErrorInRegExpMessage(): void
{
$patterns = [
self::INVALID_REGEX => 'MASKED',
];
$calls = [];
$auditLogger = function ($path, $original, $masked) use (&$calls): void {
$calls[] = [$path, $original, $masked];
};
$processor = new GdprProcessor($patterns, [], [], $auditLogger);
$result = $processor->regExpMessage('test');
$this->assertSame('test', $result);
$this->assertNotEmpty($calls);
$this->assertSame(['preg_replace_error', 'test', 'test'], $calls[0]);
}
public function testRegExpMessageHandlesPregReplaceError(): void
{
$invalidPattern = ['/(unclosed[' => 'REPLACED'];
$called = false;
$logger = function ($type, $original, $message) use (&$called) {
$called = true;
$this->assertSame('preg_replace_error', $type);
$this->assertSame('test', $original);
$this->assertSame('test', $message);
};
$processor = new GdprProcessor($invalidPattern);
$processor->setAuditLogger($logger);
$result = $processor->regExpMessage('test');
$this->assertTrue($called, 'Audit logger should be called on preg_replace error');
$this->assertSame('test', $result, 'Message should be unchanged if preg_replace fails');
}
public function testRegExpMessageReturnsOriginalIfResultIsEmptyString(): void
{
$patterns = [
'/^foo$/' => '',
];
$processor = new GdprProcessor($patterns);
$result = $processor->regExpMessage('foo');
$this->assertSame('foo', $result, 'Should return original message if preg_replace result is empty string');
}
public function testRegExpMessageReturnsOriginalIfResultIsStringZero(): void
{
$patterns = [
'/^foo$/' => '0',
];
$processor = new GdprProcessor($patterns);
$result = $processor->regExpMessage('foo');
$this->assertSame('foo', $result, 'Should return original message if preg_replace result is string "0"');
}
}

View File

@@ -0,0 +1,267 @@
<?php
declare(strict_types=1);
namespace Tests;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\TestCase;
#[CoversClass(GdprProcessor::class)]
#[CoversMethod(GdprProcessor::class, '__construct')]
#[CoversMethod(GdprProcessor::class, '__invoke')]
#[CoversMethod(GdprProcessor::class, 'getDefaultPatterns')]
#[CoversMethod(GdprProcessor::class, 'maskMessage')]
#[CoversMethod(GdprProcessor::class, 'maskWithRegex')]
#[CoversMethod(GdprProcessor::class, 'recursiveMask')]
#[CoversMethod(GdprProcessor::class, 'regExpMessage')]
#[CoversMethod(GdprProcessor::class, 'removeField')]
#[CoversMethod(GdprProcessor::class, 'replaceWith')]
class RegexMaskProcessorTest extends TestCase
{
use TestHelpers;
private GdprProcessor $processor;
protected function setUp(): void
{
parent::setUp();
$patterns = [
"/\b\d{6}[-+A]?\d{3}[A-Z]\b/u" => "***MASKED***",
];
$fieldPaths = [
"user.ssn" => self::GDPR_REPLACEMENT,
"order.total" => GdprProcessor::maskWithRegex(),
];
$this->processor = new GdprProcessor($patterns, $fieldPaths);
}
public function testRemoveFieldRemovesKey(): void
{
$patterns = $this->processor::getDefaultPatterns();
$fieldPaths = ["user.ssn" => GdprProcessor::removeField()];
$processor = new GdprProcessor($patterns, $fieldPaths);
$record = $this->logEntry()->with(
message: "Remove SSN",
context: ["user" => ["ssn" => self::TEST_HETU, "name" => "John"]],
);
$result = ($processor)($record);
$this->assertArrayNotHasKey("ssn", $result["context"]["user"]);
$this->assertSame("John", $result["context"]["user"]["name"]);
}
public function testReplaceWithFieldReplacesValue(): void
{
$patterns = $this->processor::getDefaultPatterns();
$fieldPaths = ["user.card" => GdprProcessor::replaceWith("MASKED")];
$processor = new GdprProcessor($patterns, $fieldPaths);
$record = $this->logEntry()->with(
message: "Payment processed",
context: ["user" => ["card" => "1234123412341234"]],
);
$result = ($processor)($record);
$this->assertSame("MASKED", $result["context"]["user"]["card"]);
}
public function testCustomCallbackIsUsed(): void
{
$patterns = $this->processor::getDefaultPatterns();
$fieldPaths = ["user.name" => GdprProcessor::maskWithRegex()];
$customCallbacks = ["user.name" => fn($value): string => strtoupper((string)$value)];
$processor = new GdprProcessor($patterns, $fieldPaths, $customCallbacks);
$record = $this->logEntry()->with(
message: "Name logged",
context: ["user" => ["name" => "john"]],
);
$result = ($processor)($record);
$this->assertSame("JOHN", $result["context"]["user"]["name"]);
}
public function testAuditLoggerIsCalled(): void
{
$patterns = $this->processor::getDefaultPatterns();
$fieldPaths = ["user.email" => GdprProcessor::maskWithRegex()];
$auditCalls = [];
$auditLogger = function ($path, $original, $masked) use (&$auditCalls): void {
$auditCalls[] = [$path, $original, $masked];
};
$processor = new GdprProcessor($patterns, $fieldPaths, [], $auditLogger);
$record = $this->logEntry()->with(
message: self::USER_REGISTERED,
context: ["user" => ["email" => self::TEST_EMAIL]],
);
$processor($record);
$this->assertNotEmpty($auditCalls);
$this->assertSame(["user.email", "john.doe@example.com", "***EMAIL***"], $auditCalls[0]);
}
public function testMaskMessagePregReplaceError(): void
{
$patterns = [
self::INVALID_REGEX => 'MASKED',
];
$calls = [];
$auditLogger = function ($path, $original, $masked) use (&$calls): void {
$calls[] = [$path, $original, $masked];
};
$processor = new GdprProcessor($patterns, [], [], $auditLogger);
$result = $processor->maskMessage('test');
$this->assertSame('test', $result);
$this->assertNotEmpty($calls);
$this->assertSame(['preg_replace_error', 'test', 'test'], $calls[0]);
}
public function testRegExpMessagePregReplaceError(): void
{
$patterns = [
self::INVALID_REGEX => 'MASKED',
];
$calls = [];
$auditLogger = function ($path, $original, $masked) use (&$calls): void {
$calls[] = [$path, $original, $masked];
};
$processor = new GdprProcessor($patterns, [], [], $auditLogger);
$result = $processor->regExpMessage('test');
$this->assertSame('test', $result);
$this->assertNotEmpty($calls);
$this->assertSame(['preg_replace_error', 'test', 'test'], $calls[0]);
}
public function testStringReplacementBackwardCompatibility(): void
{
$patterns = $this->processor::getDefaultPatterns();
$fieldPaths = ["user.email" => '[MASKED]'];
$processor = new GdprProcessor($patterns, $fieldPaths);
$record = $this->logEntry()->with(
message: self::USER_REGISTERED,
context: ["user" => ["email" => self::TEST_EMAIL]],
);
$result = ($processor)($record);
$this->assertSame('[MASKED]', $result["context"]["user"]["email"]);
}
public function testNonStringValueInContextIsUnchanged(): void
{
$patterns = $this->processor::getDefaultPatterns();
$fieldPaths = ["user.id" => GdprProcessor::maskWithRegex()];
$processor = new GdprProcessor($patterns, $fieldPaths);
$record = $this->logEntry()->with(
message: self::USER_REGISTERED,
context: ["user" => ["id" => 12345]],
);
$result = ($processor)($record);
$this->assertSame('12345', $result["context"]["user"]["id"]);
}
public function testMissingFieldInContextIsIgnored(): void
{
$patterns = $this->processor::getDefaultPatterns();
$fieldPaths = ["user.missing" => GdprProcessor::maskWithRegex()];
$processor = new GdprProcessor($patterns, $fieldPaths);
$record = $this->logEntry()->with(
message: self::USER_REGISTERED,
context: ["user" => ["email" => self::TEST_EMAIL]],
);
$result = ($processor)($record);
$this->assertArrayNotHasKey('missing', $result["context"]["user"]);
}
public function testHetuMasking(): void
{
$testHetu = [self::TEST_HETU, "131052+308T", "131052A308T"];
foreach ($testHetu as $hetu) {
$record = $this->logEntry()->with(message: 'ID: ' . $hetu);
$result = ($this->processor)($record);
$this->assertSame("ID: ***MASKED***", $result["message"]);
}
}
public function testReplacesContextUserSsnWithCustomReplacement(): void
{
$record = $this->logEntry()->with(
message: "Login",
context: ["user" => ["ssn" => self::TEST_HETU]],
);
$result = ($this->processor)($record);
$this->assertSame(self::GDPR_REPLACEMENT, $result["context"]["user"]["ssn"]);
}
public function testMasksOrderTotalUsingRegexWhenReplacementIsEmpty(): void
{
$record = $this->logEntry()->with(
message: "Order created",
context: ["order" => ["total" => self::TEST_HETU . " €150"]],
);
$result = ($this->processor)($record);
$this->assertSame("***MASKED*** €150", $result["context"]["order"]["total"]);
}
public function testNoMaskingWhenPatternDoesNotMatch(): void
{
$record = $this->logEntry()->with(
message: "No sensitive data here",
context: ["user" => ["ssn" => "not-a-hetu"]],
);
$result = ($this->processor)($record);
$this->assertSame("No sensitive data here", $result["message"]);
$this->assertSame(self::GDPR_REPLACEMENT, $result["context"]["user"]["ssn"]);
}
public function testMissingFieldPathIsIgnored(): void
{
$record = $this->logEntry()->with(
message: "Missing field",
context: ["user" => ["name" => "John"]],
);
$result = ($this->processor)($record);
$this->assertArrayNotHasKey("ssn", $result["context"]["user"]);
}
public function testMaskMessageDirect(): void
{
$patterns = [
'/foo/' => 'bar',
'/baz/' => 'qux',
];
$processor = new GdprProcessor($patterns);
$masked = $processor->maskMessage('foo and baz');
$this->assertSame('bar and qux', $masked);
}
public function testRecursiveMaskDirect(): void
{
$patterns = [
'/secret/' => 'MASKED',
];
$processor = new class ($patterns) extends GdprProcessor {
public function callRecursiveMask($data)
{
return $this->recursiveMask($data);
}
};
$data = [
'a' => 'secret',
'b' => ['c' => 'secret'],
'd' => 123,
];
$masked = $processor->callRecursiveMask($data);
$this->assertSame([
'a' => 'MASKED',
'b' => ['c' => 'MASKED'],
'd' => '123',
], $masked);
}
public function testStaticHelpers(): void
{
$regex = GdprProcessor::maskWithRegex();
$remove = GdprProcessor::removeField();
$replace = GdprProcessor::replaceWith('MASKED');
$this->assertSame('mask_regex', $regex->type);
$this->assertSame('remove', $remove->type);
$this->assertSame('replace', $replace->type);
$this->assertSame('MASKED', $replace->replacement);
}
}

87
tests/TestHelpers.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
/** @noinspection GrazieInspection */
/** @noinspection PhpMultipleClassDeclarationsInspection */
declare(strict_types=1);
namespace Tests;
use DateTimeImmutable;
use Monolog\JsonSerializableDateTimeImmutable;
use Monolog\Level;
use Monolog\Logger;
use Monolog\LogRecord;
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;
use Stringable;
trait TestHelpers
{
private const GDPR_REPLACEMENT = '[GDPR]';
private const TEST_HETU = '131052-308T';
private const TEST_CC = '1234567812345678';
public const TEST_EMAIL = 'john.doe@example.com';
public const MASKED_EMAIL = '***EMAIL***';
public const MASKED_SECRET = '***MASKED***';
public const USER_REGISTERED = 'User registered';
private const INVALID_REGEX = '/[invalid/';
// ]'/' this should fix the issue with the regex breaking highlighting in the test
/**
* @source \Monolog\LogRecord::__construct
*/
protected function logEntry(
int|string|Level $level = Level::Warning,
string|Stringable $message = "test",
array $context = [],
string $channel = "test",
DateTimeImmutable $datetime = new JsonSerializableDateTimeImmutable(true),
array $extra = [],
): LogRecord {
return new LogRecord(
datetime: $datetime,
channel: $channel,
level: Logger::toMonologLevel($level),
message: (string) $message,
context: $context,
extra: $extra,
);
}
protected function getReflection(
object|string $object,
string $methodName = '',
): ReflectionMethod {
if (empty($methodName) && is_string($object)) {
$method = new ReflectionMethod($object);
} else {
$method = new ReflectionMethod($object, $methodName);
}
/** @noinspection PhpExpressionResultUnusedInspection */
$method->setAccessible(true);
return $method;
}
/**
* Returns a reflection of the given class.
*
* @psalm-api
* @noinspection PhpUnused
*/
protected function noOperation(): void
{
// This method intentionally left blank.
// It can be used to indicate a no-operation in tests.
}
}