mirror of
https://github.com/ivuorinen/monolog-gdpr-filter.git
synced 2026-01-26 03:34:00 +00:00
feat: initial commit
This commit is contained in:
19
.editorconfig
Normal file
19
.editorconfig
Normal 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
2
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Global owners for the repository
|
||||
* @ivuorinen
|
||||
145
.github/CODE_OF_CONDUCT.md
vendored
Normal file
145
.github/CODE_OF_CONDUCT.md
vendored
Normal 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
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
79
.github/copilot-instructions.md
vendored
Normal 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
20
.github/renovate.json
vendored
Normal 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
46
.github/workflows/codeql.yml
vendored
Normal 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
22
.github/workflows/phpcs.yaml
vendored
Normal 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
30
.github/workflows/pr-lint.yml
vendored
Normal 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
26
.github/workflows/stale.yml
vendored
Normal 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
41
.github/workflows/sync-labels.yml
vendored
Normal 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
59
.github/workflows/test-coverage.yaml
vendored
Normal 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
20
.gitignore
vendored
Normal 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
13
.markdownlint.json
Normal 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
35
.mega-linter.yml
Normal 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
63
.pre-commit-config.yaml
Normal 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
1
.shellcheckrc
Normal file
@@ -0,0 +1 @@
|
||||
disable=SC2129
|
||||
0
.yamlignore
Normal file
0
.yamlignore
Normal file
13
.yamllint.yml
Normal file
13
.yamllint.yml
Normal 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
21
LICENSE
Normal 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
21
LICENSE.md
Normal 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
224
README.md
Normal 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
59
composer.json
Normal 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
6070
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
phpcs.xml
Normal file
10
phpcs.xml
Normal 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
28
phpunit.xml
Normal 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
23
psalm.xml
Normal 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
28
rector.php
Normal 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
19
src/FieldMaskConfig.php
Normal 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
275
src/GdprProcessor.php
Normal 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;
|
||||
}
|
||||
}
|
||||
77
tests/AdvancedRegexMaskProcessorTest.php
Normal file
77
tests/AdvancedRegexMaskProcessorTest.php
Normal 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"]);
|
||||
}
|
||||
}
|
||||
36
tests/FieldMaskConfigTest.php
Normal file
36
tests/FieldMaskConfigTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
146
tests/GdprDefaultPatternsTest.php
Normal file
146
tests/GdprDefaultPatternsTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
128
tests/GdprProcessorMethodsTest.php
Normal file
128
tests/GdprProcessorMethodsTest.php
Normal 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
330
tests/GdprProcessorTest.php
Normal 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"');
|
||||
}
|
||||
}
|
||||
267
tests/RegexMaskProcessorTest.php
Normal file
267
tests/RegexMaskProcessorTest.php
Normal 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
87
tests/TestHelpers.php
Normal 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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user