mirror of
https://github.com/ivuorinen/monolog-gdpr-filter.git
synced 2026-03-17 19:02:55 +00:00
Compare commits
87 Commits
v1.0.0
...
4ec9653cd8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ec9653cd8 | ||
|
|
347faa80d1 | ||
|
|
4d9f925e89 | ||
|
|
d43de372e3 | ||
|
|
16a986001c | ||
|
|
8425411b6c | ||
|
|
57acc7847f | ||
| d1cbf50c5e | |||
|
|
e26312a6ee | ||
| b0925ce489 | |||
| e58397a75d | |||
| f6b0f864b4 | |||
|
|
0fd7cd099f | ||
| 38946574a4 | |||
|
|
1be44fff9d | ||
|
|
3be9c07d6c | ||
|
|
8ec91aad35 | ||
|
|
5eb01578d2 | ||
|
|
110598e921 | ||
|
|
9af85cb9b1 | ||
|
|
1a60d2b573 | ||
|
|
97ac6b1eae | ||
| 47564c5cd6 | |||
|
|
3d3448dcf0 | ||
|
|
f16eb2a095 | ||
|
|
451726a365 | ||
|
|
966618ec5a | ||
|
|
c3f5ddcc45 | ||
|
|
e499663b5d | ||
|
|
c89bc1ae72 | ||
|
|
74ec52721e | ||
|
|
7fe55b86f8 | ||
|
|
2a157f1871 | ||
|
|
36c4fd6e1d | ||
|
|
497353f4f3 | ||
|
|
4ab3db8a12 | ||
|
|
b3eea46780 | ||
| 86deca0371 | |||
| 8866daaf33 | |||
|
|
b1eb567b92 | ||
|
|
170cfb2fc9 | ||
|
|
b5fec58dd5 | ||
|
|
6307a37e4d | ||
|
|
1967ee722b | ||
|
|
03d24479c0 | ||
|
|
8d82b70304 | ||
|
|
10923e99e9 | ||
|
|
7a48d493c4 | ||
|
|
ad11859b46 | ||
| c3d6b8b1c6 | |||
| e293587296 | |||
| ac4559ae48 | |||
| c30c136a92 | |||
|
|
5f2793ca99 | ||
|
|
ddfa3151ea | ||
|
|
433a2830f3 | ||
|
|
e37bbbedcd | ||
|
|
294e5e5f3c | ||
|
|
44f6cdc380 | ||
|
|
0ba827a9fb | ||
|
|
6afc04d67d | ||
|
|
5bf81ef083 | ||
|
|
5166e41fbc | ||
| 00c6f76c97 | |||
|
|
63637900c8 | ||
|
|
da4cf50c95 | ||
|
|
79e8fe5bd6 | ||
|
|
263199f72c | ||
|
|
3cf7e7b222 | ||
|
|
2bcc8071fd | ||
|
|
014f4e1da1 | ||
|
|
6fa57dee2d | ||
|
|
a5a285d527 | ||
|
|
902e2861b6 | ||
|
|
bc78843e94 | ||
|
|
4d1eb0f3d8 | ||
|
|
6c69098c38 | ||
|
|
cc70f1b331 | ||
|
|
50e41ba710 | ||
|
|
7325fe8700 | ||
|
|
275f2231cc | ||
|
|
2b16eaaa68 | ||
|
|
74ff852b34 | ||
|
|
3bcc0fe551 | ||
|
|
dcccea1cc3 | ||
|
|
0d45cacdc1 | ||
|
|
4925262cea |
@@ -17,3 +17,9 @@ max_line_length = 120
|
||||
|
||||
[*.{md,json,yml,yaml,xml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.json]
|
||||
max_line_length = 200
|
||||
|
||||
[{CHANGELOG.md,TODO.md}]
|
||||
max_line_length = 300
|
||||
|
||||
17
.github/ISSUE_TEMPLATE/bug_report.md
vendored
17
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -12,6 +12,7 @@ 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 '....'
|
||||
@@ -24,15 +25,17 @@ A clear and concise description of what you expected to happen.
|
||||
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]
|
||||
|
||||
- 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]
|
||||
|
||||
- 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.
|
||||
|
||||
4
.github/copilot-instructions.md
vendored
4
.github/copilot-instructions.md
vendored
@@ -9,8 +9,8 @@ field-level configuration, and custom callbacks. It is designed for easy integra
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
- **Language:** PHP 8.2+
|
||||
- **PHP Version:** Ensure compatibility with PHP 8.2 and above.
|
||||
- **Language:** PHP 8.4+
|
||||
- **PHP Version:** Ensure compatibility with PHP 8.4 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.
|
||||
|
||||
25
.github/renovate.json
vendored
25
.github/renovate.json
vendored
@@ -1,20 +1,33 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["github>ivuorinen/renovate-config"],
|
||||
"extends": [
|
||||
"github>ivuorinen/renovate-config"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"matchUpdateTypes": [
|
||||
"minor",
|
||||
"patch"
|
||||
],
|
||||
"matchCurrentVersion": "!/^0/",
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"matchDepTypes": ["devDependencies"],
|
||||
"matchDepTypes": [
|
||||
"devDependencies"
|
||||
],
|
||||
"automerge": true
|
||||
}
|
||||
],
|
||||
"schedule": ["before 4am on monday"],
|
||||
"schedule": [
|
||||
"before 4am on monday"
|
||||
],
|
||||
"vulnerabilityAlerts": {
|
||||
"labels": ["security"],
|
||||
"assignees": ["ivuorinen"]
|
||||
"labels": [
|
||||
"security"
|
||||
],
|
||||
"assignees": [
|
||||
"ivuorinen"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
122
.github/workflows/ci.yml
vendored
Normal file
122
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php-version: ["8.4", "8.5"]
|
||||
|
||||
name: PHP ${{ matrix.php-version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
extensions: mbstring, xml, ctype, iconv, intl, json
|
||||
tools: composer:v2
|
||||
coverage: pcov
|
||||
|
||||
- name: Get composer cache directory
|
||||
id: composer-cache
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache composer dependencies
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress
|
||||
|
||||
- name: Run PHPUnit tests
|
||||
run: composer test
|
||||
|
||||
- name: Run Psalm static analysis
|
||||
run: ./vendor/bin/psalm --show-info=true
|
||||
|
||||
- name: Run PHPStan static analysis
|
||||
run: ./vendor/bin/phpstan analyse --memory-limit=1G --no-progress
|
||||
|
||||
- name: Run PHP_CodeSniffer
|
||||
run: ./vendor/bin/phpcs src/ tests/ rector.php --warning-severity=0
|
||||
|
||||
- name: Run Rector (dry-run)
|
||||
run: ./vendor/bin/rector --dry-run --no-progress-bar
|
||||
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
name: Coverage
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
|
||||
with:
|
||||
php-version-file: '.php-version'
|
||||
extensions: mbstring, xml, ctype, iconv, intl, json
|
||||
tools: composer:v2
|
||||
coverage: pcov
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: composer test:ci
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./coverage.xml
|
||||
fail_ci_if_error: false
|
||||
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
name: Security Analysis
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
|
||||
with:
|
||||
php-version-file: '.php-version'
|
||||
extensions: mbstring, xml, ctype, iconv, intl, json
|
||||
tools: composer:v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress
|
||||
|
||||
- name: Run security audit
|
||||
run: composer audit
|
||||
|
||||
- name: Check for known security vulnerabilities
|
||||
uses: symfonycorp/security-checker-action@258311ef7ac571f1310780ef3d79fc5abef642b5 # v5
|
||||
36
.github/workflows/codeql.yml
vendored
36
.github/workflows/codeql.yml
vendored
@@ -1,46 +1,34 @@
|
||||
---
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
name: 'CodeQL'
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ['main']
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
- cron: '30 1 * * 0' # Run at 1:30 AM UTC every Sunday
|
||||
- cron: "30 1 * * 0"
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
packages: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ['javascript'] # Add languages used in your actions
|
||||
|
||||
language: ["actions"]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4
|
||||
- name: CodeQL Analysis
|
||||
uses: ivuorinen/actions/codeql-analysis@7f6a23b59316795c4b3cb3b3b28dd53e53655a33 # v2026.03.11
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
language: ${{ 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}}'
|
||||
|
||||
8
.github/workflows/phpcs.yaml
vendored
8
.github/workflows/phpcs.yaml
vendored
@@ -1,3 +1,5 @@
|
||||
---
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
name: Code Style Check
|
||||
|
||||
on:
|
||||
@@ -13,8 +15,10 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: shivammathur/setup-php@34a5396826718e0013f08e3e639d1c315d5f6b23 # 2.35.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
|
||||
with:
|
||||
php-version-file: '.php-version'
|
||||
- name: Install dependencies
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
- name: Run PHP_CodeSniffer (PSR-12)
|
||||
|
||||
16
.github/workflows/pr-lint.yml
vendored
16
.github/workflows/pr-lint.yml
vendored
@@ -4,15 +4,16 @@ name: Lint Code Base
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, main]
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [master, main]
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: read-all
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
Linter:
|
||||
@@ -20,11 +21,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
statuses: write
|
||||
contents: read
|
||||
actions: write
|
||||
contents: write
|
||||
issues: write
|
||||
packages: read
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
steps:
|
||||
- name: Run PR Lint
|
||||
# https://github.com/ivuorinen/actions
|
||||
uses: ivuorinen/actions/pr-lint@8476cd4675ea8210eadf4a267bbeb13bddea4e75 # 25.7.21
|
||||
uses: ivuorinen/actions/pr-lint@7f6a23b59316795c4b3cb3b3b28dd53e53655a33 # v2026.03.11
|
||||
|
||||
76
.github/workflows/release.yml
vendored
Normal file
76
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
name: Create Release
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
|
||||
with:
|
||||
php-version-file: '.php-version'
|
||||
extensions: mbstring, xml, ctype, iconv, intl, json
|
||||
tools: composer:v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress --optimize-autoloader
|
||||
|
||||
- name: Run tests
|
||||
run: composer test
|
||||
|
||||
- name: Run linting
|
||||
run: composer lint
|
||||
|
||||
- name: Get tag name
|
||||
id: tag
|
||||
run: echo "name=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Extract changelog for this version
|
||||
id: changelog
|
||||
run: |
|
||||
# Extract changelog section for this version
|
||||
if [ -f CHANGELOG.md ]; then
|
||||
# Get content between this version and next version header
|
||||
awk '/^## \[${{ steps.tag.outputs.name }}\]/{flag=1; next} /^## \[/{flag=0} flag' CHANGELOG.md > /tmp/changelog.txt
|
||||
if [ -s /tmp/changelog.txt ]; then
|
||||
{
|
||||
echo "content<<EOF"
|
||||
cat /tmp/changelog.txt
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "content=Release ${{ steps.tag.outputs.name }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "content=Release ${{ steps.tag.outputs.name }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Archive source code
|
||||
run: |
|
||||
mkdir -p release
|
||||
composer archive --format=zip --dir=release --file=monolog-gdpr-filter-${{ steps.tag.outputs.name }}
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
with:
|
||||
name: ${{ steps.tag.outputs.name }}
|
||||
body: ${{ steps.changelog.outputs.content }}
|
||||
draft: false
|
||||
prerelease: ${{ contains(steps.tag.outputs.name, '-') }}
|
||||
files: ./release/monolog-gdpr-filter-${{ steps.tag.outputs.name }}.zip
|
||||
4
.github/workflows/stale.yml
vendored
4
.github/workflows/stale.yml
vendored
@@ -4,7 +4,7 @@ name: Stale
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 8 * * *' # Every day at 08:00
|
||||
- cron: "0 8 * * *" # Every day at 08:00
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -23,4 +23,4 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: ivuorinen/actions/stale@8476cd4675ea8210eadf4a267bbeb13bddea4e75 # 25.7.21
|
||||
- uses: ivuorinen/actions/stale@7f6a23b59316795c4b3cb3b3b28dd53e53655a33 # v2026.03.11
|
||||
|
||||
13
.github/workflows/sync-labels.yml
vendored
13
.github/workflows/sync-labels.yml
vendored
@@ -8,10 +8,10 @@ on:
|
||||
- main
|
||||
- master
|
||||
paths:
|
||||
- '.github/labels.yml'
|
||||
- '.github/workflows/sync-labels.yml'
|
||||
- ".github/labels.yml"
|
||||
- ".github/workflows/sync-labels.yml"
|
||||
schedule:
|
||||
- cron: '34 5 * * *' # Run every day at 05:34 AM UTC
|
||||
- cron: "34 5 * * *" # Run every day at 05:34 AM UTC
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
merge_group:
|
||||
@@ -20,7 +20,8 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: read-all
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
labels:
|
||||
@@ -34,8 +35,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: ⤵️ Checkout Repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: ⤵️ Sync Latest Labels Definitions
|
||||
uses: ivuorinen/actions/sync-labels@8476cd4675ea8210eadf4a267bbeb13bddea4e75 # 25.7.21
|
||||
uses: ivuorinen/actions/sync-labels@7f6a23b59316795c4b3cb3b3b28dd53e53655a33 # v2026.03.11
|
||||
|
||||
23
.github/workflows/test-coverage.yaml
vendored
23
.github/workflows/test-coverage.yaml
vendored
@@ -1,3 +1,5 @@
|
||||
---
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
name: Test & Coverage
|
||||
|
||||
on:
|
||||
@@ -6,7 +8,7 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions: read-all
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -17,12 +19,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@34a5396826718e0013f08e3e639d1c315d5f6b23 # 2.35.0
|
||||
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
|
||||
with:
|
||||
coverage: pcov
|
||||
php-version-file: '.php-version'
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
@@ -35,24 +38,24 @@ jobs:
|
||||
run: composer test:ci
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage.xml
|
||||
|
||||
- name: Code Coverage Summary Report
|
||||
id: coverage-summary
|
||||
uses: saschanowak/CloverCodeCoverageSummary@217593f67675e88fe1e6afeab0175018eb37deaa # 1.1.0
|
||||
uses: saschanowak/CloverCodeCoverageSummary@74291a4a2e8b848605aae10d27adbe7d17aa71e5 # 1.1.1
|
||||
with:
|
||||
filename: coverage.xml
|
||||
|
||||
- name: 'Add Code Coverage to Job Summary'
|
||||
- name: "Add Code Coverage to Job Summary"
|
||||
run: |
|
||||
cat code-coverage-summary.md >> $GITHUB_STEP_SUMMARY
|
||||
cat code-coverage-details.md >> $GITHUB_STEP_SUMMARY
|
||||
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
|
||||
- name: "Add Code Coverage Summary as PR Comment"
|
||||
uses: marocchino/sticky-pull-request-comment@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
recreate: true
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -18,3 +18,5 @@
|
||||
# Ignore test coverage reports
|
||||
/coverage/
|
||||
coverage.xml
|
||||
coverage*
|
||||
*.bak
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"MD024": {
|
||||
"siblings_only": true
|
||||
},
|
||||
"MD029": false,
|
||||
"MD033": false,
|
||||
"MD041": false
|
||||
}
|
||||
|
||||
4
.markdownlintignore
Normal file
4
.markdownlintignore
Normal file
@@ -0,0 +1,4 @@
|
||||
vendor/
|
||||
node_modules/
|
||||
coverage/
|
||||
.git/
|
||||
@@ -35,3 +35,8 @@ TYPESCRIPT_ES_CONFIG_FILE: .eslintrc.json
|
||||
|
||||
FILTER_REGEX_EXCLUDE: >
|
||||
(vendor|node_modules|\.automation/test|docs/json-schemas)
|
||||
|
||||
PHP_PHPCS_CLI_LINT_MODE: project
|
||||
PHP_PHPCS_ARGUMENTS: "--warning-severity=0"
|
||||
PHP_PSALM_CLI_LINT_MODE: project
|
||||
PHP_PSALM_ARGUMENTS: "--memory-limit=1G"
|
||||
|
||||
134
.php-cs-fixer.dist.php
Normal file
134
.php-cs-fixer.dist.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$finder = PhpCsFixer\Finder::create()
|
||||
->in(__DIR__ . '/src')
|
||||
->in(__DIR__ . '/tests')
|
||||
->name('*.php')
|
||||
->notPath('vendor');
|
||||
|
||||
return (new PhpCsFixer\Config())
|
||||
->setRiskyAllowed(true)
|
||||
->setRules([
|
||||
// PSR-12 compliance
|
||||
'@PSR12' => true,
|
||||
|
||||
// Additional rules for better code quality
|
||||
'array_syntax' => ['syntax' => 'short'],
|
||||
'binary_operator_spaces' => ['default' => 'single_space'],
|
||||
'blank_line_after_opening_tag' => true,
|
||||
'blank_line_before_statement' => [
|
||||
'statements' => ['return', 'try', 'throw', 'if', 'switch', 'for', 'foreach', 'while', 'do'],
|
||||
],
|
||||
'cast_spaces' => true,
|
||||
'class_attributes_separation' => [
|
||||
'elements' => [
|
||||
'method' => 'one',
|
||||
'property' => 'one',
|
||||
'trait_import' => 'none',
|
||||
],
|
||||
],
|
||||
'concat_space' => ['spacing' => 'one'],
|
||||
'declare_strict_types' => true,
|
||||
'fully_qualified_strict_types' => true,
|
||||
'function_typehint_space' => true,
|
||||
'general_phpdoc_tag_rename' => true,
|
||||
'include' => true,
|
||||
'increment_style' => ['style' => 'post'],
|
||||
'linebreak_after_opening_tag' => true,
|
||||
'lowercase_cast' => true,
|
||||
'magic_constant_casing' => true,
|
||||
'magic_method_casing' => true,
|
||||
'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline'],
|
||||
'native_function_casing' => true,
|
||||
'native_function_type_declaration_casing' => true,
|
||||
'new_with_braces' => true,
|
||||
'no_alias_language_construct_call' => true,
|
||||
'no_alternative_syntax' => true,
|
||||
'no_binary_string' => true,
|
||||
'no_blank_lines_after_phpdoc' => true,
|
||||
'no_empty_phpdoc' => true,
|
||||
'no_empty_statement' => true,
|
||||
'no_extra_blank_lines' => [
|
||||
'tokens' => [
|
||||
'extra',
|
||||
'throw',
|
||||
'use',
|
||||
],
|
||||
],
|
||||
'no_leading_import_slash' => true,
|
||||
'no_leading_namespace_whitespace' => true,
|
||||
'no_mixed_echo_print' => true,
|
||||
'no_multiline_whitespace_around_double_arrow' => true,
|
||||
'no_short_bool_cast' => true,
|
||||
'no_singleline_whitespace_before_semicolons' => true,
|
||||
'no_spaces_around_offset' => true,
|
||||
'no_trailing_comma_in_singleline_array' => true,
|
||||
'no_unneeded_control_parentheses' => true,
|
||||
'no_unneeded_curly_braces' => true,
|
||||
'no_unused_imports' => true,
|
||||
'no_whitespace_before_comma_in_array' => true,
|
||||
'normalize_index_brace' => true,
|
||||
'object_operator_without_whitespace' => true,
|
||||
'ordered_imports' => [
|
||||
'imports_order' => ['class', 'function', 'const'],
|
||||
'sort_algorithm' => 'alpha',
|
||||
],
|
||||
'phpdoc_align' => ['align' => 'left'],
|
||||
'phpdoc_annotation_without_dot' => true,
|
||||
'phpdoc_indent' => true,
|
||||
'phpdoc_inline_tag_normalizer' => true,
|
||||
'phpdoc_no_access' => true,
|
||||
'phpdoc_no_alias_tag' => true,
|
||||
'phpdoc_no_empty_return' => true,
|
||||
'phpdoc_no_package' => true,
|
||||
'phpdoc_no_useless_inheritdoc' => true,
|
||||
'phpdoc_order' => true,
|
||||
'phpdoc_return_self_reference' => true,
|
||||
'phpdoc_scalar' => true,
|
||||
'phpdoc_separation' => true,
|
||||
'phpdoc_single_line_var_spacing' => true,
|
||||
'phpdoc_summary' => true,
|
||||
'phpdoc_tag_type' => true,
|
||||
'phpdoc_to_comment' => true,
|
||||
'phpdoc_trim' => true,
|
||||
'phpdoc_trim_consecutive_blank_line_separation' => true,
|
||||
'phpdoc_types' => true,
|
||||
'phpdoc_types_order' => ['null_adjustment' => 'always_last'],
|
||||
'phpdoc_var_without_name' => true,
|
||||
'return_type_declaration' => true,
|
||||
'semicolon_after_instruction' => true,
|
||||
'short_scalar_cast' => true,
|
||||
'single_blank_line_before_namespace' => true,
|
||||
'single_class_element_per_statement' => true,
|
||||
'single_line_comment_style' => true,
|
||||
'single_quote' => true,
|
||||
'space_after_semicolon' => ['remove_in_empty_for_expressions' => true],
|
||||
'standardize_increment' => true,
|
||||
'standardize_not_equals' => true,
|
||||
'ternary_operator_spaces' => true,
|
||||
'trailing_comma_in_multiline' => true,
|
||||
'trim_array_spaces' => true,
|
||||
'unary_operator_spaces' => true,
|
||||
'visibility_required' => true,
|
||||
'whitespace_after_comma_in_array' => true,
|
||||
|
||||
// Risky rules for better code quality
|
||||
'strict_comparison' => true,
|
||||
'strict_param' => true,
|
||||
'array_push' => true,
|
||||
'combine_consecutive_issets' => true,
|
||||
'combine_consecutive_unsets' => true,
|
||||
'dir_constant' => true,
|
||||
'function_to_constant' => true,
|
||||
'is_null' => true,
|
||||
'modernize_types_casting' => true,
|
||||
'no_alias_functions' => true,
|
||||
'no_homoglyph_names' => true,
|
||||
'non_printable_character' => true,
|
||||
'php_unit_construct' => true,
|
||||
'psr_autoloading' => true,
|
||||
'self_accessor' => true,
|
||||
])
|
||||
->setFinder($finder);
|
||||
1
.php-version
Normal file
1
.php-version
Normal file
@@ -0,0 +1 @@
|
||||
8.4
|
||||
@@ -1,9 +1,8 @@
|
||||
---
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: requirements-txt-fixer
|
||||
- id: detect-private-key
|
||||
- id: trailing-whitespace
|
||||
args: [--markdown-linebreak-ext=md]
|
||||
@@ -23,41 +22,36 @@ repos:
|
||||
args: [--autofix, --no-sort-keys]
|
||||
|
||||
- repo: https://github.com/igorshubovych/markdownlint-cli
|
||||
rev: v0.44.0
|
||||
rev: v0.48.0
|
||||
hooks:
|
||||
- id: markdownlint
|
||||
args: [-c, .markdownlint.json, --fix]
|
||||
|
||||
- repo: https://github.com/adrienverge/yamllint
|
||||
rev: v1.37.0
|
||||
rev: v1.38.0
|
||||
hooks:
|
||||
- id: yamllint
|
||||
|
||||
- repo: https://github.com/scop/pre-commit-shfmt
|
||||
rev: v3.11.0-1
|
||||
rev: v3.12.0-2
|
||||
hooks:
|
||||
- id: shfmt
|
||||
|
||||
- repo: https://github.com/koalaman/shellcheck-precommit
|
||||
rev: v0.10.0
|
||||
rev: v0.11.0
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
args: ['--severity=warning']
|
||||
args: ["--severity=warning"]
|
||||
|
||||
- repo: https://github.com/rhysd/actionlint
|
||||
rev: v1.7.7
|
||||
rev: v1.7.11
|
||||
hooks:
|
||||
- id: actionlint
|
||||
args: ['-shellcheck=']
|
||||
|
||||
- repo: https://github.com/renovatebot/pre-commit-hooks
|
||||
rev: 39.227.2
|
||||
hooks:
|
||||
- id: renovate-config-validator
|
||||
args: ["-shellcheck="]
|
||||
|
||||
- repo: https://github.com/bridgecrewio/checkov.git
|
||||
rev: '3.2.400'
|
||||
rev: "3.2.508"
|
||||
hooks:
|
||||
- id: checkov
|
||||
args:
|
||||
- '--quiet'
|
||||
- "--quiet"
|
||||
|
||||
226
CHANGELOG.md
Normal file
226
CHANGELOG.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Phase 6: Code Quality & Architecture ✅ COMPLETED (2025-07-29)**:
|
||||
- **Custom Exception Classes**: Comprehensive exception hierarchy with rich context and error reporting
|
||||
- `GdprProcessorException` - Base exception with context support for key-value error reporting
|
||||
- `InvalidRegexPatternException` - Regex compilation errors with PCRE error details and ReDoS detection
|
||||
- `MaskingOperationFailedException` - Failed masking operations with operation context and value previews
|
||||
- `AuditLoggingException` - Audit logger failures with operation tracking and serialization error handling
|
||||
- `RecursionDepthExceededException` - Deep nesting issues with recommendations and circular reference detection
|
||||
- **Masking Strategy Interface System**: Complete extensible strategy pattern implementation
|
||||
- `MaskingStrategyInterface` - Comprehensive method contracts for masking, validation, priority, and configuration
|
||||
- `AbstractMaskingStrategy` - Base class with utilities for path matching, type preservation, and value conversion
|
||||
- `RegexMaskingStrategy` - Pattern-based masking with ReDoS protection and include/exclude path filtering
|
||||
- `FieldPathMaskingStrategy` - Dot-notation field path masking with wildcard support and FieldMaskConfig integration
|
||||
- `ConditionalMaskingStrategy` - Context-aware conditional masking with AND/OR logic and factory methods
|
||||
- `DataTypeMaskingStrategy` - PHP type-based masking with type-specific conversion and factory methods
|
||||
- `StrategyManager` - Priority-based coordination with strategy validation, statistics, and default factory
|
||||
- **PHP 8.2+ Modernization**: Comprehensive codebase modernization with backward compatibility
|
||||
- Converted `FieldMaskConfig` to readonly class for immutability
|
||||
- Added modern type declarations with proper imports (`Throwable`, `Closure`, `JsonException`)
|
||||
- Applied `::class` syntax for class references instead of `get_class()`
|
||||
- Implemented arrow functions where appropriate for concise code
|
||||
- Used modern array comparisons (`=== []` instead of `empty()`)
|
||||
- Enhanced string formatting with `sprintf()` for better performance
|
||||
- Added newline consistency and proper imports throughout codebase
|
||||
- **Code Quality Improvements**: Significant enhancements to code standards and type safety
|
||||
- Fixed 287 PHPCS style issues automatically through code beautifier
|
||||
- Reduced Psalm static analysis errors from 100+ to 61 (mostly false positives)
|
||||
- Achieved 97.89% type coverage in Psalm analysis
|
||||
- Applied 29 Rector modernization rules for PHP 8.2+ features
|
||||
- Enhanced docblock types and removed redundant return tags
|
||||
- Improved parameter type coercion and null safety
|
||||
- **Phase 5: Advanced Features ✅ COMPLETED (2025-07-29)**:
|
||||
- **Data Type-Based Masking**: Configurable type-specific masks for integers, strings, booleans, null, arrays, and objects
|
||||
- **Conditional Masking**: Context-aware masking based on log level, channel, and custom rules with AND logic
|
||||
- **Helper Methods**: Creating common conditional rules (level-based, channel-based, context field presence)
|
||||
- **JSON String Masking**: Detection and recursive processing of JSON strings within log messages with validation
|
||||
- **Rate Limiting**: Configurable audit logger rate limiting to prevent log flooding (profiles: strict, default, relaxed, testing)
|
||||
- **Operation Classification**: Different rate limits for different operation types (JSON, conditional, regex, general)
|
||||
- **Enhanced Audit Logging**: Detailed error context, conditional rule decisions, and operation tracking
|
||||
- **Comprehensive Testing**: 30+ new tests across 6 test files (DataTypeMaskingTest, ConditionalMaskingTest, JsonMaskingTest, RateLimiterTest, RateLimitedAuditLoggerTest, GdprProcessorRateLimitingIntegrationTest)
|
||||
- **Examples**: Created comprehensive examples for conditional masking and rate limiting features
|
||||
- **Phase 4: Performance Optimizations ✅ COMPLETED (2025-07-29)**:
|
||||
- **Exceptional Performance**: Optimized processing to 0.004ms per operation (exceeded 0.007ms target)
|
||||
- **Static Pattern Caching**: 6.6% performance improvement after warmup with regex pattern validation
|
||||
- **Recursion Depth Limiting**: Configurable maximum depth (default: 100 levels) preventing stack overflow
|
||||
- **Memory-Efficient Processing**: Chunked processing for large nested arrays (1000+ items)
|
||||
- **Automatic Garbage Collection**: For very large datasets (10,000+ items) with memory optimization
|
||||
- **Memory Usage**: Optimized to only 2MB for 2,000 nested items with efficient data structures
|
||||
- **Phase 4: Laravel Integration Package ✅ COMPLETED (2025-07-29)**:
|
||||
- **Service Provider**: Complete Laravel Service Provider with auto-registration and configuration
|
||||
- **Configuration**: Publishable configuration file with comprehensive GDPR processing options
|
||||
- **Facade**: Laravel Facade for easy access (`Gdpr::regExpMessage()`, `Gdpr::createProcessor()`)
|
||||
- **Artisan Commands**: Pattern testing and debugging commands (`gdpr:test-pattern`, `gdpr:debug`)
|
||||
- **HTTP Middleware**: Request/response GDPR logging middleware for web applications
|
||||
- **Documentation**: Comprehensive Laravel integration examples and step-by-step setup guide
|
||||
- **Phase 4: Testing & Quality Assurance ✅ COMPLETED (2025-07-29)**:
|
||||
- **Performance Benchmarks**: Tests measuring actual optimization impact (0.004ms per operation)
|
||||
- **Memory Usage Tests**: Validation for large datasets with memory efficiency tracking
|
||||
- **Concurrent Processing**: Simulation tests for high-volume concurrent processing scenarios
|
||||
- **Pattern Caching**: Effectiveness validation showing 6.6% improvement after warmup
|
||||
- **Major GDPR Pattern Expansion**: Added 15+ new patterns doubling coverage
|
||||
- IPv4 and IPv6 IP address patterns
|
||||
- Vehicle registration number patterns (US license plates)
|
||||
- National ID patterns (UK National Insurance, Canadian SIN)
|
||||
- Bank account patterns (UK sort codes, Canadian transit numbers)
|
||||
- Health insurance patterns (US Medicare, European Health Insurance Cards)
|
||||
- **Enhanced Security**:
|
||||
- Regex pattern validation to prevent injection attacks
|
||||
- ReDoS (Regular Expression Denial of Service) protection
|
||||
- Comprehensive error handling replacing `@` suppression
|
||||
- **Type Safety Improvements**:
|
||||
- Fixed all PHPStan type errors for better code quality
|
||||
- Enhanced type annotations throughout codebase
|
||||
- Improved generic type specifications
|
||||
- **Development Infrastructure**:
|
||||
- PHPStan configuration file with maximum level analysis
|
||||
- GitHub Actions CI/CD pipeline with multi-PHP version testing
|
||||
- Automated security scanning and dependency updates
|
||||
- Comprehensive documentation (CONTRIBUTING.md, SECURITY.md)
|
||||
- **Quality Assurance**:
|
||||
- Enhanced test suite with improved error handling validation
|
||||
- All tests passing across PHP 8.2, 8.3, and 8.4
|
||||
- Comprehensive linting with Psalm, PHPStan, and PHPCS
|
||||
|
||||
### Changed
|
||||
|
||||
- **Phase 6: Code Quality & Architecture (2025-07-29)**:
|
||||
- **Exception System**: Replaced generic exceptions with specific, context-rich exception classes
|
||||
- **Strategy Pattern**: Refactored masking logic into pluggable strategy system with priority management
|
||||
- **Type System**: Enhanced type safety with PHP 8.2+ features and strict type declarations
|
||||
- **Code Standards**: Applied modern PHP conventions and automated code quality improvements
|
||||
- **Phase 5: Advanced Features (2025-07-29)**:
|
||||
- **Improved Error Handling**: Replaced error suppression with proper try-catch blocks
|
||||
- **Enhanced Audit Logging**: More detailed error context and security measures
|
||||
- **Better Pattern Organization**: Grouped patterns by category with clear documentation
|
||||
- **Type Safety**: Stricter type declarations and validation throughout
|
||||
|
||||
### Security
|
||||
|
||||
- **Phase 6: Enhanced Security (2025-07-29)**:
|
||||
- **ReDoS Protection**: Enhanced regular expression denial of service detection in InvalidRegexPatternException
|
||||
- **Type Safety**: Improved parameter validation and type coercion safety
|
||||
- **Error Context**: Added secure error reporting without exposing sensitive data
|
||||
- **Phase 5: Critical Security Fixes (2025-07-29)**:
|
||||
- Eliminated regex injection vulnerabilities
|
||||
- Added ReDoS attack protection
|
||||
- Implemented pattern validation for untrusted input
|
||||
- Enhanced audit logger security measures
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Phase 6: Code Quality Fixes (2025-07-29)**:
|
||||
- Fixed 287 PHPCS formatting and style issues
|
||||
- Resolved Psalm type coercion warnings and parameter type issues
|
||||
- Improved null safety and optional parameter handling
|
||||
- Enhanced docblock accuracy and type specifications
|
||||
- **Phase 5: Stability Fixes (2025-07-29)**:
|
||||
- All PHPStan type safety errors resolved
|
||||
- Improved error handling in regex processing
|
||||
- Fixed potential security vulnerabilities in pattern handling
|
||||
- Resolved test compatibility issues across PHP versions
|
||||
|
||||
## [Previous Versions]
|
||||
|
||||
### [1.0.0] - Initial Release
|
||||
|
||||
- Basic GDPR processor implementation
|
||||
- Initial pattern set (Finnish SSN, US SSN, IBAN, etc.)
|
||||
- Monolog integration
|
||||
- Laravel compatibility
|
||||
- Field-level masking with dot notation
|
||||
- Custom callback support
|
||||
- Audit logging functionality
|
||||
|
||||
---
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From 1.x to 2.x (Upcoming)
|
||||
|
||||
#### Breaking Changes
|
||||
|
||||
- None currently - maintaining backward compatibility
|
||||
|
||||
#### Deprecated Features
|
||||
|
||||
- `setAuditLogger()` method parameter type changed (constructor parameter preferred)
|
||||
|
||||
#### New Features
|
||||
|
||||
- 15+ new GDPR patterns available by default
|
||||
- Enhanced security validation
|
||||
- Improved error handling and logging
|
||||
|
||||
#### Security Improvements
|
||||
|
||||
- All regex patterns now validated for safety
|
||||
- ReDoS protection enabled by default
|
||||
- Enhanced audit logging security
|
||||
|
||||
### Developer Notes
|
||||
|
||||
#### Pattern Validation
|
||||
|
||||
New patterns are automatically validated for:
|
||||
|
||||
- Basic regex syntax correctness
|
||||
- ReDoS attack patterns
|
||||
- Security vulnerabilities
|
||||
|
||||
#### Error Handling
|
||||
|
||||
The library now uses proper exception handling instead of error suppression:
|
||||
|
||||
```php
|
||||
// Old (deprecated)
|
||||
$result = @preg_replace($pattern, $replacement, $input);
|
||||
|
||||
// New (secure)
|
||||
try {
|
||||
$result = preg_replace($pattern, $replacement, $input);
|
||||
if ($result === null) {
|
||||
// Handle error properly
|
||||
}
|
||||
} catch (\Error $e) {
|
||||
// Handle regex compilation errors
|
||||
}
|
||||
```
|
||||
|
||||
#### Type Safety
|
||||
|
||||
Enhanced type declarations provide better IDE support and error detection:
|
||||
|
||||
```php
|
||||
// Improved type annotations
|
||||
/**
|
||||
* @param array<string, string> $patterns
|
||||
* @param array<string, FieldMaskConfig|string> $fieldPaths
|
||||
*/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute to this project.
|
||||
|
||||
## Security
|
||||
|
||||
Please see [SECURITY.md](SECURITY.md) for information about reporting security vulnerabilities.
|
||||
|
||||
## Support
|
||||
|
||||
- **Documentation**: See README.md for usage examples
|
||||
- **Issues**: Report bugs and request features via GitHub Issues
|
||||
- **Discussions**: General questions via GitHub Discussions
|
||||
198
CLAUDE.md
Normal file
198
CLAUDE.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
composer install
|
||||
|
||||
# Run all linting tools
|
||||
composer lint
|
||||
|
||||
# Auto-fix code issues (runs Rector, Psalm fix, and PHPCBF)
|
||||
composer lint:fix
|
||||
|
||||
# Run tests with coverage
|
||||
composer test
|
||||
composer test:coverage # Generates HTML coverage report
|
||||
|
||||
# Individual linting tools
|
||||
composer lint:tool:phpcs # PHP_CodeSniffer
|
||||
composer lint:tool:phpcbf # PHP Code Beautifier and Fixer
|
||||
composer lint:tool:psalm # Static analysis
|
||||
composer lint:tool:psalm:fix # Auto-fix Psalm issues
|
||||
composer lint:tool:rector # Code refactoring
|
||||
|
||||
# Preview changes before applying (dry-run)
|
||||
composer lint:tool:rector -- --dry-run
|
||||
composer lint:tool:psalm -- --alter --dry-run
|
||||
|
||||
# Check for hardcoded constant values
|
||||
php check_for_constants.php # Basic scan
|
||||
php check_for_constants.php --verbose # Show line context
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
composer test
|
||||
|
||||
# Run specific test file
|
||||
./vendor/bin/phpunit tests/GdprProcessorTest.php
|
||||
|
||||
# Run specific test method
|
||||
./vendor/bin/phpunit --filter testMethodName
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
This is a Monolog processor library for GDPR compliance that masks sensitive data in logs.
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **GdprProcessor** (`src/GdprProcessor.php`): The main processor implementing Monolog's `ProcessorInterface`
|
||||
- Processes log records to mask/remove/replace sensitive data
|
||||
- Supports regex patterns, field paths (dot notation), and custom callbacks
|
||||
- Provides static factory methods for common field configurations
|
||||
- Includes default GDPR patterns (SSN, credit cards, emails, etc.)
|
||||
|
||||
2. **FieldMaskConfig** (`src/FieldMaskConfig.php`): Configuration value object with three types:
|
||||
- `MASK_REGEX`: Apply regex patterns to field value
|
||||
- `REMOVE`: Remove field entirely from context
|
||||
- `REPLACE`: Replace with static value
|
||||
|
||||
### Key Design Patterns
|
||||
|
||||
- **Processor Pattern**: Implements Monolog's ProcessorInterface for log record transformation
|
||||
- **Value Objects**: FieldMaskConfig is immutable configuration
|
||||
- **Factory Methods**: Static methods for creating common configurations
|
||||
- **Dot Notation**: Uses `adbario/php-dot-notation` for nested array access (e.g., "user.email")
|
||||
|
||||
### Laravel Integration
|
||||
|
||||
The library can be integrated with Laravel in two ways:
|
||||
|
||||
1. Service Provider registration
|
||||
2. Using a Tap class to modify logging channels
|
||||
|
||||
## Code Standards
|
||||
|
||||
- **PHP 8.4+** with strict types
|
||||
- **PSR-12** coding standard (enforced by PHP_CodeSniffer)
|
||||
- **Psalm Level 5** static analysis with conservative configuration
|
||||
- **PHPStan Level 6** for additional code quality insights
|
||||
- **Rector** for safe automated code improvements
|
||||
- **EditorConfig**: 4 spaces, LF line endings, UTF-8, trim trailing whitespace
|
||||
- **PHPUnit 11** for testing with strict configuration
|
||||
|
||||
### Static Analysis & Linting Policy
|
||||
|
||||
**All issues reported by static analysis tools MUST be fixed.** The project uses a comprehensive static analysis setup:
|
||||
|
||||
- **Psalm**: Conservative Level 5 with targeted suppressions for valid patterns
|
||||
- **PHPStan**: Level 6 analysis with Laravel compatibility
|
||||
- **Rector**: Safe automated improvements (return types, string casting, etc.)
|
||||
- **PHPCS**: PSR-12 compliance enforcement
|
||||
- **SonarQube**: Cloud-based code quality and security analysis (quality gate must pass)
|
||||
|
||||
**Issue Resolution Priority:**
|
||||
|
||||
1. **Fix the underlying issue** (preferred approach)
|
||||
2. **Refactor code** to avoid the issue pattern
|
||||
3. **Use safe automated fixes** via `composer lint:fix`
|
||||
4. **Ask before suppressing** - Suppression should be used only as an absolute last resort and requires
|
||||
explicit discussion
|
||||
|
||||
**Zero-Tolerance Policy:**
|
||||
|
||||
- **ALL issues must be addressed** - this includes ERROR, WARNING, and INFO level issues
|
||||
- **INFO-level issues are NOT acceptable** - they indicate potential problems that should be resolved
|
||||
- **Never ignore or suppress issues** without explicit approval and documented justification
|
||||
- **Psalm INFO messages** should be addressed by:
|
||||
- Refactoring code to avoid the pattern
|
||||
- Adding proper type hints and assertions
|
||||
- Using `@psalm-suppress` ONLY when absolutely necessary and with clear comments explaining why
|
||||
- **Exit code must be 0** - any non-zero exit from linting tools is a failure
|
||||
|
||||
**Tip:** Use `git stash` before running `composer lint:fix` to easily revert changes if needed.
|
||||
|
||||
### SonarQube-Specific Guidelines
|
||||
|
||||
SonarQube is a **static analysis tool** that analyzes code structure,
|
||||
not runtime behavior. Unlike human reviewers, it does NOT understand:
|
||||
|
||||
- PHPUnit's `expectException()` mechanism
|
||||
- Test intent or context
|
||||
- Comments explaining why code is written a certain way
|
||||
|
||||
**Common SonarQube issues and their fixes:**
|
||||
|
||||
1. **S1848: Useless object instantiation**
|
||||
- **Issue**: `new ClassName()` in tests that expect exceptions
|
||||
- **Why it occurs**: SonarQube doesn't understand `expectException()` means the object creation is the test
|
||||
- **Fix**: Assign to variable and add assertion: `$obj = new ClassName(); $this->assertInstanceOf(...)`
|
||||
|
||||
2. **S4833: Replace require_once with use statement**
|
||||
- **Issue**: Direct file inclusion instead of autoloading
|
||||
- **Fix**: Use composer's autoloader and proper `use` statements
|
||||
|
||||
3. **S1172: Remove unused function parameter**
|
||||
- **Issue**: Callback parameters that aren't used in the function body
|
||||
- **Fix**: Remove unused parameters from function signature
|
||||
|
||||
4. **S112: Define dedicated exception instead of generic one**
|
||||
- **Issue**: Throwing `\RuntimeException` or `\Exception` directly
|
||||
- **Fix**: Use project-specific exceptions like `RuleExecutionException`, `MaskingOperationFailedException`
|
||||
|
||||
5. **S1192: Define constant instead of duplicating literal**
|
||||
- **Issue**: String/number literals repeated 3+ times
|
||||
- **Fix**: Add to `TestConstants` or `MaskConstants` and use the constant reference
|
||||
|
||||
6. **S1481: Remove unused local variable**
|
||||
- **Issue**: Variable assigned but never read
|
||||
- **Fix**: Remove assignment or use the variable
|
||||
|
||||
**IMPORTANT**: Comments and docblocks do NOT fix SonarQube issues. The code structure itself must be changed.
|
||||
|
||||
## Code Quality
|
||||
|
||||
### Constant Usage
|
||||
|
||||
To reduce code duplication and improve maintainability
|
||||
(as required by SonarQube), the project uses centralized constants:
|
||||
|
||||
- **MaskConstants** (`src/MaskConstants.php`): Mask replacement values (e.g., `MASK_MASKED`, `MASK_REDACTED`)
|
||||
- **TestConstants** (`tests/TestConstants.php`): Test data values, patterns, field paths, messages
|
||||
|
||||
**Always use constants instead of hardcoded strings** for values defined in these files.
|
||||
Use the constant checker to identify hardcoded values:
|
||||
|
||||
```bash
|
||||
# Scan for hardcoded constant values
|
||||
php check_for_constants.php
|
||||
|
||||
# Show line context for each match
|
||||
php check_for_constants.php --verbose
|
||||
```
|
||||
|
||||
The checker intelligently scans all PHP files and reports where constant references should be used:
|
||||
|
||||
- **MaskConstants** checked in both `src/` and `tests/` directories
|
||||
- **TestConstants** checked only in `tests/` directory (not enforced in production code)
|
||||
- Filters out common false positives like array keys and internal identifiers
|
||||
- Helps maintain SonarQube code quality standards
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Always run `composer lint:fix` before manual fixes**
|
||||
- **Fix all linting issues** - suppression requires explicit approval
|
||||
- **Use constants instead of hardcoded values** - run `php check_for_constants.php` to verify
|
||||
- The library focuses on GDPR compliance - be careful when modifying masking logic
|
||||
- Default patterns include Finnish SSN, US SSN, IBAN, credit cards, emails, phones, and IPs
|
||||
- Audit logging feature can track when sensitive data was masked for compliance
|
||||
- All static analysis tools are configured to work harmoniously without conflicts
|
||||
277
CONTRIBUTING.md
Normal file
277
CONTRIBUTING.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# Contributing to Monolog GDPR Filter
|
||||
|
||||
Thank you for your interest in contributing to Monolog GDPR Filter!
|
||||
This document provides guidelines and information about contributing to this project.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Making Changes](#making-changes)
|
||||
- [Testing](#testing)
|
||||
- [Code Quality](#code-quality)
|
||||
- [Submitting Changes](#submitting-changes)
|
||||
- [Adding New GDPR Patterns](#adding-new-gdpr-patterns)
|
||||
- [Security Issues](#security-issues)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
This project adheres to a code of conduct that promotes a welcoming and inclusive environment.
|
||||
Please be respectful in all interactions.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- PHP 8.4 or higher
|
||||
- Composer
|
||||
- Git
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. **Fork and clone the repository:**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/monolog-gdpr-filter.git
|
||||
cd monolog-gdpr-filter
|
||||
```
|
||||
|
||||
2. **Install dependencies:**
|
||||
|
||||
```bash
|
||||
composer install
|
||||
```
|
||||
|
||||
3. **Verify the setup:**
|
||||
|
||||
```bash
|
||||
composer test
|
||||
composer lint
|
||||
```
|
||||
|
||||
## Making Changes
|
||||
|
||||
### Branch Structure
|
||||
|
||||
- `main` - Stable releases
|
||||
- `develop` - Development branch for new features
|
||||
- Feature branches: `feature/description`
|
||||
- Bug fixes: `bugfix/description`
|
||||
- Security fixes: `security/description`
|
||||
|
||||
### Workflow
|
||||
|
||||
1. **Create a feature branch:**
|
||||
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
|
||||
2. **Make your changes** following our coding standards
|
||||
|
||||
3. **Test your changes:**
|
||||
|
||||
```bash
|
||||
composer test
|
||||
composer lint
|
||||
```
|
||||
|
||||
4. **Commit your changes:**
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add new GDPR pattern for vehicle registration"
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
composer test
|
||||
|
||||
# Run tests with coverage (requires Xdebug)
|
||||
composer test:coverage
|
||||
|
||||
# Run specific test file
|
||||
./vendor/bin/phpunit tests/GdprProcessorTest.php
|
||||
|
||||
# Run specific test method
|
||||
./vendor/bin/phpunit --filter testMethodName
|
||||
```
|
||||
|
||||
### Writing Tests
|
||||
|
||||
- Write tests for all new functionality
|
||||
- Follow existing test patterns in the `tests/` directory
|
||||
- Use descriptive test method names
|
||||
- Include both positive and negative test cases
|
||||
- Test edge cases and error conditions
|
||||
|
||||
### Test Structure
|
||||
|
||||
```php
|
||||
public function testNewGdprPattern(): void
|
||||
{
|
||||
$processor = new GdprProcessor([
|
||||
'/your-pattern/' => '***MASKED***',
|
||||
]);
|
||||
|
||||
$result = $processor->regExpMessage('sensitive data');
|
||||
|
||||
$this->assertSame('***MASKED***', $result);
|
||||
}
|
||||
```
|
||||
|
||||
## Code Quality
|
||||
|
||||
### Coding Standards
|
||||
|
||||
This project follows:
|
||||
|
||||
- **PSR-12** coding standard
|
||||
- **PHPStan level max** for static analysis
|
||||
- **Psalm** for additional type checking
|
||||
|
||||
### Quality Tools
|
||||
|
||||
```bash
|
||||
# Run all linting tools
|
||||
composer lint
|
||||
|
||||
# Auto-fix code style issues
|
||||
composer lint:fix
|
||||
|
||||
# Individual tools
|
||||
composer lint:tool:phpcs # PHP_CodeSniffer
|
||||
composer lint:tool:phpcbf # PHP Code Beautifier and Fixer
|
||||
composer lint:tool:psalm # Static analysis
|
||||
composer lint:tool:phpstan # Static analysis (max level)
|
||||
composer lint:tool:rector # Code refactoring
|
||||
```
|
||||
|
||||
### Code Style Guidelines
|
||||
|
||||
- Use strict types: `declare(strict_types=1);`
|
||||
- Use proper type hints for all parameters and return types
|
||||
- Document all public methods with PHPDoc
|
||||
- Use meaningful variable and method names
|
||||
- Keep methods focused and concise
|
||||
- Avoid deep nesting (max 3 levels)
|
||||
|
||||
## Submitting Changes
|
||||
|
||||
### Pull Request Process
|
||||
|
||||
1. **Ensure all checks pass:**
|
||||
- All tests pass
|
||||
- All linting checks pass
|
||||
- No merge conflicts
|
||||
|
||||
2. **Write a clear PR description:**
|
||||
- What changes were made
|
||||
- Why the changes were necessary
|
||||
- Any breaking changes
|
||||
- Link to related issues
|
||||
|
||||
3. **PR Title Format:**
|
||||
- `feat: add new feature`
|
||||
- `fix: resolve bug in pattern matching`
|
||||
- `docs: update README examples`
|
||||
- `refactor: improve code structure`
|
||||
- `test: add missing test coverage`
|
||||
|
||||
### Commit Message Guidelines
|
||||
|
||||
Follow [Conventional Commits](https://conventionalcommits.org/):
|
||||
|
||||
```text
|
||||
type(scope): description
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer(s)]
|
||||
```
|
||||
|
||||
Types:
|
||||
|
||||
- `feat`: New features
|
||||
- `fix`: Bug fixes
|
||||
- `docs`: Documentation changes
|
||||
- `style`: Code style changes
|
||||
- `refactor`: Code refactoring
|
||||
- `test`: Adding tests
|
||||
- `chore`: Maintenance tasks
|
||||
|
||||
## Adding New GDPR Patterns
|
||||
|
||||
### Pattern Guidelines
|
||||
|
||||
When adding new GDPR patterns to the `getDefaultPatterns()` method:
|
||||
|
||||
1. **Be Specific**: Patterns should be specific enough to avoid false positives
|
||||
2. **Security First**: Validate patterns using the built-in `isValidRegexPattern()` method
|
||||
3. **Documentation**: Include clear comments explaining what the pattern matches
|
||||
4. **Testing**: Add comprehensive tests for the new pattern
|
||||
|
||||
### Pattern Structure
|
||||
|
||||
```php
|
||||
// Pattern comment explaining what it matches
|
||||
'/your-regex-pattern/' => '***MASKED_TYPE***',
|
||||
```
|
||||
|
||||
### Pattern Testing
|
||||
|
||||
```php
|
||||
public function testNewPattern(): void
|
||||
{
|
||||
$patterns = GdprProcessor::getDefaultPatterns();
|
||||
$processor = new GdprProcessor($patterns);
|
||||
|
||||
// Test positive case
|
||||
$result = $processor->regExpMessage('sensitive-data-123');
|
||||
$this->assertSame('***MASKED_TYPE***', $result);
|
||||
|
||||
// Test negative case (should not match)
|
||||
$result = $processor->regExpMessage('normal-data');
|
||||
$this->assertSame('normal-data', $result);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern Validation
|
||||
|
||||
Before submitting, validate your pattern:
|
||||
|
||||
```php
|
||||
// Test pattern safety
|
||||
GdprProcessor::validatePatterns([
|
||||
'/your-pattern/' => '***TEST***'
|
||||
]);
|
||||
|
||||
// Test ReDoS resistance
|
||||
$processor = new GdprProcessor(['/your-pattern/' => '***TEST***']);
|
||||
$result = $processor->regExpMessage('very-long-string-to-test-performance');
|
||||
```
|
||||
|
||||
## Security Issues
|
||||
|
||||
If you discover a security vulnerability, please refer to our
|
||||
[Security Policy](SECURITY.md) for responsible disclosure procedures.
|
||||
|
||||
## Questions and Support
|
||||
|
||||
- **Issues**: Use GitHub Issues for bug reports and feature requests
|
||||
- **Discussions**: Use GitHub Discussions for questions and general discussion
|
||||
- **Documentation**: Check README.md and code comments first
|
||||
|
||||
## Recognition
|
||||
|
||||
Contributors are recognized in:
|
||||
|
||||
- Git commit history
|
||||
- Release notes for significant contributions
|
||||
- Special thanks for security fixes
|
||||
|
||||
Thank you for contributing to Monolog GDPR Filter! 🎉
|
||||
548
README.md
548
README.md
@@ -1,143 +1,326 @@
|
||||
# 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.
|
||||
A PHP library providing a Monolog processor for GDPR compliance.
|
||||
Mask, remove, or replace sensitive data in logs using regex patterns, field-level configuration,
|
||||
custom callbacks, and advanced features like streaming, rate limiting, and k-anonymity.
|
||||
|
||||
## 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**
|
||||
### Core Masking
|
||||
|
||||
- **Regex-based masking** for patterns like SSNs, credit cards, emails, IPs, and more
|
||||
- **Field-level masking** using dot-notation paths with flexible configuration
|
||||
- **Custom callbacks** for advanced per-field masking logic
|
||||
- **Data type masking** to mask values based on their PHP type
|
||||
- **Serialized data support** for JSON, print_r, var_export, and serialize formats
|
||||
|
||||
### Enterprise Features
|
||||
|
||||
- **Fluent builder API** for readable processor configuration
|
||||
- **Streaming processor** for memory-efficient large file processing
|
||||
- **Rate-limited audit logging** to prevent log flooding
|
||||
- **Plugin system** for extensible pre/post-processing hooks
|
||||
- **K-anonymity support** for statistical privacy guarantees
|
||||
- **Retry and recovery** with configurable failure modes
|
||||
- **Conditional masking** based on log level, channel, or context
|
||||
|
||||
### Framework Integration
|
||||
|
||||
- **Monolog 3.x compatible** with ProcessorInterface implementation
|
||||
- **Laravel integration** with service provider, middleware, and console commands
|
||||
- **Audit logging** for compliance tracking and debugging
|
||||
|
||||
## Requirements
|
||||
|
||||
- PHP 8.4 or higher
|
||||
- Monolog 3.x
|
||||
|
||||
## Installation
|
||||
|
||||
Install via Composer:
|
||||
|
||||
```bash
|
||||
composer require ivuorinen/monolog-gdpr-filter
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Monolog Setup
|
||||
## Quick Start
|
||||
|
||||
```php
|
||||
use Monolog\Logger;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Level;
|
||||
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)
|
||||
// Create processor with default GDPR patterns
|
||||
$processor = new GdprProcessor(
|
||||
patterns: GdprProcessor::getDefaultPatterns(),
|
||||
fieldPaths: [
|
||||
'user.email' => FieldMaskConfig::remove(),
|
||||
'user.ssn' => FieldMaskConfig::replace('[REDACTED]'),
|
||||
]
|
||||
);
|
||||
|
||||
$logger->warning('This is a warning message.', [
|
||||
'user' => ['ssn' => '123456-900T'],
|
||||
'contact' => ['email' => 'user@example.com'],
|
||||
'payment' => ['card' => '1234567812345678'],
|
||||
// Integrate with Monolog
|
||||
$logger = new Logger('app');
|
||||
$logger->pushHandler(new StreamHandler('app.log', Level::Warning));
|
||||
$logger->pushProcessor($processor);
|
||||
|
||||
// Sensitive data is automatically masked
|
||||
$logger->warning('User login', [
|
||||
'user' => [
|
||||
'email' => 'john@example.com', // Will be removed
|
||||
'ssn' => '123-45-6789', // Will be replaced with [REDACTED]
|
||||
]
|
||||
]);
|
||||
```
|
||||
|
||||
### FieldMaskConfig Options
|
||||
## Core Concepts
|
||||
|
||||
- `GdprProcessor::maskWithRegex()` — Mask field value using regex patterns
|
||||
- `GdprProcessor::removeField()` — Remove field from context
|
||||
- `GdprProcessor::replaceWith($value)` — Replace field value with static value
|
||||
### Regex Patterns
|
||||
|
||||
### Custom Callbacks
|
||||
|
||||
Provide custom callbacks for specific fields:
|
||||
Define regex patterns to mask sensitive data in log messages and context values:
|
||||
|
||||
```php
|
||||
$customCallbacks = [
|
||||
'user.name' => fn($value) => strtoupper($value),
|
||||
$patterns = [
|
||||
'/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/' => '***EMAIL***',
|
||||
'/\b\d{3}-\d{2}-\d{4}\b/' => '***SSN***',
|
||||
'/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/' => '***CARD***',
|
||||
];
|
||||
|
||||
$processor = new GdprProcessor(patterns: $patterns);
|
||||
```
|
||||
|
||||
Use `GdprProcessor::getDefaultPatterns()` for a comprehensive set of pre-configured patterns
|
||||
covering SSNs, credit cards, emails, phone numbers, IBANs, IP addresses, and more.
|
||||
|
||||
### Field Path Masking (FieldMaskConfig)
|
||||
|
||||
Configure masking for specific fields using dot-notation paths:
|
||||
|
||||
```php
|
||||
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
|
||||
|
||||
$fieldPaths = [
|
||||
// Remove field entirely from logs
|
||||
'user.password' => FieldMaskConfig::remove(),
|
||||
|
||||
// Replace with static value
|
||||
'payment.card_number' => FieldMaskConfig::replace('[CARD]'),
|
||||
|
||||
// Apply processor's regex patterns to this field
|
||||
'user.bio' => FieldMaskConfig::useProcessorPatterns(),
|
||||
|
||||
// Apply custom regex pattern
|
||||
'user.phone' => FieldMaskConfig::regexMask('/\d{3}-\d{4}/', '***-****'),
|
||||
];
|
||||
```
|
||||
|
||||
### Audit Logger
|
||||
### Custom Callbacks
|
||||
|
||||
Optionally provide an audit logger callback to record masking actions:
|
||||
Provide custom masking functions for complex scenarios:
|
||||
|
||||
```php
|
||||
$auditLogger = function($path, $original, $masked) {
|
||||
// Log or store audit info
|
||||
};
|
||||
$customCallbacks = [
|
||||
'user.name' => fn($value) => strtoupper(substr($value, 0, 1)) . '***',
|
||||
'user.id' => fn($value) => hash('sha256', (string) $value),
|
||||
];
|
||||
|
||||
$processor = new GdprProcessor(
|
||||
patterns: [],
|
||||
fieldPaths: [],
|
||||
customCallbacks: $customCallbacks
|
||||
);
|
||||
```
|
||||
|
||||
> **IMPORTANT**: Be mindful what you send to your audit log. Passing the original value might defeat the whole purpose
|
||||
> of this project.
|
||||
## Basic Usage
|
||||
|
||||
### Direct GdprProcessor Usage
|
||||
|
||||
```php
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
|
||||
|
||||
$processor = new GdprProcessor(
|
||||
patterns: GdprProcessor::getDefaultPatterns(),
|
||||
fieldPaths: [
|
||||
'user.ssn' => FieldMaskConfig::remove(),
|
||||
'payment.card' => FieldMaskConfig::replace('[REDACTED]'),
|
||||
'contact.email' => FieldMaskConfig::useProcessorPatterns(),
|
||||
],
|
||||
customCallbacks: [
|
||||
'user.name' => fn($v) => strtoupper($v),
|
||||
],
|
||||
auditLogger: function($path, $original, $masked) {
|
||||
// Log masking operations for compliance
|
||||
error_log("Masked: $path");
|
||||
},
|
||||
maxDepth: 100,
|
||||
);
|
||||
```
|
||||
|
||||
### Using GdprProcessorBuilder (Recommended)
|
||||
|
||||
The builder provides a fluent, readable API:
|
||||
|
||||
```php
|
||||
use Ivuorinen\MonologGdprFilter\Builder\GdprProcessorBuilder;
|
||||
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->withDefaultPatterns()
|
||||
->addPattern('/custom-secret-\w+/', '[SECRET]')
|
||||
->addFieldPath('user.email', FieldMaskConfig::remove())
|
||||
->addFieldPath('user.ssn', FieldMaskConfig::replace('[SSN]'))
|
||||
->addCallback('user.id', fn($v) => hash('sha256', (string) $v))
|
||||
->withMaxDepth(50)
|
||||
->withAuditLogger(function($path, $original, $masked) {
|
||||
// Audit logging
|
||||
})
|
||||
->build();
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Conditional Masking
|
||||
|
||||
Apply masking only when specific conditions are met:
|
||||
|
||||
```php
|
||||
use Ivuorinen\MonologGdprFilter\ConditionalRuleFactory;
|
||||
use Monolog\Level;
|
||||
|
||||
$processor = new GdprProcessor(
|
||||
patterns: GdprProcessor::getDefaultPatterns(),
|
||||
conditionalRules: [
|
||||
// Only mask error-level logs
|
||||
'error_only' => ConditionalRuleFactory::createLevelBasedRule([Level::Error]),
|
||||
|
||||
// Only mask specific channels
|
||||
'app_channel' => ConditionalRuleFactory::createChannelBasedRule(['app', 'security']),
|
||||
|
||||
// Custom condition
|
||||
'has_user' => fn($record) => isset($record->context['user']),
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
### Data Type Masking
|
||||
|
||||
Mask values based on their PHP type:
|
||||
|
||||
```php
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants;
|
||||
|
||||
$processor = new GdprProcessor(
|
||||
patterns: [],
|
||||
dataTypeMasks: [
|
||||
'integer' => MaskConstants::MASK_INT,
|
||||
'double' => MaskConstants::MASK_FLOAT,
|
||||
'boolean' => MaskConstants::MASK_BOOL,
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
### Rate-Limited Audit Logging
|
||||
|
||||
Prevent audit log flooding in high-volume applications:
|
||||
|
||||
```php
|
||||
use Ivuorinen\MonologGdprFilter\RateLimitedAuditLogger;
|
||||
|
||||
$baseLogger = function($path, $original, $masked) {
|
||||
// Your audit logging logic
|
||||
};
|
||||
|
||||
// Create rate-limited wrapper (100 logs per minute)
|
||||
$rateLimitedLogger = new RateLimitedAuditLogger($baseLogger, 100, 60);
|
||||
|
||||
$processor = new GdprProcessor(
|
||||
patterns: GdprProcessor::getDefaultPatterns(),
|
||||
auditLogger: $rateLimitedLogger
|
||||
);
|
||||
|
||||
// Available rate limit profiles via factory
|
||||
$strictLogger = RateLimitedAuditLogger::create($baseLogger, 'strict'); // 50/min
|
||||
$defaultLogger = RateLimitedAuditLogger::create($baseLogger, 'default'); // 100/min
|
||||
$relaxedLogger = RateLimitedAuditLogger::create($baseLogger, 'relaxed'); // 200/min
|
||||
```
|
||||
|
||||
### Streaming Large Files
|
||||
|
||||
Process large log files with memory-efficient streaming:
|
||||
|
||||
```php
|
||||
use Ivuorinen\MonologGdprFilter\Streaming\StreamingProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\MaskingOrchestrator;
|
||||
|
||||
$orchestrator = new MaskingOrchestrator(GdprProcessor::getDefaultPatterns());
|
||||
$streaming = new StreamingProcessor($orchestrator, chunkSize: 1000);
|
||||
|
||||
// Process file line by line
|
||||
$lineParser = fn(string $line) => ['message' => $line, 'context' => []];
|
||||
|
||||
foreach ($streaming->processFile('large-app.log', $lineParser) as $maskedRecord) {
|
||||
// Write to output file or process further
|
||||
fwrite($output, $maskedRecord['message'] . "\n");
|
||||
}
|
||||
|
||||
// Or process to file directly
|
||||
$formatter = fn(array $record) => json_encode($record);
|
||||
$count = $streaming->processToFile($records, 'masked-output.log', $formatter);
|
||||
```
|
||||
|
||||
## Laravel Integration
|
||||
|
||||
You can integrate the GDPR processor with Laravel logging in two ways:
|
||||
|
||||
### 1. Service Provider
|
||||
### Service Provider
|
||||
|
||||
```php
|
||||
// app/Providers/AppServiceProvider.php
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot()
|
||||
public function boot(): void
|
||||
{
|
||||
$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));
|
||||
$processor = new GdprProcessor(
|
||||
patterns: GdprProcessor::getDefaultPatterns(),
|
||||
fieldPaths: [
|
||||
'user.email' => FieldMaskConfig::remove(),
|
||||
'user.password' => FieldMaskConfig::remove(),
|
||||
]
|
||||
);
|
||||
|
||||
$this->app['log']->getLogger()->pushProcessor($processor);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Tap Class (config/logging.php)
|
||||
### Tap Class
|
||||
|
||||
```php
|
||||
// app/Logging/GdprTap.php
|
||||
namespace App\Logging;
|
||||
|
||||
use Monolog\Logger;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
|
||||
|
||||
class GdprTap
|
||||
{
|
||||
public function __invoke(Logger $logger)
|
||||
public function __invoke(Logger $logger): void
|
||||
{
|
||||
$patterns = GdprProcessor::getDefaultPatterns();
|
||||
$fieldPaths = [
|
||||
'user.ssn' => '[GDPR]',
|
||||
'payment.card' => '[CC]',
|
||||
'contact.email' => '',
|
||||
'metadata.session' => '[SESSION]',
|
||||
];
|
||||
$logger->pushProcessor(new GdprProcessor($patterns, $fieldPaths));
|
||||
$processor = new GdprProcessor(
|
||||
patterns: GdprProcessor::getDefaultPatterns(),
|
||||
fieldPaths: [
|
||||
'user.email' => FieldMaskConfig::remove(),
|
||||
'payment.card' => FieldMaskConfig::replace('[CARD]'),
|
||||
]
|
||||
);
|
||||
|
||||
$logger->pushProcessor($processor);
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -146,79 +329,192 @@ Reference in `config/logging.php`:
|
||||
|
||||
```php
|
||||
'channels' => [
|
||||
'stack' => [
|
||||
'driver' => 'stack',
|
||||
'channels' => ['single'],
|
||||
'single' => [
|
||||
'driver' => 'single',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => 'debug',
|
||||
'tap' => [App\Logging\GdprTap::class],
|
||||
],
|
||||
// ...
|
||||
],
|
||||
```
|
||||
|
||||
## Configuration
|
||||
### Console Commands
|
||||
|
||||
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:
|
||||
The library provides Artisan commands for testing and debugging:
|
||||
|
||||
```bash
|
||||
# Test a pattern against sample data
|
||||
php artisan gdpr:test-pattern '/\b\d{3}-\d{2}-\d{4}\b/' 'SSN: 123-45-6789'
|
||||
|
||||
# Debug current GDPR configuration
|
||||
php artisan gdpr:debug
|
||||
```
|
||||
|
||||
## Plugin System
|
||||
|
||||
Extend the processor with custom pre/post-processing hooks:
|
||||
|
||||
```php
|
||||
use Ivuorinen\MonologGdprFilter\Contracts\MaskingPluginInterface;
|
||||
use Ivuorinen\MonologGdprFilter\Builder\GdprProcessorBuilder;
|
||||
|
||||
class CustomPlugin implements MaskingPluginInterface
|
||||
{
|
||||
public function getName(): string
|
||||
{
|
||||
return 'custom-plugin';
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
return 10; // Lower = earlier execution
|
||||
}
|
||||
|
||||
public function preProcessMessage(string $message): string
|
||||
{
|
||||
// Modify message before masking
|
||||
return $message;
|
||||
}
|
||||
|
||||
public function postProcessMessage(string $message): string
|
||||
{
|
||||
// Modify message after masking
|
||||
return $message;
|
||||
}
|
||||
|
||||
public function preProcessContext(array $context): array
|
||||
{
|
||||
return $context;
|
||||
}
|
||||
|
||||
public function postProcessContext(array $context): array
|
||||
{
|
||||
return $context;
|
||||
}
|
||||
}
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->withDefaultPatterns()
|
||||
->addPlugin(new CustomPlugin())
|
||||
->buildWithPlugins();
|
||||
```
|
||||
|
||||
## Default Patterns Reference
|
||||
|
||||
`GdprProcessor::getDefaultPatterns()` includes patterns for:
|
||||
|
||||
| Category | Data Types |
|
||||
| -------- | ---------- |
|
||||
| Personal IDs | Finnish SSN (HETU), US SSN, Passport numbers, National IDs |
|
||||
| Financial | Credit cards, IBAN, Bank account numbers |
|
||||
| Contact | Email addresses, Phone numbers (E.164) |
|
||||
| Technical | IPv4/IPv6 addresses, MAC addresses, API keys, Bearer tokens |
|
||||
| Health | Medicare numbers, European Health Insurance Card (EHIC) |
|
||||
| Dates | Birth dates in multiple formats |
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Pattern Optimization
|
||||
|
||||
Order patterns from most specific to most general:
|
||||
|
||||
```php
|
||||
// Recommended: specific patterns first
|
||||
$patterns = [
|
||||
'/\b\d{3}-\d{2}-\d{4}\b/' => '***SSN***', // Specific format
|
||||
'/\b\d+\b/' => '***NUMBER***', // Generic fallback
|
||||
];
|
||||
```
|
||||
|
||||
### Memory-Efficient Processing
|
||||
|
||||
For large datasets:
|
||||
|
||||
- Use `StreamingProcessor` for file-based processing
|
||||
- Configure appropriate `maxDepth` to limit recursion
|
||||
- Use rate-limited audit logging to prevent memory growth
|
||||
|
||||
### Pattern Caching
|
||||
|
||||
Patterns are validated and cached internally.
|
||||
For high-throughput applications, the library automatically caches compiled patterns.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Pattern Not Matching
|
||||
|
||||
```php
|
||||
// Test pattern in isolation
|
||||
$pattern = '/your-pattern/';
|
||||
if (preg_match($pattern, $testString)) {
|
||||
echo 'Pattern matches';
|
||||
}
|
||||
|
||||
// Validate pattern safety
|
||||
try {
|
||||
GdprProcessor::validatePatternsArray([
|
||||
'/your-pattern/' => '***MASKED***'
|
||||
]);
|
||||
} catch (PatternValidationException $e) {
|
||||
echo 'Invalid pattern: ' . $e->getMessage();
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Issues
|
||||
|
||||
- Reduce pattern count to essential patterns only
|
||||
- Use field-specific masking instead of broad regex patterns
|
||||
- Profile with audit logging to identify slow operations
|
||||
|
||||
### Audit Logger Issues
|
||||
|
||||
```php
|
||||
// Safe audit logging (never log original sensitive data)
|
||||
$auditLogger = function($path, $original, $masked) {
|
||||
error_log(sprintf(
|
||||
'GDPR Audit: %s - type=%s, masked=%s',
|
||||
$path,
|
||||
gettype($original),
|
||||
$original !== $masked ? 'yes' : 'no'
|
||||
));
|
||||
};
|
||||
```
|
||||
|
||||
## Testing and Quality
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
composer test
|
||||
```
|
||||
|
||||
To generate a code coverage report (HTML output in the `coverage/` directory):
|
||||
|
||||
```bash
|
||||
# Run tests with coverage report
|
||||
composer test:coverage
|
||||
```
|
||||
|
||||
### Linting & Static Analysis
|
||||
|
||||
To run all linters and static analysis:
|
||||
|
||||
```bash
|
||||
# Run all linters
|
||||
composer lint
|
||||
```
|
||||
|
||||
To automatically fix code style and static analysis issues:
|
||||
|
||||
```bash
|
||||
# Auto-fix code style issues
|
||||
composer lint:fix
|
||||
```
|
||||
|
||||
## Notable Implementation Details
|
||||
## Security
|
||||
|
||||
- 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.
|
||||
- All patterns are validated for safety before use to prevent regex injection attacks
|
||||
- The library includes ReDoS (Regular Expression Denial of Service) protection
|
||||
- Dangerous patterns with recursive structures or excessive backtracking are rejected
|
||||
|
||||
## Directory Structure
|
||||
|
||||
- `src/` — Main library source code
|
||||
- `tests/` — PHPUnit tests
|
||||
- `coverage/` — Code coverage reports
|
||||
- `vendor/` — Composer dependencies
|
||||
For security vulnerabilities, please see [SECURITY.md](SECURITY.md) for responsible disclosure guidelines.
|
||||
|
||||
## 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.
|
||||
This library helps mask and filter sensitive data for GDPR compliance, but it is your responsibility
|
||||
to ensure your application fully complies with all applicable legal requirements.
|
||||
This tool is provided as-is without warranty.
|
||||
Review your logging and data handling policies regularly with legal counsel.
|
||||
|
||||
## Contributing
|
||||
|
||||
If you would like to contribute to this project, please fork the repository and submit a pull request.
|
||||
Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details.
|
||||
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
266
SECURITY.md
Normal file
266
SECURITY.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# Security Policy
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Supported Versions](#supported-versions)
|
||||
- [Security Features](#security-features)
|
||||
- [Reporting Security Vulnerabilities](#reporting-security-vulnerabilities)
|
||||
- [Security Best Practices](#security-best-practices)
|
||||
- [Known Security Considerations](#known-security-considerations)
|
||||
- [Security Measures Implemented](#security-measures-implemented)
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We actively support the following versions with security updates:
|
||||
|
||||
| Version | Supported | PHP Requirements |
|
||||
| ------- | -------------------- | ---------------- |
|
||||
| 2.x | Active support | PHP 8.4+ |
|
||||
| 1.x | Security fixes only | PHP 8.4+ |
|
||||
|
||||
## Security Features
|
||||
|
||||
This library includes several built-in security features:
|
||||
|
||||
### 🛡️ Regex Injection Protection
|
||||
|
||||
- All regex patterns are validated before use
|
||||
- Input sanitization prevents malicious pattern injection
|
||||
- Built-in pattern validation using `isValidRegexPattern()`
|
||||
|
||||
### 🛡️ ReDoS (Regular Expression Denial of Service) Protection
|
||||
|
||||
- Automatic detection of dangerous regex patterns
|
||||
- Protection against nested quantifiers and excessive backtracking
|
||||
- Safe pattern compilation with error handling
|
||||
|
||||
### 🛡️ Secure Error Handling
|
||||
|
||||
- No error suppression (`@`) operators used
|
||||
- Proper exception handling for all regex operations
|
||||
- Comprehensive error logging for security monitoring
|
||||
|
||||
### 🛡️ Audit Trail Security
|
||||
|
||||
- Secure audit logging with configurable callbacks
|
||||
- Protection against sensitive data exposure in audit logs
|
||||
- Validation of audit logger parameters
|
||||
|
||||
## Reporting Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability, please follow these steps:
|
||||
|
||||
### 🚨 **DO NOT** create a public GitHub issue for security vulnerabilities
|
||||
|
||||
### ✅ **DO** report privately using one of these methods
|
||||
|
||||
1. **GitHub Security Advisories** (Preferred):
|
||||
- Go to the [Security tab](https://github.com/ivuorinen/monolog-gdpr-filter/security)
|
||||
- Click "Report a vulnerability"
|
||||
- Provide detailed information about the vulnerability
|
||||
|
||||
2. **Direct Email**:
|
||||
- Send to: [security@ivuorinen.com](mailto:security@ivuorinen.com)
|
||||
- Use subject: "SECURITY: Monolog GDPR Filter Vulnerability"
|
||||
- Include GPG encrypted message if possible
|
||||
|
||||
### 📝 What to Include in Your Report
|
||||
|
||||
Please provide as much information as possible:
|
||||
|
||||
- **Description**: Clear description of the vulnerability
|
||||
- **Impact**: Potential impact and attack scenarios
|
||||
- **Reproduction**: Step-by-step reproduction instructions
|
||||
- **Environment**: PHP version, library version, OS details
|
||||
- **Proof of Concept**: Code example demonstrating the issue
|
||||
- **Suggested Fix**: If you have ideas for remediation
|
||||
|
||||
### 🕒 Response Timeline
|
||||
|
||||
- **Initial Response**: Within 48 hours
|
||||
- **Vulnerability Assessment**: Within 1 week
|
||||
- **Fix Development**: Depends on severity (1-4 weeks)
|
||||
- **Release**: Security fixes are prioritized
|
||||
- **Public Disclosure**: After fix is released and users have time to update
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### For Users of This Library
|
||||
|
||||
#### ✅ Pattern Validation
|
||||
|
||||
Always validate custom patterns before use:
|
||||
|
||||
```php
|
||||
// Good: Validate custom patterns
|
||||
try {
|
||||
GdprProcessor::validatePatterns([
|
||||
'/your-custom-pattern/' => '***MASKED***'
|
||||
]);
|
||||
$processor = new GdprProcessor($patterns);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
// Handle invalid pattern
|
||||
}
|
||||
```
|
||||
|
||||
#### ✅ Secure Audit Logging
|
||||
|
||||
Be careful with audit logger implementation:
|
||||
|
||||
```php
|
||||
// Good: Secure audit logger
|
||||
$auditLogger = function (string $path, mixed $original, mixed $masked): void {
|
||||
// DON'T log the original sensitive data
|
||||
error_log("GDPR: Masked field '{$path}' - type: " . gettype($original));
|
||||
};
|
||||
|
||||
// Bad: Insecure audit logger
|
||||
$auditLogger = function (string $path, mixed $original, mixed $masked): void {
|
||||
// NEVER do this - logs sensitive data!
|
||||
error_log("GDPR: {$path} changed from {$original} to {$masked}");
|
||||
};
|
||||
```
|
||||
|
||||
#### ✅ Input Validation
|
||||
|
||||
Validate input when using custom callbacks:
|
||||
|
||||
```php
|
||||
// Good: Validate callback input
|
||||
$customCallback = function (mixed $value): string {
|
||||
if (!is_string($value)) {
|
||||
return '***INVALID***';
|
||||
}
|
||||
|
||||
// Additional validation
|
||||
if (strlen($value) > 1000) {
|
||||
return '***TOOLONG***';
|
||||
}
|
||||
|
||||
return preg_replace('/sensitive/', '***MASKED***', $value) ?? '***ERROR***';
|
||||
};
|
||||
```
|
||||
|
||||
#### ✅ Regular Updates
|
||||
|
||||
- Keep the library updated to get security fixes
|
||||
- Monitor security advisories
|
||||
- Review changelogs for security-related changes
|
||||
|
||||
### For Contributors
|
||||
|
||||
#### 🔒 Secure Development Practices
|
||||
|
||||
1. **Never commit sensitive data**:
|
||||
- No real credentials, tokens, or personal data in tests
|
||||
- Use placeholder data only
|
||||
- Review diffs before committing
|
||||
|
||||
2. **Validate all regex patterns**:
|
||||
|
||||
```php
|
||||
// Always test new patterns for security
|
||||
if (!$this->isValidRegexPattern($pattern)) {
|
||||
throw new InvalidArgumentException('Invalid pattern');
|
||||
}
|
||||
```
|
||||
|
||||
3. **Use proper error handling**:
|
||||
|
||||
```php
|
||||
// Good
|
||||
try {
|
||||
$result = preg_replace($pattern, $replacement, $input);
|
||||
} catch (\Error $e) {
|
||||
// Handle error
|
||||
}
|
||||
|
||||
// Bad
|
||||
$result = @preg_replace($pattern, $replacement, $input);
|
||||
```
|
||||
|
||||
## Known Security Considerations
|
||||
|
||||
### ⚠️ Performance Considerations
|
||||
|
||||
- Complex regex patterns may cause performance issues
|
||||
- Large input strings should be validated for reasonable size
|
||||
- Consider implementing timeouts for regex operations
|
||||
|
||||
### ⚠️ Pattern Conflicts
|
||||
|
||||
- Multiple patterns may interact unexpectedly
|
||||
- Pattern order matters for security
|
||||
- Test all patterns together, not just individually
|
||||
|
||||
### ⚠️ Audit Logging
|
||||
|
||||
- Audit loggers can inadvertently log sensitive data
|
||||
- Implement audit loggers carefully
|
||||
- Consider what data is actually needed for compliance
|
||||
|
||||
## Security Measures Implemented
|
||||
|
||||
### 🔒 Code-Level Security
|
||||
|
||||
1. **Input Validation**:
|
||||
- All regex patterns validated before compilation
|
||||
- ReDoS pattern detection and prevention
|
||||
- Type safety enforcement with strict typing
|
||||
|
||||
2. **Error Handling**:
|
||||
- No error suppression operators used
|
||||
- Comprehensive exception handling
|
||||
- Secure failure modes
|
||||
|
||||
3. **Memory Safety**:
|
||||
- Proper resource cleanup
|
||||
- Prevention of memory exhaustion attacks
|
||||
- Bounded regex operations
|
||||
|
||||
### 🔒 Development Security
|
||||
|
||||
1. **Static Analysis**:
|
||||
- PHPStan at maximum level
|
||||
- Psalm static analysis
|
||||
- Security-focused linting rules
|
||||
|
||||
2. **Automated Testing**:
|
||||
- Comprehensive test suite
|
||||
- Security-specific test cases
|
||||
- Continuous integration with security checks
|
||||
|
||||
3. **Dependency Management**:
|
||||
- Regular dependency updates via Dependabot
|
||||
- Security vulnerability scanning
|
||||
- Minimal dependency footprint
|
||||
|
||||
### 🔒 Release Security
|
||||
|
||||
1. **Secure Release Process**:
|
||||
- Automated builds and testing
|
||||
- Signed releases
|
||||
- Security review before major releases
|
||||
|
||||
2. **Version Management**:
|
||||
- Semantic versioning for security transparency
|
||||
- Clear documentation of security changes
|
||||
- Migration guides for security updates
|
||||
|
||||
## Contact
|
||||
|
||||
For security-related questions or concerns:
|
||||
|
||||
- **Security Issues**: Use GitHub Security Advisories or email <security@ivuorinen.com>
|
||||
- **General Questions**: Create a GitHub Discussion
|
||||
- **Documentation**: Refer to README.md and inline code documentation
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
We appreciate responsible disclosure from security researchers and the community.
|
||||
Contributors who report valid security vulnerabilities will be acknowledged
|
||||
in release notes (unless they prefer to remain anonymous).
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-07-29
|
||||
121
TODO.md
Normal file
121
TODO.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# TODO.md - Monolog GDPR Filter
|
||||
|
||||
This file tracks remaining issues, improvements, and feature requests for the monolog-gdpr-filter library.
|
||||
|
||||
## Current Status - PRODUCTION READY
|
||||
|
||||
**Project Statistics (verified 2025-12-01):**
|
||||
|
||||
- **141 PHP files** (60 source files, 81 test files)
|
||||
- **1,346 tests** with **100% success rate** (3,386 assertions)
|
||||
- **85.07% line coverage**, **88.31% method coverage**
|
||||
- **PHP 8.4+** with modern language features and strict type safety
|
||||
- **Zero Critical Issues**: All functionality-blocking bugs resolved
|
||||
- **Static Analysis**: All tools pass cleanly (Psalm, PHPStan, Rector, PHPCS)
|
||||
|
||||
## Static Analysis Status
|
||||
|
||||
All static analysis tools now pass:
|
||||
|
||||
- **Psalm Level 5**: 0 errors
|
||||
- **PHPStan Level 6**: 0 errors
|
||||
- **Rector**: No changes needed
|
||||
- **PHPCS**: 0 errors, 0 warnings
|
||||
|
||||
## Completed Items (2025-12-01)
|
||||
|
||||
### Developer Experience
|
||||
|
||||
- [x] **Added recovery mechanism** for failed masking operations
|
||||
- `src/Recovery/FailureMode.php` - Enum for failure modes (FAIL_OPEN, FAIL_CLOSED, FAIL_SAFE)
|
||||
- `src/Recovery/RecoveryStrategy.php` - Interface for recovery strategies
|
||||
- `src/Recovery/RecoveryResult.php` - Value object for recovery outcomes
|
||||
- `src/Recovery/RetryStrategy.php` - Retry with exponential backoff
|
||||
- `src/Recovery/FallbackMaskStrategy.php` - Type-aware fallback values
|
||||
- [x] **Improved error context** in audit logging with detailed context
|
||||
- `src/Audit/ErrorContext.php` - Standardized error information with sensitive data sanitization
|
||||
- `src/Audit/AuditContext.php` - Structured context for audit entries with operation types
|
||||
- `src/Audit/StructuredAuditLogger.php` - Enhanced audit logger wrapper
|
||||
- [x] **Created interactive demo/playground** for pattern testing
|
||||
- `demo/PatternTester.php` - Pattern testing utility
|
||||
- `demo/index.php` - Web API endpoint
|
||||
- `demo/templates/playground.html` - Interactive web interface
|
||||
|
||||
### Code Quality
|
||||
|
||||
- [x] **Fixed all PHPCS Warnings** (81 warnings → 0):
|
||||
- Added missing PHPDoc documentation blocks
|
||||
- Fixed line length and spacing formatting issues
|
||||
- Full PSR-12 compliance achieved
|
||||
|
||||
### Framework Integration
|
||||
|
||||
- [x] **Created Symfony integration guide** - `docs/symfony-integration.md`
|
||||
- [x] **Added PSR-3 logger decorator pattern example** - `docs/psr3-decorator.md`
|
||||
- [x] **Created Docker development environment** - `docker/Dockerfile`, `docker/docker-compose.yml`
|
||||
- [x] **Added examples for other popular frameworks** - `docs/framework-examples.md`
|
||||
- CakePHP, CodeIgniter 4, Laminas, Yii2, PSR-15 middleware
|
||||
|
||||
### Architecture
|
||||
|
||||
- [x] **Extended Strategy Pattern support**:
|
||||
- `src/Strategies/CallbackMaskingStrategy.php` - Wraps custom callbacks as strategies
|
||||
- Factory methods: `constant()`, `hash()`, `partial()` for common use cases
|
||||
|
||||
### Advanced Features (Completed 2025-12-01)
|
||||
|
||||
- [x] **Support masking arrays/objects in message strings**
|
||||
- `src/SerializedDataProcessor.php` - Handles print_r, var_export, serialize output formats
|
||||
- [x] **Add data anonymization with k-anonymity**
|
||||
- `src/Anonymization/KAnonymizer.php` - K-anonymity implementation for GDPR compliance
|
||||
- `src/Anonymization/GeneralizationStrategy.php` - Age, date, location, numeric range strategies
|
||||
- [x] **Add retention policy support**
|
||||
- `src/Retention/RetentionPolicy.php` - Configurable retention periods with actions (delete, anonymize, archive)
|
||||
- [x] **Add data portability features (export masked logs)**
|
||||
- `src/Streaming/StreamingProcessor.php::processToFile()` - Export processed logs to files
|
||||
- [x] **Implement streaming processing for very large logs**
|
||||
- `src/Streaming/StreamingProcessor.php` - Memory-efficient chunked processing with generators
|
||||
|
||||
### Architecture Improvements (Completed 2025-12-01)
|
||||
|
||||
- [x] **Refactor to follow Single Responsibility Principle more strictly**
|
||||
- `src/MaskingOrchestrator.php` - Extracted masking coordination from GdprProcessor
|
||||
- [x] **Reduce coupling with `Adbar\Dot` library (create abstraction)**
|
||||
- `src/Contracts/ArrayAccessorInterface.php` - Abstraction interface
|
||||
- `src/ArrayAccessor/DotArrayAccessor.php` - Implementation using adbario/php-dot-notation
|
||||
- `src/ArrayAccessor/ArrayAccessorFactory.php` - Factory for creating accessors
|
||||
- [x] **Add dependency injection container support**
|
||||
- `src/Builder/GdprProcessorBuilder.php` - Fluent builder for configuration
|
||||
- [x] **Replace remaining static methods for better testability**
|
||||
- `src/Factory/AuditLoggerFactory.php` - Instance-based factory for audit loggers
|
||||
- `src/PatternValidator.php` - Instance methods added (static methods deprecated)
|
||||
- [x] **Implement plugin architecture for custom processors**
|
||||
- `src/Contracts/MaskingPluginInterface.php` - Contract for masking plugins
|
||||
- `src/Plugins/AbstractMaskingPlugin.php` - Base class with no-op defaults
|
||||
- `src/Builder/PluginAwareProcessor.php` - Wrapper with pre/post processing hooks
|
||||
|
||||
### Documentation (Completed 2025-12-01)
|
||||
|
||||
- [x] **Create performance tuning guide**
|
||||
- `docs/performance-tuning.md` - Benchmarking, pattern optimization, memory management, caching, streaming
|
||||
- [x] **Add troubleshooting guide with common issues**
|
||||
- `docs/troubleshooting.md` - Installation, pattern matching, performance, memory, integration issues
|
||||
- [x] **Add integration examples with popular logging solutions**
|
||||
- `docs/logging-integrations.md` - ELK, Graylog, Datadog, New Relic, Sentry, Papertrail, Loggly, AWS CloudWatch, Google Cloud, Fluentd
|
||||
- [x] **Create plugin development guide**
|
||||
- `docs/plugin-development.md` - Comprehensive guide for creating custom masking plugins (interface, hooks, priority, use cases)
|
||||
|
||||
## Development Notes
|
||||
|
||||
- **All critical, high, medium, and low priority functionality is complete**
|
||||
- **Project is production-ready** with comprehensive test coverage (85.07% line coverage)
|
||||
- **Static analysis tools all pass** - maintain this standard
|
||||
- **Use `composer lint:fix` for automated code quality improvements**
|
||||
- **Follow linting policy: fix issues, don't suppress unless absolutely necessary**
|
||||
- **Run demo**: `php -S localhost:8080 demo/index.php`
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-12-01
|
||||
**Production Status**: Ready
|
||||
**All Items**: Complete
|
||||
325
check_for_constants.php
Executable file
325
check_for_constants.php
Executable file
@@ -0,0 +1,325 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Check for hardcoded constant values in PHP files.
|
||||
*
|
||||
* This script scans all PHP files in the project and identifies places where
|
||||
* constant values from MaskConstants and TestConstants are hardcoded instead
|
||||
* of using the actual constant references.
|
||||
*
|
||||
* Usage: php check_for_constants.php [--verbose]
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// ANSI color codes for better readability
|
||||
const COLOR_RED = "\033[31m";
|
||||
const COLOR_GREEN = "\033[32m";
|
||||
const COLOR_YELLOW = "\033[33m";
|
||||
const COLOR_BLUE = "\033[34m";
|
||||
const COLOR_MAGENTA = "\033[35m";
|
||||
const COLOR_CYAN = "\033[36m";
|
||||
const COLOR_RESET = "\033[0m";
|
||||
const COLOR_BOLD = "\033[1m";
|
||||
|
||||
$verbose = in_array('--verbose', $argv) || in_array('-v', $argv);
|
||||
|
||||
echo "\n";
|
||||
echo sprintf("%s%s+%s+\n%s", COLOR_BOLD, COLOR_CYAN, str_repeat("=", 62), COLOR_RESET);
|
||||
echo sprintf(
|
||||
"%s%s| Constant Value Duplication Checker%s|\n%s",
|
||||
COLOR_BOLD,
|
||||
COLOR_CYAN,
|
||||
str_repeat(" ", 26),
|
||||
COLOR_RESET
|
||||
);
|
||||
echo sprintf("%s%s+%s+\n%s", COLOR_BOLD, COLOR_CYAN, str_repeat("=", 62), COLOR_RESET);
|
||||
echo "\n";
|
||||
|
||||
// Load constant files
|
||||
$maskConstantsFile = __DIR__ . '/src/MaskConstants.php';
|
||||
$testConstantsFile = __DIR__ . '/tests/TestConstants.php';
|
||||
|
||||
if (!file_exists($maskConstantsFile)) {
|
||||
echo sprintf(
|
||||
"%sError: MaskConstants file not found at: $maskConstantsFile\n%s",
|
||||
COLOR_RED,
|
||||
COLOR_RESET
|
||||
);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (!file_exists($testConstantsFile)) {
|
||||
echo sprintf(
|
||||
"%sError: TestConstants file not found at: $testConstantsFile\n%s",
|
||||
COLOR_RED,
|
||||
COLOR_RESET
|
||||
);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo COLOR_BLUE . "Loading constants from:\n" . COLOR_RESET;
|
||||
echo " - src/MaskConstants.php\n";
|
||||
echo " - tests/TestConstants.php\n\n";
|
||||
|
||||
// Load composer autoloader to enable namespace imports
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants;
|
||||
use Tests\TestConstants;
|
||||
|
||||
try {
|
||||
$maskReflection = new ReflectionClass(MaskConstants::class);
|
||||
$maskConstants = $maskReflection->getConstants();
|
||||
|
||||
$testReflection = new ReflectionClass(TestConstants::class);
|
||||
$testConstants = $testReflection->getConstants();
|
||||
} catch (ReflectionException $e) {
|
||||
echo sprintf("%sError loading constants: %s\n%s", COLOR_RED, $e->getMessage(), COLOR_RESET);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo sprintf(
|
||||
"%s✓ Loaded %s constants from MaskConstants\n%s",
|
||||
COLOR_GREEN,
|
||||
count($maskConstants),
|
||||
COLOR_RESET
|
||||
);
|
||||
echo sprintf(
|
||||
"%s✓ Loaded %s constants from TestConstants\n%s",
|
||||
COLOR_GREEN,
|
||||
count($testConstants),
|
||||
COLOR_RESET
|
||||
);
|
||||
echo sprintf(
|
||||
"%sℹ Note: TestConstants only checked in tests/ directory\n\n%s",
|
||||
COLOR_BLUE,
|
||||
COLOR_RESET
|
||||
);
|
||||
|
||||
// Combine all constants for searching
|
||||
$allConstants = [
|
||||
'MaskConstants' => $maskConstants,
|
||||
'TestConstants' => $testConstants,
|
||||
];
|
||||
|
||||
// Find all PHP files to scan
|
||||
$phpFiles = [];
|
||||
$directories = [
|
||||
__DIR__ . '/src',
|
||||
__DIR__ . '/tests',
|
||||
];
|
||||
|
||||
foreach ($directories as $dir) {
|
||||
if (!is_dir($dir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isFile() && $file->getExtension() === 'php') {
|
||||
// Skip the constant definition files themselves
|
||||
$realPath = $file->getRealPath();
|
||||
if ($realPath === $maskConstantsFile || $realPath === $testConstantsFile) {
|
||||
continue;
|
||||
}
|
||||
$phpFiles[] = $file->getRealPath();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo COLOR_BLUE . "Scanning " . count($phpFiles)
|
||||
. " PHP files for hardcoded constant values...\n\n" . COLOR_RESET;
|
||||
|
||||
// Track findings
|
||||
$findings = [];
|
||||
$filesChecked = 0;
|
||||
$totalMatches = 0;
|
||||
|
||||
// Scan each file for hardcoded constant values
|
||||
foreach ($phpFiles as $filePath) {
|
||||
$filesChecked++;
|
||||
$content = file_get_contents($filePath);
|
||||
$lines = explode("\n", $content);
|
||||
|
||||
// Determine if this is a test file
|
||||
$isTestFile = str_contains($filePath, '/tests/');
|
||||
|
||||
foreach ($allConstants as $className => $constants) {
|
||||
// Skip TestConstants for non-test files (src/ directory)
|
||||
if ($className === 'TestConstants' && !$isTestFile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($constants as $constantName => $constantValue) {
|
||||
// Skip non-string constants and empty values
|
||||
if (!is_string($constantValue) || strlen($constantValue) === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip very generic values that would produce too many false positives
|
||||
$skipGeneric = [
|
||||
'test',
|
||||
'value',
|
||||
'field',
|
||||
'path',
|
||||
'key',
|
||||
'data',
|
||||
'name',
|
||||
'id',
|
||||
'type',
|
||||
'error'
|
||||
];
|
||||
if (
|
||||
in_array(strtolower($constantValue), $skipGeneric)
|
||||
&& strlen($constantValue) < 10
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Additional filtering for src/ files - skip common internal identifiers
|
||||
if (!$isTestFile) {
|
||||
// In src/ files, skip values commonly used as array keys or internal identifiers
|
||||
$srcSkipValues = [
|
||||
'masked',
|
||||
'original',
|
||||
'remove',
|
||||
'message',
|
||||
'password',
|
||||
'email',
|
||||
'user_id',
|
||||
'sensitive_data',
|
||||
'audit',
|
||||
'security',
|
||||
'application',
|
||||
'Cannot be null or empty for REPLACE type',
|
||||
'Rate limiting key cannot be empty',
|
||||
'Test message'
|
||||
];
|
||||
if (in_array($constantValue, $srcSkipValues)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Create search patterns for both single and double-quoted strings
|
||||
$patterns = [
|
||||
"'" . str_replace("'", "\\'", $constantValue) . "'",
|
||||
'"' . str_replace('"', '\\"', $constantValue) . '"',
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
$lineNumber = 0;
|
||||
foreach ($lines as $line) {
|
||||
$lineNumber++;
|
||||
|
||||
// Skip lines that already use the constant
|
||||
if (str_contains($line, $className . '::' . $constantName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip lines that are comments
|
||||
$trimmedLine = trim($line);
|
||||
if (
|
||||
str_starts_with($trimmedLine, '//')
|
||||
|| str_starts_with($trimmedLine, '*')
|
||||
|| str_starts_with($trimmedLine, '/*')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_contains($line, $pattern)) {
|
||||
$relativePath = str_replace(__DIR__ . '/', '', $filePath);
|
||||
|
||||
if (!isset($findings[$relativePath])) {
|
||||
$findings[$relativePath] = [];
|
||||
}
|
||||
|
||||
$findings[$relativePath][] = [
|
||||
'line' => $lineNumber,
|
||||
'constant' => $className . '::' . $constantName,
|
||||
'value' => $constantValue,
|
||||
'content' => trim($line),
|
||||
];
|
||||
|
||||
$totalMatches++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo COLOR_BOLD . str_repeat("=", 64) . "\n" . COLOR_RESET;
|
||||
echo COLOR_BOLD . "Scan Results\n" . COLOR_RESET;
|
||||
echo COLOR_BOLD . str_repeat("=", 64) . "\n\n" . COLOR_RESET;
|
||||
|
||||
if (empty($findings)) {
|
||||
echo COLOR_GREEN . COLOR_BOLD . "✓ No hardcoded constant values found!\n\n" . COLOR_RESET;
|
||||
echo COLOR_GREEN . "All files are using proper constant references. "
|
||||
. "Great job! 🎉\n\n" . COLOR_RESET;
|
||||
exit(0);
|
||||
}
|
||||
|
||||
echo sprintf(
|
||||
"%s%s⚠ Found %d potential hardcoded constant value(s) in %s file(s)\n\n%s",
|
||||
COLOR_YELLOW,
|
||||
COLOR_BOLD,
|
||||
$totalMatches,
|
||||
count($findings),
|
||||
COLOR_RESET
|
||||
);
|
||||
|
||||
// Display findings grouped by file
|
||||
foreach ($findings as $file => $matches) {
|
||||
echo sprintf(
|
||||
"%s%s📄 %s%s (%s match%s)\n",
|
||||
COLOR_BOLD,
|
||||
COLOR_MAGENTA,
|
||||
$file,
|
||||
COLOR_RESET,
|
||||
count($matches),
|
||||
count($matches) > 1 ? "es" : ""
|
||||
);
|
||||
echo COLOR_BOLD . str_repeat("─", 64) . "\n" . COLOR_RESET;
|
||||
|
||||
foreach ($matches as $match) {
|
||||
echo sprintf("%s Line %s: %s", COLOR_CYAN, $match['line'], COLOR_RESET);
|
||||
echo sprintf("Use %s%s%s", COLOR_YELLOW, $match['constant'], COLOR_RESET);
|
||||
echo sprintf(" instead of %s'%s'%s\n", COLOR_RED, addslashes($match['value']), COLOR_RESET);
|
||||
|
||||
if ($verbose) {
|
||||
echo sprintf(
|
||||
"%s Context: %s%s",
|
||||
COLOR_BLUE,
|
||||
COLOR_RESET,
|
||||
substr($match['content'], 0, 100)
|
||||
);
|
||||
if (strlen($match['content']) > 100) {
|
||||
echo "...";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
echo COLOR_BOLD . str_repeat("=", 64) . "\n\n" . COLOR_RESET;
|
||||
|
||||
echo COLOR_YELLOW . "Summary:\n" . COLOR_RESET;
|
||||
echo sprintf(" • Files checked: %d\n", $filesChecked);
|
||||
echo sprintf(" • Files with issues: %s\n", count($findings));
|
||||
echo sprintf(" • Total matches: %d\n\n", $totalMatches);
|
||||
|
||||
echo sprintf(
|
||||
"%sTip: Use --verbose flag to see line context for each match\n%s",
|
||||
COLOR_BLUE,
|
||||
COLOR_RESET
|
||||
);
|
||||
echo sprintf("%sExample: php check_for_constants.php --verbose\n\n%s", COLOR_BLUE, COLOR_RESET);
|
||||
|
||||
exit(1);
|
||||
@@ -7,42 +7,59 @@
|
||||
"type": "library",
|
||||
"scripts": {
|
||||
"lint": [
|
||||
"@lint:tool:ec",
|
||||
"@lint:tool:psalm",
|
||||
"@lint:tool:phpcs"
|
||||
"@lint:tool:phpstan",
|
||||
"@lint:tool:phpcs",
|
||||
"@lint:tool:md"
|
||||
],
|
||||
"lint:fix": [
|
||||
"@lint:tool:rector",
|
||||
"@lint:tool:psalm:fix",
|
||||
"@lint:tool:phpcbf"
|
||||
"@lint:tool:phpcbf",
|
||||
"@lint:tool:md:fix",
|
||||
"@lint:tool:ec:fix"
|
||||
],
|
||||
"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",
|
||||
"test": "./vendor/bin/phpunit --coverage-text",
|
||||
"test:coverage": "./vendor/bin/phpunit --coverage-text --coverage-html=coverage",
|
||||
"test:ci": "./vendor/bin/phpunit --teamcity --coverage-clover=coverage.xml",
|
||||
"lint:tool:ec": "./vendor/bin/ec *.md *.json *.yml *.yaml *.xml *.php",
|
||||
"lint:tool:ec:fix": "./vendor/bin/ec *.md *.json *.yml *.yaml *.xml *.php --fix",
|
||||
"lint:tool:phpcs": "./vendor/bin/phpcs src/ tests/ examples/ config/ rector.php --warning-severity=0",
|
||||
"lint:tool:phpcbf": "./vendor/bin/phpcbf src/ tests/ examples/ config/ rector.php || [ $? -eq 2 ]",
|
||||
"lint:tool:phpstan": "./vendor/bin/phpstan analyse --memory-limit=1G",
|
||||
"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"
|
||||
"lint:tool:psalm:fix": "./vendor/bin/psalm --alter --issues=MissingReturnType,MissingParamType,MissingClosureReturnType",
|
||||
"lint:tool:rector": "./vendor/bin/rector",
|
||||
"lint:tool:md:fix": "npx -y markdownlint-cli -f '**/*.md'",
|
||||
"lint:tool:md": "npx -y markdownlint-cli '**/*.md'"
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"php": "^8.4",
|
||||
"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",
|
||||
"armin/editorconfig-cli": "^2.1",
|
||||
"ergebnis/composer-normalize": "^2.47",
|
||||
"guuzen/psalm-enum-plugin": "^1.1",
|
||||
"ergebnis/composer-normalize": "^2.47"
|
||||
"illuminate/console": "*",
|
||||
"illuminate/contracts": "*",
|
||||
"illuminate/http": "*",
|
||||
"orklah/psalm-strict-equality": "^3.1",
|
||||
"phpunit/phpunit": "^13",
|
||||
"psalm/plugin-phpunit": "^0.19.5",
|
||||
"rector/rector": "^2.1",
|
||||
"squizlabs/php_codesniffer": "^4.0",
|
||||
"vimeo/psalm": "^6.13"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Ivuorinen\\MonologGdprFilter\\": "src/"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"stubs/laravel-helpers.php"
|
||||
]
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
|
||||
4995
composer.lock
generated
4995
composer.lock
generated
File diff suppressed because it is too large
Load Diff
128
config/gdpr.php
Normal file
128
config/gdpr.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Auto Registration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Whether to automatically register the GDPR processor with Laravel's
|
||||
| logging system. If false, you'll need to manually register it.
|
||||
|
|
||||
*/
|
||||
'auto_register' => filter_var(env('GDPR_AUTO_REGISTER', false), FILTER_VALIDATE_BOOLEAN),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Logging Channels
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Which logging channels should have GDPR processing applied.
|
||||
| Only used when auto_register is true.
|
||||
|
|
||||
*/
|
||||
'channels' => [
|
||||
'single',
|
||||
'daily',
|
||||
'stack',
|
||||
// Add other channels as needed
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| GDPR Patterns
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Regex patterns for detecting and masking sensitive data.
|
||||
| Leave empty to use the default patterns, or add your own.
|
||||
|
|
||||
*/
|
||||
'patterns' => [
|
||||
// Uncomment and customize as needed:
|
||||
// '/\bcustom-pattern\b/' => '***CUSTOM***',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Field Paths
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Dot-notation paths for field-specific masking/removal/replacement.
|
||||
| More efficient than regex patterns for known field locations.
|
||||
|
|
||||
*/
|
||||
'field_paths' => [
|
||||
// Examples:
|
||||
// 'user.email' => '', // Mask with regex
|
||||
// 'user.ssn' => GdprProcessor::removeField(),
|
||||
// 'payment.card' => GdprProcessor::replaceWith('[CARD]'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Custom Callbacks
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Custom masking functions for specific field paths.
|
||||
| Most flexible but slowest option.
|
||||
|
|
||||
*/
|
||||
'custom_callbacks' => [
|
||||
// Examples:
|
||||
// 'user.name' => fn($value) => strtoupper($value),
|
||||
// 'metadata.ip' => fn($value) => hash('sha256', $value),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Recursion Depth Limit
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Maximum depth for recursive processing of nested arrays.
|
||||
| Prevents stack overflow on deeply nested data structures.
|
||||
|
|
||||
*/
|
||||
'max_depth' => max(1, min(1000, (int) env('GDPR_MAX_DEPTH', 100))),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Logging
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for audit logging of GDPR processing actions.
|
||||
| Useful for compliance tracking and debugging.
|
||||
|
|
||||
*/
|
||||
'audit_logging' => [
|
||||
'enabled' => filter_var(env('GDPR_AUDIT_ENABLED', false), FILTER_VALIDATE_BOOLEAN),
|
||||
'channel' => trim((string) env('GDPR_AUDIT_CHANNEL', 'gdpr-audit')) ?: 'gdpr-audit',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Performance Settings
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Settings for optimizing performance with large datasets.
|
||||
|
|
||||
*/
|
||||
'performance' => [
|
||||
'chunk_size' => max(100, min(10000, (int) env('GDPR_CHUNK_SIZE', 1000))),
|
||||
'garbage_collection_threshold' => max(1000, min(100000, (int) env('GDPR_GC_THRESHOLD', 10000))),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Input Validation Settings
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Settings for input validation and security.
|
||||
|
|
||||
*/
|
||||
'validation' => [
|
||||
'max_pattern_length' => max(10, min(1000, (int) env('GDPR_MAX_PATTERN_LENGTH', 500))),
|
||||
'max_field_path_length' => max(5, min(500, (int) env('GDPR_MAX_FIELD_PATH_LENGTH', 100))),
|
||||
'allow_empty_patterns' => filter_var(env('GDPR_ALLOW_EMPTY_PATTERNS', false), FILTER_VALIDATE_BOOLEAN),
|
||||
'strict_regex_validation' => filter_var(env('GDPR_STRICT_REGEX_VALIDATION', true), FILTER_VALIDATE_BOOLEAN),
|
||||
],
|
||||
];
|
||||
293
demo/PatternTester.php
Normal file
293
demo/PatternTester.php
Normal file
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Demo;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\Strategies\RegexMaskingStrategy;
|
||||
use Ivuorinen\MonologGdprFilter\Strategies\FieldPathMaskingStrategy;
|
||||
use Ivuorinen\MonologGdprFilter\Strategies\StrategyManager;
|
||||
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
|
||||
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
|
||||
use Monolog\Level;
|
||||
use Monolog\LogRecord;
|
||||
use DateTimeImmutable;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Pattern testing utility for the demo playground.
|
||||
*/
|
||||
final class PatternTester
|
||||
{
|
||||
/** @var array<array{path: string, original: mixed, masked: mixed}> */
|
||||
private array $auditLog = [];
|
||||
|
||||
/**
|
||||
* Test regex patterns against sample text.
|
||||
*
|
||||
* @param string $text Sample text to test
|
||||
* @param array<string, string> $patterns Regex patterns to apply
|
||||
* @return array{masked: string, matches: array<string, array<string>>, errors: array<string>}
|
||||
*/
|
||||
public function testPatterns(string $text, array $patterns): array
|
||||
{
|
||||
$errors = [];
|
||||
$matches = [];
|
||||
$masked = $text;
|
||||
|
||||
foreach ($patterns as $pattern => $replacement) {
|
||||
// Validate pattern
|
||||
if (@preg_match($pattern, '') === false) {
|
||||
$errors[] = "Invalid pattern: {$pattern}";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find matches
|
||||
if (preg_match_all($pattern, $text, $found)) {
|
||||
$matches[$pattern] = $found[0];
|
||||
}
|
||||
|
||||
// Apply replacement
|
||||
$result = @preg_replace($pattern, $replacement, $masked);
|
||||
if ($result !== null) {
|
||||
$masked = $result;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'masked' => $masked,
|
||||
'matches' => $matches,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with the full GdprProcessor.
|
||||
*
|
||||
* @param string $message Log message to test
|
||||
* @param array<string, mixed> $context Log context to test
|
||||
* @param array<string, string> $patterns Custom patterns (or empty for defaults)
|
||||
* @param array<string, string|FieldMaskConfig> $fieldPaths Field path configurations
|
||||
* @return array{
|
||||
* original_message: string,
|
||||
* masked_message: string,
|
||||
* original_context: array<string, mixed>,
|
||||
* masked_context: array<string, mixed>,
|
||||
* audit_log: array<array{path: string, original: mixed, masked: mixed}>,
|
||||
* errors: array<string>
|
||||
* }
|
||||
*/
|
||||
public function testProcessor(
|
||||
string $message,
|
||||
array $context = [],
|
||||
array $patterns = [],
|
||||
array $fieldPaths = []
|
||||
): array {
|
||||
$this->auditLog = [];
|
||||
$errors = [];
|
||||
|
||||
try {
|
||||
// Use default patterns if none provided
|
||||
if (empty($patterns)) {
|
||||
$patterns = DefaultPatterns::get();
|
||||
}
|
||||
|
||||
// Create audit logger
|
||||
$auditLogger = function (string $path, mixed $original, mixed $masked): void {
|
||||
$this->auditLog[] = [
|
||||
'path' => $path,
|
||||
'original' => $original,
|
||||
'masked' => $masked,
|
||||
];
|
||||
};
|
||||
|
||||
// Convert field paths to FieldMaskConfig
|
||||
$configuredPaths = $this->convertFieldPathsToConfig($fieldPaths);
|
||||
|
||||
// Create processor
|
||||
$processor = new GdprProcessor(
|
||||
patterns: $patterns,
|
||||
fieldPaths: $configuredPaths,
|
||||
auditLogger: $auditLogger
|
||||
);
|
||||
|
||||
// Create log record
|
||||
$record = new LogRecord(
|
||||
datetime: new DateTimeImmutable(),
|
||||
channel: 'demo',
|
||||
level: Level::Info,
|
||||
message: $message,
|
||||
context: $context
|
||||
);
|
||||
|
||||
// Process
|
||||
$result = $processor($record);
|
||||
|
||||
return [
|
||||
'original_message' => $message,
|
||||
'masked_message' => $result->message,
|
||||
'original_context' => $context,
|
||||
'masked_context' => $result->context,
|
||||
'audit_log' => $this->auditLog,
|
||||
'errors' => $errors,
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
$errors[] = $e->getMessage();
|
||||
|
||||
return [
|
||||
'original_message' => $message,
|
||||
'masked_message' => $message,
|
||||
'original_context' => $context,
|
||||
'masked_context' => $context,
|
||||
'audit_log' => [],
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with the Strategy pattern.
|
||||
*
|
||||
* @param string $message Log message
|
||||
* @param array<string, mixed> $context Log context
|
||||
* @param array<string, string> $patterns Regex patterns
|
||||
* @param array<string> $includePaths Paths to include
|
||||
* @param array<string> $excludePaths Paths to exclude
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function testStrategies(
|
||||
string $message,
|
||||
array $context = [],
|
||||
array $patterns = [],
|
||||
array $includePaths = [],
|
||||
array $excludePaths = []
|
||||
): array {
|
||||
$errors = [];
|
||||
|
||||
try {
|
||||
if (empty($patterns)) {
|
||||
$patterns = DefaultPatterns::get();
|
||||
}
|
||||
|
||||
// Create strategies
|
||||
$regexStrategy = new RegexMaskingStrategy(
|
||||
patterns: $patterns,
|
||||
includePaths: $includePaths,
|
||||
excludePaths: $excludePaths
|
||||
);
|
||||
|
||||
// Create strategy manager
|
||||
$manager = new StrategyManager([$regexStrategy]);
|
||||
|
||||
// Create log record
|
||||
$record = new LogRecord(
|
||||
datetime: new DateTimeImmutable(),
|
||||
channel: 'demo',
|
||||
level: Level::Info,
|
||||
message: $message,
|
||||
context: $context
|
||||
);
|
||||
|
||||
// Mask message
|
||||
$maskedMessage = $manager->maskValue($message, 'message', $record);
|
||||
|
||||
// Mask context recursively
|
||||
$maskedContext = $this->maskContextWithStrategies($context, $manager, $record);
|
||||
|
||||
return [
|
||||
'original_message' => $message,
|
||||
'masked_message' => $maskedMessage,
|
||||
'original_context' => $context,
|
||||
'masked_context' => $maskedContext,
|
||||
'strategy_stats' => $manager->getStatistics(),
|
||||
'errors' => $errors,
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
$errors[] = $e->getMessage();
|
||||
|
||||
return [
|
||||
'original_message' => $message,
|
||||
'masked_message' => $message,
|
||||
'original_context' => $context,
|
||||
'masked_context' => $context,
|
||||
'strategy_stats' => [],
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default patterns for display.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getDefaultPatterns(): array
|
||||
{
|
||||
return DefaultPatterns::get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single regex pattern.
|
||||
*
|
||||
* @return array{valid: bool, error: string|null}
|
||||
*/
|
||||
public function validatePattern(string $pattern): array
|
||||
{
|
||||
if ($pattern === '') {
|
||||
return ['valid' => false, 'error' => 'Pattern cannot be empty'];
|
||||
}
|
||||
|
||||
if (@preg_match($pattern, '') === false) {
|
||||
$error = preg_last_error_msg();
|
||||
return ['valid' => false, 'error' => $error];
|
||||
}
|
||||
|
||||
return ['valid' => true, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert field paths to configuration array.
|
||||
*
|
||||
* @param array<string, string|FieldMaskConfig> $fieldPaths
|
||||
* @return array<string, string|FieldMaskConfig>
|
||||
*/
|
||||
private function convertFieldPathsToConfig(array $fieldPaths): array
|
||||
{
|
||||
$configuredPaths = [];
|
||||
foreach ($fieldPaths as $path => $config) {
|
||||
// Accept both FieldMaskConfig instances and strings
|
||||
$configuredPaths[$path] = $config;
|
||||
}
|
||||
return $configuredPaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively mask context values using strategy manager.
|
||||
*
|
||||
* @param array<string, mixed> $context
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function maskContextWithStrategies(
|
||||
array $context,
|
||||
StrategyManager $manager,
|
||||
LogRecord $record,
|
||||
string $prefix = ''
|
||||
): array {
|
||||
$result = [];
|
||||
|
||||
foreach ($context as $key => $value) {
|
||||
$path = $prefix === '' ? $key : $prefix . '.' . $key;
|
||||
|
||||
if (is_array($value)) {
|
||||
$result[$key] = $this->maskContextWithStrategies($value, $manager, $record, $path);
|
||||
} elseif (is_string($value)) {
|
||||
$result[$key] = $manager->maskValue($value, $path, $record);
|
||||
} else {
|
||||
$result[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
270
demo/index.php
Normal file
270
demo/index.php
Normal file
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* GDPR Pattern Tester - Interactive Demo
|
||||
*
|
||||
* This is a simple web interface for testing GDPR masking patterns.
|
||||
* Run with: php -S localhost:8080 demo/index.php
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Demo\PatternTester;
|
||||
|
||||
// Auto-load the PatternTester class
|
||||
spl_autoload_register(function (string $class): void {
|
||||
if (str_starts_with($class, 'Ivuorinen\\MonologGdprFilter\\Demo\\')) {
|
||||
$file = __DIR__ . '/' . substr($class, strlen('Ivuorinen\\MonologGdprFilter\\Demo\\')) . '.php';
|
||||
if (file_exists($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$tester = new PatternTester();
|
||||
|
||||
// Handle API requests
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_SERVER['CONTENT_TYPE']) && str_contains($_SERVER['CONTENT_TYPE'], 'application/json')) {
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!is_array($input)) {
|
||||
echo json_encode(['error' => 'Invalid JSON input']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$action = $input['action'] ?? 'test';
|
||||
|
||||
$result = match ($action) {
|
||||
'test_patterns' => $tester->testPatterns(
|
||||
$input['text'] ?? '',
|
||||
$input['patterns'] ?? []
|
||||
),
|
||||
'test_processor' => $tester->testProcessor(
|
||||
$input['message'] ?? '',
|
||||
$input['context'] ?? [],
|
||||
$input['patterns'] ?? [],
|
||||
$input['field_paths'] ?? []
|
||||
),
|
||||
'test_strategies' => $tester->testStrategies(
|
||||
$input['message'] ?? '',
|
||||
$input['context'] ?? [],
|
||||
$input['patterns'] ?? []
|
||||
),
|
||||
'validate_pattern' => $tester->validatePattern($input['pattern'] ?? ''),
|
||||
'get_defaults' => ['patterns' => $tester->getDefaultPatterns()],
|
||||
default => ['error' => 'Unknown action'],
|
||||
};
|
||||
|
||||
echo json_encode($result, JSON_PRETTY_PRINT);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Serve the HTML template
|
||||
$templatePath = __DIR__ . '/templates/playground.html';
|
||||
if (file_exists($templatePath)) {
|
||||
readfile($templatePath);
|
||||
} else {
|
||||
// Fallback inline template
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GDPR Pattern Tester</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
h1 { color: #333; }
|
||||
.container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
.panel {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.full-width { grid-column: 1 / -1; }
|
||||
label { display: block; margin-bottom: 5px; font-weight: 600; }
|
||||
textarea, input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
textarea { min-height: 150px; resize: vertical; }
|
||||
button {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
button:hover { background: #0056b3; }
|
||||
.result {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin-top: 10px;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.error { background: #f8d7da; color: #721c24; }
|
||||
.success { background: #d4edda; color: #155724; }
|
||||
.match { background: #fff3cd; padding: 2px 4px; border-radius: 2px; }
|
||||
.patterns-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
.pattern-item {
|
||||
padding: 5px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.pattern-item code {
|
||||
background: #e9ecef;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>GDPR Pattern Tester</h1>
|
||||
<p>Test regex patterns for masking sensitive data in log messages.</p>
|
||||
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
<h2>Sample Text</h2>
|
||||
<label for="sampleText">Enter text containing sensitive data:</label>
|
||||
<textarea id="sampleText">User john.doe@example.com logged in from 192.168.1.100.
|
||||
Credit card: 4532-1234-5678-9012
|
||||
SSN: 123-45-6789
|
||||
Phone: +1 (555) 123-4567</textarea>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Custom Patterns</h2>
|
||||
<label for="patterns">JSON patterns (pattern => replacement):</label>
|
||||
<textarea id="patterns">{
|
||||
"/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}/": "[EMAIL]",
|
||||
"/\\b\\d{3}-\\d{2}-\\d{4}\\b/": "***-**-****",
|
||||
"/\\b\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}\\b/": "****-****-****-****"
|
||||
}</textarea>
|
||||
<button onclick="loadDefaults()">Load Default Patterns</button>
|
||||
</div>
|
||||
|
||||
<div class="panel full-width">
|
||||
<button onclick="testPatterns()">Test Patterns</button>
|
||||
<button onclick="testProcessor()">Test Full Processor</button>
|
||||
</div>
|
||||
|
||||
<div class="panel full-width">
|
||||
<h2>Results</h2>
|
||||
<div id="results" class="result">Results will appear here...</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Default Patterns</h2>
|
||||
<div id="defaultPatterns" class="patterns-list">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Audit Log</h2>
|
||||
<div id="auditLog" class="result">Audit log will appear here...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function api(action, data = {}) {
|
||||
const response = await fetch(window.location.href, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, ...data })
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function testPatterns() {
|
||||
const text = document.getElementById('sampleText').value;
|
||||
let patterns;
|
||||
try {
|
||||
patterns = JSON.parse(document.getElementById('patterns').value);
|
||||
} catch (e) {
|
||||
showResult({ error: 'Invalid JSON in patterns: ' + e.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api('test_patterns', { text, patterns });
|
||||
showResult(result);
|
||||
}
|
||||
|
||||
async function testProcessor() {
|
||||
const message = document.getElementById('sampleText').value;
|
||||
let patterns;
|
||||
try {
|
||||
patterns = JSON.parse(document.getElementById('patterns').value);
|
||||
} catch (e) {
|
||||
patterns = {};
|
||||
}
|
||||
|
||||
const result = await api('test_processor', { message, patterns });
|
||||
showResult(result);
|
||||
if (result.audit_log) {
|
||||
document.getElementById('auditLog').textContent =
|
||||
JSON.stringify(result.audit_log, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDefaults() {
|
||||
const result = await api('get_defaults');
|
||||
if (result.patterns) {
|
||||
document.getElementById('patterns').value =
|
||||
JSON.stringify(result.patterns, null, 4);
|
||||
}
|
||||
}
|
||||
|
||||
function showResult(result) {
|
||||
const el = document.getElementById('results');
|
||||
if (result.error || (result.errors && result.errors.length)) {
|
||||
el.className = 'result error';
|
||||
} else {
|
||||
el.className = 'result success';
|
||||
}
|
||||
el.textContent = JSON.stringify(result, null, 2);
|
||||
}
|
||||
|
||||
// Load defaults on page load
|
||||
(async function() {
|
||||
const result = await api('get_defaults');
|
||||
if (result.patterns) {
|
||||
const container = document.getElementById('defaultPatterns');
|
||||
container.innerHTML = Object.entries(result.patterns)
|
||||
.map(([pattern, replacement]) =>
|
||||
`<div class="pattern-item"><code>${pattern}</code> → <code>${replacement}</code></div>`
|
||||
).join('');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
}
|
||||
478
demo/templates/playground.html
Normal file
478
demo/templates/playground.html
Normal file
@@ -0,0 +1,478 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GDPR Pattern Tester - Monolog GDPR Filter</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
header {
|
||||
text-align: center;
|
||||
color: white;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
header p {
|
||||
opacity: 0.9;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
padding: 25px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.card h2 {
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.card h2::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 24px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 2px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
textarea, input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e1e5eb;
|
||||
border-radius: 8px;
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
textarea:focus, input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
textarea {
|
||||
min-height: 180px;
|
||||
resize: vertical;
|
||||
}
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 15px;
|
||||
}
|
||||
button {
|
||||
background: linear-gradient(135deg, #4c51bf 0%, #553c9a 100%);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(76, 81, 191, 0.4);
|
||||
}
|
||||
button.secondary {
|
||||
background: #f8f9fa;
|
||||
color: #333;
|
||||
}
|
||||
button.secondary:hover {
|
||||
background: #e9ecef;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.result-box {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-top: 15px;
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.result-box.error {
|
||||
background: #fff5f5;
|
||||
border-left: 4px solid #e53e3e;
|
||||
}
|
||||
.result-box.success {
|
||||
background: #f0fff4;
|
||||
border-left: 4px solid #38a169;
|
||||
}
|
||||
.patterns-list {
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.pattern-item {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.pattern-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.pattern-item code {
|
||||
background: #e9ecef;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
.pattern-item .arrow {
|
||||
color: #553c9a;
|
||||
margin: 0 8px;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.tab {
|
||||
padding: 8px 16px;
|
||||
background: #f0f0f0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.tab.active {
|
||||
background: linear-gradient(135deg, #4c51bf 0%, #553c9a 100%);
|
||||
color: #ffffff;
|
||||
}
|
||||
.highlight {
|
||||
background: #fff3cd;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.stat-box {
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.stat-box .value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
.stat-box .label {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-top: 5px;
|
||||
}
|
||||
footer {
|
||||
text-align: center;
|
||||
color: white;
|
||||
margin-top: 30px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
footer a {
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>GDPR Pattern Tester</h1>
|
||||
<p>Test and validate regex patterns for masking sensitive data in log messages</p>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2>Sample Input</h2>
|
||||
<label for="sampleText">Enter text containing sensitive data:</label>
|
||||
<textarea id="sampleText">User john.doe@example.com logged in from 192.168.1.100.
|
||||
Credit card: 4532-1234-5678-9012
|
||||
SSN: 123-45-6789
|
||||
Phone: +1 (555) 123-4567
|
||||
Finnish SSN: 131052-308T
|
||||
IBAN: FI21 1234 5600 0007 85</textarea>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Custom Patterns</h2>
|
||||
<label for="patterns">JSON patterns (pattern => replacement):</label>
|
||||
<textarea id="patterns">{
|
||||
"/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}/": "[EMAIL]",
|
||||
"/\\b\\d{3}-\\d{2}-\\d{4}\\b/": "***-**-****",
|
||||
"/\\b\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}\\b/": "****-****-****-****",
|
||||
"/\\b\\d{6}[-+A]\\d{3}[A-Z0-9]\\b/": "******-****"
|
||||
}</textarea>
|
||||
<div class="btn-group">
|
||||
<button class="secondary" onclick="loadDefaults()">Load Defaults</button>
|
||||
<button class="secondary" onclick="clearPatterns()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="grid-column: 1 / -1;">
|
||||
<h2>Actions</h2>
|
||||
<div class="btn-group">
|
||||
<button onclick="testPatterns()">Test Patterns</button>
|
||||
<button onclick="testProcessor()">Test Full Processor</button>
|
||||
<button onclick="testStrategies()">Test with Strategies</button>
|
||||
<button class="secondary" onclick="validatePatterns()">Validate Patterns</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Masked Output</h2>
|
||||
<div id="maskedOutput" class="result-box">Masked output will appear here...</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Pattern Matches</h2>
|
||||
<div id="matchesOutput" class="result-box">Matches will appear here...</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Default Patterns</h2>
|
||||
<div id="defaultPatterns" class="patterns-list">Loading default patterns...</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Audit Log</h2>
|
||||
<div id="auditLog" class="result-box">Audit log entries will appear here...</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="grid-column: 1 / -1;">
|
||||
<h2>Full Results</h2>
|
||||
<div id="fullResults" class="result-box">Complete results will appear here...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>
|
||||
<a href="https://github.com/ivuorinen/monolog-gdpr-filter" target="_blank">
|
||||
ivuorinen/monolog-gdpr-filter
|
||||
</a>
|
||||
— Run with: <code>php -S localhost:8080 demo/index.php</code>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function api(action, data = {}) {
|
||||
try {
|
||||
const response = await fetch(globalThis.location.href, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, ...data })
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
function getPatterns() {
|
||||
try {
|
||||
return JSON.parse(document.getElementById('patterns').value);
|
||||
} catch (error) {
|
||||
showError('Invalid JSON in patterns field: ' + error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function testPatterns() {
|
||||
const text = document.getElementById('sampleText').value;
|
||||
const patterns = getPatterns();
|
||||
|
||||
if (!patterns) {
|
||||
showError('Invalid JSON in patterns field');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api('test_patterns', { text, patterns });
|
||||
|
||||
if (result.error) {
|
||||
showError(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('maskedOutput').textContent = result.masked || '';
|
||||
document.getElementById('maskedOutput').className = 'result-box success';
|
||||
|
||||
if (result.matches && Object.keys(result.matches).length > 0) {
|
||||
document.getElementById('matchesOutput').textContent =
|
||||
JSON.stringify(result.matches, null, 2);
|
||||
} else {
|
||||
document.getElementById('matchesOutput').textContent = 'No matches found';
|
||||
}
|
||||
|
||||
document.getElementById('fullResults').textContent =
|
||||
JSON.stringify(result, null, 2);
|
||||
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
document.getElementById('fullResults').className = 'result-box error';
|
||||
} else {
|
||||
document.getElementById('fullResults').className = 'result-box success';
|
||||
}
|
||||
}
|
||||
|
||||
async function testProcessor() {
|
||||
const message = document.getElementById('sampleText').value;
|
||||
const patterns = getPatterns() || {};
|
||||
|
||||
const result = await api('test_processor', { message, patterns });
|
||||
|
||||
if (result.error) {
|
||||
showError(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('maskedOutput').textContent =
|
||||
result.masked_message || '';
|
||||
document.getElementById('maskedOutput').className = 'result-box success';
|
||||
|
||||
if (result.audit_log && result.audit_log.length > 0) {
|
||||
document.getElementById('auditLog').textContent =
|
||||
JSON.stringify(result.audit_log, null, 2);
|
||||
} else {
|
||||
document.getElementById('auditLog').textContent = 'No audit entries';
|
||||
}
|
||||
|
||||
document.getElementById('fullResults').textContent =
|
||||
JSON.stringify(result, null, 2);
|
||||
document.getElementById('fullResults').className = 'result-box success';
|
||||
}
|
||||
|
||||
async function testStrategies() {
|
||||
const message = document.getElementById('sampleText').value;
|
||||
const patterns = getPatterns() || {};
|
||||
|
||||
const result = await api('test_strategies', { message, patterns });
|
||||
|
||||
document.getElementById('maskedOutput').textContent =
|
||||
result.masked_message || '';
|
||||
document.getElementById('maskedOutput').className = 'result-box success';
|
||||
|
||||
if (result.strategy_stats) {
|
||||
document.getElementById('matchesOutput').textContent =
|
||||
'Strategy Statistics:\n' + JSON.stringify(result.strategy_stats, null, 2);
|
||||
}
|
||||
|
||||
document.getElementById('fullResults').textContent =
|
||||
JSON.stringify(result, null, 2);
|
||||
document.getElementById('fullResults').className = 'result-box success';
|
||||
}
|
||||
|
||||
async function validatePatterns() {
|
||||
const patterns = getPatterns();
|
||||
|
||||
if (!patterns) {
|
||||
showError('Invalid JSON in patterns field');
|
||||
return;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const pattern of Object.keys(patterns)) {
|
||||
const result = await api('validate_pattern', { pattern });
|
||||
results.push({
|
||||
pattern,
|
||||
valid: result.valid,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
|
||||
const valid = results.filter(r => r.valid).length;
|
||||
const invalid = results.filter(r => !r.valid).length;
|
||||
|
||||
document.getElementById('fullResults').textContent =
|
||||
`Validation Results: ${valid} valid, ${invalid} invalid\n\n` +
|
||||
JSON.stringify(results, null, 2);
|
||||
|
||||
document.getElementById('fullResults').className =
|
||||
invalid > 0 ? 'result-box error' : 'result-box success';
|
||||
}
|
||||
|
||||
async function loadDefaults() {
|
||||
const result = await api('get_defaults');
|
||||
if (result.patterns) {
|
||||
document.getElementById('patterns').value =
|
||||
JSON.stringify(result.patterns, null, 4);
|
||||
}
|
||||
}
|
||||
|
||||
function clearPatterns() {
|
||||
document.getElementById('patterns').value = '{\n \n}';
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
document.getElementById('fullResults').textContent = 'Error: ' + message;
|
||||
document.getElementById('fullResults').className = 'result-box error';
|
||||
}
|
||||
|
||||
// Load default patterns on page load
|
||||
async function loadDefaultPatternsOnInit() {
|
||||
try {
|
||||
const result = await api('get_defaults');
|
||||
if (result.patterns) {
|
||||
const container = document.getElementById('defaultPatterns');
|
||||
container.innerHTML = Object.entries(result.patterns)
|
||||
.map(([pattern, replacement]) =>
|
||||
`<div class="pattern-item">
|
||||
<code>${escapeHtml(pattern)}</code>
|
||||
<span class="arrow">→</span>
|
||||
<code>${escapeHtml(replacement)}</code>
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
} catch (error) {
|
||||
const container = document.getElementById('defaultPatterns');
|
||||
container.textContent = 'Error loading default patterns: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
loadDefaultPatternsOnInit().catch(error => {
|
||||
console.error('Failed to initialize:', error);
|
||||
});
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
35
docker/Dockerfile
Normal file
35
docker/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
||||
FROM php:8.5-cli-alpine
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
git \
|
||||
unzip \
|
||||
curl \
|
||||
libzip-dev \
|
||||
icu-dev \
|
||||
&& docker-php-ext-install \
|
||||
zip \
|
||||
intl \
|
||||
pcntl
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Install PCOV for code coverage
|
||||
RUN apk add --no-cache $PHPIZE_DEPS \
|
||||
&& pecl install pcov \
|
||||
&& docker-php-ext-enable pcov
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Set recommended PHP settings for development and create non-root user
|
||||
RUN echo "memory_limit=512M" >> /usr/local/etc/php/conf.d/docker-php-memory.ini \
|
||||
&& echo "error_reporting=E_ALL" >> /usr/local/etc/php/conf.d/docker-php-errors.ini \
|
||||
&& echo "display_errors=On" >> /usr/local/etc/php/conf.d/docker-php-errors.ini \
|
||||
&& addgroup -g 1000 developer \
|
||||
&& adduser -D -u 1000 -G developer developer
|
||||
|
||||
USER developer
|
||||
|
||||
CMD ["php", "-v"]
|
||||
29
docker/docker-compose.yml
Normal file
29
docker/docker-compose.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
php:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- ..:/app
|
||||
- composer-cache:/home/developer/.composer/cache
|
||||
working_dir: /app
|
||||
environment:
|
||||
- COMPOSER_HOME=/home/developer/.composer
|
||||
stdin_open: true
|
||||
tty: true
|
||||
command: tail -f /dev/null
|
||||
|
||||
# PHP 8.3 for testing compatibility
|
||||
php83:
|
||||
image: php:8.5-cli-alpine
|
||||
volumes:
|
||||
- ..:/app
|
||||
working_dir: /app
|
||||
profiles:
|
||||
- testing
|
||||
command: php -v
|
||||
|
||||
volumes:
|
||||
composer-cache:
|
||||
315
docs/docker-development.md
Normal file
315
docs/docker-development.md
Normal file
@@ -0,0 +1,315 @@
|
||||
# Docker Development Environment
|
||||
|
||||
This guide explains how to set up a Docker development environment for working with the Monolog GDPR Filter library.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using Docker Compose
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/ivuorinen/monolog-gdpr-filter.git
|
||||
cd monolog-gdpr-filter
|
||||
|
||||
# Start the development environment
|
||||
docker compose up -d
|
||||
|
||||
# Run tests
|
||||
docker compose exec php composer test
|
||||
|
||||
# Run linting
|
||||
docker compose exec php composer lint
|
||||
```
|
||||
|
||||
## Docker Configuration Files
|
||||
|
||||
### docker/Dockerfile
|
||||
|
||||
```dockerfile
|
||||
FROM php:8.4-cli-alpine
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
git \
|
||||
unzip \
|
||||
curl \
|
||||
libzip-dev \
|
||||
icu-dev \
|
||||
&& docker-php-ext-install \
|
||||
zip \
|
||||
intl \
|
||||
pcntl
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Install Xdebug for code coverage
|
||||
RUN apk add --no-cache $PHPIZE_DEPS \
|
||||
&& pecl install xdebug \
|
||||
&& docker-php-ext-enable xdebug
|
||||
|
||||
# Configure Xdebug
|
||||
RUN echo "xdebug.mode=coverage,debug" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
|
||||
&& echo "xdebug.client_host=host.docker.internal" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Set recommended PHP settings for development
|
||||
RUN echo "memory_limit=512M" >> /usr/local/etc/php/conf.d/docker-php-memory.ini \
|
||||
&& echo "error_reporting=E_ALL" >> /usr/local/etc/php/conf.d/docker-php-errors.ini \
|
||||
&& echo "display_errors=On" >> /usr/local/etc/php/conf.d/docker-php-errors.ini
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1000 developer \
|
||||
&& adduser -D -u 1000 -G developer developer
|
||||
|
||||
USER developer
|
||||
|
||||
CMD ["php", "-v"]
|
||||
```
|
||||
|
||||
### docker/docker-compose.yml
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
php:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- ..:/app
|
||||
- composer-cache:/home/developer/.composer/cache
|
||||
working_dir: /app
|
||||
environment:
|
||||
- COMPOSER_HOME=/home/developer/.composer
|
||||
- XDEBUG_MODE=coverage
|
||||
stdin_open: true
|
||||
tty: true
|
||||
command: tail -f /dev/null
|
||||
|
||||
# Optional: PHP 8.5 for testing compatibility
|
||||
php83:
|
||||
image: php:8.5-cli-alpine
|
||||
volumes:
|
||||
- ..:/app
|
||||
working_dir: /app
|
||||
profiles:
|
||||
- testing
|
||||
command: php -v
|
||||
|
||||
volumes:
|
||||
composer-cache:
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### All Tests
|
||||
|
||||
```bash
|
||||
docker compose exec php composer test
|
||||
```
|
||||
|
||||
### With Coverage Report
|
||||
|
||||
```bash
|
||||
docker compose exec php composer test:coverage
|
||||
```
|
||||
|
||||
### Specific Test File
|
||||
|
||||
```bash
|
||||
docker compose exec php ./vendor/bin/phpunit tests/GdprProcessorTest.php
|
||||
```
|
||||
|
||||
### Specific Test Method
|
||||
|
||||
```bash
|
||||
docker compose exec php ./vendor/bin/phpunit --filter testEmailMasking
|
||||
```
|
||||
|
||||
## Running Linting Tools
|
||||
|
||||
### All Linting
|
||||
|
||||
```bash
|
||||
docker compose exec php composer lint
|
||||
```
|
||||
|
||||
### Individual Tools
|
||||
|
||||
```bash
|
||||
# PHP CodeSniffer
|
||||
docker compose exec php ./vendor/bin/phpcs
|
||||
|
||||
# Auto-fix with PHPCBF
|
||||
docker compose exec php ./vendor/bin/phpcbf
|
||||
|
||||
# Psalm
|
||||
docker compose exec php ./vendor/bin/psalm
|
||||
|
||||
# PHPStan
|
||||
docker compose exec php ./vendor/bin/phpstan analyse
|
||||
|
||||
# Rector (dry-run)
|
||||
docker compose exec php ./vendor/bin/rector --dry-run
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Initial Setup
|
||||
|
||||
```bash
|
||||
# Build containers
|
||||
docker compose build
|
||||
|
||||
# Start services
|
||||
docker compose up -d
|
||||
|
||||
# Install dependencies
|
||||
docker compose exec php composer install
|
||||
|
||||
# Run initial checks
|
||||
docker compose exec php composer lint
|
||||
docker compose exec php composer test
|
||||
```
|
||||
|
||||
### Daily Development
|
||||
|
||||
```bash
|
||||
# Start environment
|
||||
docker compose up -d
|
||||
|
||||
# Make changes...
|
||||
|
||||
# Run tests
|
||||
docker compose exec php composer test
|
||||
|
||||
# Run linting
|
||||
docker compose exec php composer lint
|
||||
|
||||
# Auto-fix issues
|
||||
docker compose exec php composer lint:fix
|
||||
```
|
||||
|
||||
### Testing Multiple PHP Versions
|
||||
|
||||
```bash
|
||||
# Test with PHP 8.3
|
||||
docker compose --profile testing run php83 php -v
|
||||
docker compose --profile testing run php83 ./vendor/bin/phpunit
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### Enable Xdebug
|
||||
|
||||
The Docker configuration includes Xdebug. Configure your IDE to listen on port 9003.
|
||||
|
||||
For VS Code, add to `.vscode/launch.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Listen for Xdebug",
|
||||
"type": "php",
|
||||
"request": "launch",
|
||||
"port": 9003,
|
||||
"pathMappings": {
|
||||
"/app": "${workspaceFolder}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Interactive Shell
|
||||
|
||||
```bash
|
||||
docker compose exec php sh
|
||||
```
|
||||
|
||||
### View Logs
|
||||
|
||||
```bash
|
||||
docker compose logs -f php
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions Example
|
||||
|
||||
```yaml
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.4', '8.5']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
extensions: intl, zip
|
||||
coverage: xdebug
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress
|
||||
|
||||
- name: Run linting
|
||||
run: composer lint
|
||||
|
||||
- name: Run tests
|
||||
run: composer test:coverage
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Permission Issues
|
||||
|
||||
If you encounter permission issues:
|
||||
|
||||
```bash
|
||||
# Fix ownership
|
||||
docker compose exec -u root php chown -R developer:developer /app
|
||||
|
||||
# Or run as root temporarily
|
||||
docker compose exec -u root php composer install
|
||||
```
|
||||
|
||||
### Composer Memory Limit
|
||||
|
||||
```bash
|
||||
docker compose exec php php -d memory_limit=-1 /usr/bin/composer install
|
||||
```
|
||||
|
||||
### Clear Caches
|
||||
|
||||
```bash
|
||||
# Clear composer cache
|
||||
docker compose exec php composer clear-cache
|
||||
|
||||
# Clear Psalm cache
|
||||
docker compose exec php ./vendor/bin/psalm --clear-cache
|
||||
|
||||
# Clear PHPStan cache
|
||||
docker compose exec php ./vendor/bin/phpstan clear-result-cache
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [Symfony Integration](symfony-integration.md)
|
||||
- [PSR-3 Decorator](psr3-decorator.md)
|
||||
- [Framework Examples](framework-examples.md)
|
||||
372
docs/framework-examples.md
Normal file
372
docs/framework-examples.md
Normal file
@@ -0,0 +1,372 @@
|
||||
# Framework Integration Examples
|
||||
|
||||
This guide provides integration examples for various PHP frameworks.
|
||||
|
||||
## CakePHP
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
composer require ivuorinen/monolog-gdpr-filter
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Create a custom log engine in `src/Log/Engine/GdprFileLog.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Log\Engine;
|
||||
|
||||
use Cake\Log\Engine\FileLog;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Monolog\Level;
|
||||
use Monolog\LogRecord;
|
||||
use DateTimeImmutable;
|
||||
|
||||
class GdprFileLog extends FileLog
|
||||
{
|
||||
protected GdprProcessor $gdprProcessor;
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
parent::__construct($config);
|
||||
|
||||
$patterns = $config['gdpr_patterns'] ?? [
|
||||
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => '[email]',
|
||||
'/\b\d{3}-\d{2}-\d{4}\b/' => '***-**-****',
|
||||
];
|
||||
|
||||
$this->gdprProcessor = new GdprProcessor($patterns);
|
||||
}
|
||||
|
||||
public function log($level, string $message, array $context = []): void
|
||||
{
|
||||
$record = new LogRecord(
|
||||
datetime: new DateTimeImmutable(),
|
||||
channel: 'app',
|
||||
level: $this->convertLevel($level),
|
||||
message: $message,
|
||||
context: $context
|
||||
);
|
||||
|
||||
$processed = ($this->gdprProcessor)($record);
|
||||
|
||||
parent::log($level, $processed->message, $processed->context);
|
||||
}
|
||||
|
||||
private function convertLevel(mixed $level): Level
|
||||
{
|
||||
return match ($level) {
|
||||
'emergency' => Level::Emergency,
|
||||
'alert' => Level::Alert,
|
||||
'critical' => Level::Critical,
|
||||
'error' => Level::Error,
|
||||
'warning' => Level::Warning,
|
||||
'notice' => Level::Notice,
|
||||
'info' => Level::Info,
|
||||
'debug' => Level::Debug,
|
||||
default => Level::Info,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Configure in `config/app.php`:
|
||||
|
||||
```php
|
||||
'Log' => [
|
||||
'default' => [
|
||||
'className' => \App\Log\Engine\GdprFileLog::class,
|
||||
'path' => LOGS,
|
||||
'file' => 'debug',
|
||||
'levels' => ['notice', 'info', 'debug'],
|
||||
'gdpr_patterns' => [
|
||||
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => '[email]',
|
||||
],
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
## CodeIgniter 4
|
||||
|
||||
### Configuration
|
||||
|
||||
Create a custom logger in `app/Libraries/GdprLogger.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use CodeIgniter\Log\Logger;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Monolog\Level;
|
||||
use Monolog\LogRecord;
|
||||
use DateTimeImmutable;
|
||||
|
||||
class GdprLogger extends Logger
|
||||
{
|
||||
protected GdprProcessor $gdprProcessor;
|
||||
|
||||
public function __construct($config, bool $introspect = true)
|
||||
{
|
||||
parent::__construct($config, $introspect);
|
||||
|
||||
$patterns = $config->gdprPatterns ?? [
|
||||
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => '[email]',
|
||||
];
|
||||
|
||||
$this->gdprProcessor = new GdprProcessor($patterns);
|
||||
}
|
||||
|
||||
public function log($level, $message, array $context = []): bool
|
||||
{
|
||||
$record = new LogRecord(
|
||||
datetime: new DateTimeImmutable(),
|
||||
channel: 'ci4',
|
||||
level: $this->mapLevel($level),
|
||||
message: (string) $message,
|
||||
context: $context
|
||||
);
|
||||
|
||||
$processed = ($this->gdprProcessor)($record);
|
||||
|
||||
return parent::log($level, $processed->message, $processed->context);
|
||||
}
|
||||
|
||||
private function mapLevel(mixed $level): Level
|
||||
{
|
||||
return match (strtolower((string) $level)) {
|
||||
'emergency' => Level::Emergency,
|
||||
'alert' => Level::Alert,
|
||||
'critical' => Level::Critical,
|
||||
'error' => Level::Error,
|
||||
'warning' => Level::Warning,
|
||||
'notice' => Level::Notice,
|
||||
'info' => Level::Info,
|
||||
'debug' => Level::Debug,
|
||||
default => Level::Info,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Register in `app/Config/Services.php`:
|
||||
|
||||
```php
|
||||
public static function logger(bool $getShared = true): \App\Libraries\GdprLogger
|
||||
{
|
||||
if ($getShared) {
|
||||
return static::getSharedInstance('logger');
|
||||
}
|
||||
|
||||
return new \App\Libraries\GdprLogger(new \Config\Logger());
|
||||
}
|
||||
```
|
||||
|
||||
## Laminas (formerly Zend Framework)
|
||||
|
||||
### Service Configuration
|
||||
|
||||
```php
|
||||
<?php
|
||||
// config/autoload/logging.global.php
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Laminas\Log\Logger;
|
||||
use Laminas\Log\Writer\Stream;
|
||||
use Laminas\Log\Processor\ProcessorInterface;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
return [
|
||||
'service_manager' => [
|
||||
'factories' => [
|
||||
GdprProcessor::class => function (ContainerInterface $container) {
|
||||
$config = $container->get('config')['gdpr'] ?? [];
|
||||
return new GdprProcessor(
|
||||
$config['patterns'] ?? [],
|
||||
$config['field_paths'] ?? []
|
||||
);
|
||||
},
|
||||
|
||||
'GdprLogProcessor' => function (ContainerInterface $container) {
|
||||
$gdprProcessor = $container->get(GdprProcessor::class);
|
||||
|
||||
return new class($gdprProcessor) implements ProcessorInterface {
|
||||
public function __construct(
|
||||
private readonly GdprProcessor $gdprProcessor
|
||||
) {}
|
||||
|
||||
public function process(array $event): array
|
||||
{
|
||||
// Convert to LogRecord, process, convert back
|
||||
$record = new \Monolog\LogRecord(
|
||||
datetime: new \DateTimeImmutable(),
|
||||
channel: 'laminas',
|
||||
level: \Monolog\Level::Info,
|
||||
message: $event['message'] ?? '',
|
||||
context: $event['extra'] ?? []
|
||||
);
|
||||
|
||||
$processed = ($this->gdprProcessor)($record);
|
||||
|
||||
$event['message'] = $processed->message;
|
||||
$event['extra'] = $processed->context;
|
||||
|
||||
return $event;
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
Logger::class => function (ContainerInterface $container) {
|
||||
$logger = new Logger();
|
||||
$logger->addWriter(new Stream('data/logs/app.log'));
|
||||
$logger->addProcessor($container->get('GdprLogProcessor'));
|
||||
return $logger;
|
||||
},
|
||||
],
|
||||
],
|
||||
|
||||
'gdpr' => [
|
||||
'patterns' => [
|
||||
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => '[email]',
|
||||
'/\b\d{3}-\d{2}-\d{4}\b/' => '***-**-****',
|
||||
],
|
||||
'field_paths' => [
|
||||
'user.password' => '***REMOVED***',
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
## Yii2
|
||||
|
||||
### Component Configuration
|
||||
|
||||
```php
|
||||
<?php
|
||||
// config/web.php or config/console.php
|
||||
|
||||
return [
|
||||
'components' => [
|
||||
'log' => [
|
||||
'traceLevel' => YII_DEBUG ? 3 : 0,
|
||||
'targets' => [
|
||||
[
|
||||
'class' => 'app\components\GdprFileTarget',
|
||||
'levels' => ['error', 'warning', 'info'],
|
||||
'gdprPatterns' => [
|
||||
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => '[email]',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
Create `components/GdprFileTarget.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace app\components;
|
||||
|
||||
use yii\log\FileTarget;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Monolog\Level;
|
||||
use Monolog\LogRecord;
|
||||
use DateTimeImmutable;
|
||||
|
||||
class GdprFileTarget extends FileTarget
|
||||
{
|
||||
public array $gdprPatterns = [];
|
||||
|
||||
private ?GdprProcessor $processor = null;
|
||||
|
||||
public function init(): void
|
||||
{
|
||||
parent::init();
|
||||
|
||||
if (!empty($this->gdprPatterns)) {
|
||||
$this->processor = new GdprProcessor($this->gdprPatterns);
|
||||
}
|
||||
}
|
||||
|
||||
public function formatMessage($message): string
|
||||
{
|
||||
if ($this->processor !== null) {
|
||||
[$text, $level, $category, $timestamp] = $message;
|
||||
|
||||
$record = new LogRecord(
|
||||
datetime: new DateTimeImmutable('@' . $timestamp),
|
||||
channel: $category,
|
||||
level: Level::Info,
|
||||
message: is_string($text) ? $text : json_encode($text) ?: '',
|
||||
context: []
|
||||
);
|
||||
|
||||
$processed = ($this->processor)($record);
|
||||
$message[0] = $processed->message;
|
||||
}
|
||||
|
||||
return parent::formatMessage($message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Generic PSR-15 Middleware
|
||||
|
||||
For any framework supporting PSR-15 middleware:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace YourApp\Middleware;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class GdprLoggingMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly GdprProcessor $gdprProcessor
|
||||
) {
|
||||
}
|
||||
|
||||
public function process(
|
||||
ServerRequestInterface $request,
|
||||
RequestHandlerInterface $handler
|
||||
): ResponseInterface {
|
||||
// Log request (with GDPR filtering applied via decorator)
|
||||
$this->logger->info('Request received', [
|
||||
'method' => $request->getMethod(),
|
||||
'uri' => (string) $request->getUri(),
|
||||
'body' => $request->getParsedBody(),
|
||||
]);
|
||||
|
||||
$response = $handler->handle($request);
|
||||
|
||||
// Log response
|
||||
$this->logger->info('Response sent', [
|
||||
'status' => $response->getStatusCode(),
|
||||
]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [Symfony Integration](symfony-integration.md)
|
||||
- [PSR-3 Decorator](psr3-decorator.md)
|
||||
- [Docker Development](docker-development.md)
|
||||
595
docs/logging-integrations.md
Normal file
595
docs/logging-integrations.md
Normal file
@@ -0,0 +1,595 @@
|
||||
# Logging Platform Integrations
|
||||
|
||||
This guide covers integrating the Monolog GDPR Filter with popular logging platforms and services.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [ELK Stack (Elasticsearch, Logstash, Kibana)](#elk-stack)
|
||||
- [Graylog](#graylog)
|
||||
- [Datadog](#datadog)
|
||||
- [New Relic](#new-relic)
|
||||
- [Sentry](#sentry)
|
||||
- [Papertrail](#papertrail)
|
||||
- [Loggly](#loggly)
|
||||
- [AWS CloudWatch](#aws-cloudwatch)
|
||||
- [Google Cloud Logging](#google-cloud-logging)
|
||||
- [Fluentd/Fluent Bit](#fluentdfluent-bit)
|
||||
|
||||
## ELK Stack
|
||||
|
||||
### Elasticsearch with Monolog
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Monolog\Logger;
|
||||
use Monolog\Handler\ElasticsearchHandler;
|
||||
use Monolog\Formatter\ElasticsearchFormatter;
|
||||
use Elastic\Elasticsearch\ClientBuilder;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
|
||||
|
||||
// Create Elasticsearch client
|
||||
$client = ClientBuilder::create()
|
||||
->setHosts(['localhost:9200'])
|
||||
->build();
|
||||
|
||||
// Create handler
|
||||
$handler = new ElasticsearchHandler($client, [
|
||||
'index' => 'app-logs',
|
||||
'type' => '_doc',
|
||||
]);
|
||||
$handler->setFormatter(new ElasticsearchFormatter('app-logs', '_doc'));
|
||||
|
||||
// Create logger with GDPR processor
|
||||
$logger = new Logger('app');
|
||||
$logger->pushHandler($handler);
|
||||
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
|
||||
|
||||
// Logs are now GDPR-compliant before reaching Elasticsearch
|
||||
$logger->info('User login', ['email' => 'user@example.com', 'ip' => '192.168.1.1']);
|
||||
```
|
||||
|
||||
### Logstash Integration
|
||||
|
||||
For Logstash, use the Gelf handler or send JSON to a TCP/UDP input:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Monolog\Logger;
|
||||
use Monolog\Handler\SocketHandler;
|
||||
use Monolog\Formatter\JsonFormatter;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
|
||||
|
||||
$handler = new SocketHandler('tcp://logstash.example.com:5000');
|
||||
$handler->setFormatter(new JsonFormatter());
|
||||
|
||||
$logger = new Logger('app');
|
||||
$logger->pushHandler($handler);
|
||||
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
|
||||
```
|
||||
|
||||
Logstash configuration:
|
||||
|
||||
```ruby
|
||||
input {
|
||||
tcp {
|
||||
port => 5000
|
||||
codec => json
|
||||
}
|
||||
}
|
||||
|
||||
output {
|
||||
elasticsearch {
|
||||
hosts => ["elasticsearch:9200"]
|
||||
index => "app-logs-%{+YYYY.MM.dd}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Graylog
|
||||
|
||||
### GELF Handler Integration
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Monolog\Logger;
|
||||
use Monolog\Handler\GelfHandler;
|
||||
use Gelf\Publisher;
|
||||
use Gelf\Transport\UdpTransport;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
|
||||
|
||||
// Create GELF transport
|
||||
$transport = new UdpTransport('graylog.example.com', 12201);
|
||||
$publisher = new Publisher($transport);
|
||||
|
||||
// Create handler
|
||||
$handler = new GelfHandler($publisher);
|
||||
|
||||
// Create logger with GDPR processor
|
||||
$logger = new Logger('app');
|
||||
$logger->pushHandler($handler);
|
||||
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
|
||||
|
||||
$logger->info('Payment processed', [
|
||||
'user_email' => 'customer@example.com',
|
||||
'card_last_four' => '4242',
|
||||
]);
|
||||
```
|
||||
|
||||
### Graylog Stream Configuration
|
||||
|
||||
Create a stream to filter GDPR-sensitive logs:
|
||||
|
||||
1. Create an extractor to identify masked fields
|
||||
2. Set up alerts for potential data leaks (unmasked patterns)
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// Add metadata to help Graylog categorize
|
||||
$logger->pushProcessor(function ($record) {
|
||||
$record['extra']['gdpr_processed'] = true;
|
||||
$record['extra']['app_version'] = '1.0.0';
|
||||
return $record;
|
||||
});
|
||||
```
|
||||
|
||||
## Datadog
|
||||
|
||||
### Datadog Handler Integration
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Monolog\Logger;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Formatter\JsonFormatter;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
|
||||
|
||||
// Datadog agent reads from file or stdout
|
||||
$handler = new StreamHandler('php://stdout');
|
||||
$handler->setFormatter(new JsonFormatter());
|
||||
|
||||
$logger = new Logger('app');
|
||||
$logger->pushHandler($handler);
|
||||
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
|
||||
|
||||
// Add Datadog-specific context
|
||||
$logger->pushProcessor(function ($record) {
|
||||
$record['extra']['dd'] = [
|
||||
'service' => 'my-php-app',
|
||||
'env' => getenv('DD_ENV') ?: 'production',
|
||||
'version' => '1.0.0',
|
||||
];
|
||||
return $record;
|
||||
});
|
||||
|
||||
$logger->info('User action', ['user_id' => 123, 'email' => 'user@example.com']);
|
||||
```
|
||||
|
||||
### Datadog APM Integration
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use DDTrace\GlobalTracer;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
|
||||
// Add trace context to logs
|
||||
$logger->pushProcessor(function ($record) {
|
||||
$tracer = GlobalTracer::get();
|
||||
$span = $tracer->getActiveSpan();
|
||||
|
||||
if ($span) {
|
||||
$record['extra']['dd.trace_id'] = $span->getTraceId();
|
||||
$record['extra']['dd.span_id'] = $span->getSpanId();
|
||||
}
|
||||
|
||||
return $record;
|
||||
});
|
||||
```
|
||||
|
||||
## New Relic
|
||||
|
||||
### New Relic Handler Integration
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Monolog\Logger;
|
||||
use Monolog\Handler\NewRelicHandler;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
|
||||
|
||||
$handler = new NewRelicHandler(
|
||||
level: Logger::ERROR,
|
||||
appName: 'My PHP App'
|
||||
);
|
||||
|
||||
$logger = new Logger('app');
|
||||
$logger->pushHandler($handler);
|
||||
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
|
||||
|
||||
// Errors are sent to New Relic with masked PII
|
||||
$logger->error('Authentication failed', [
|
||||
'email' => 'user@example.com',
|
||||
'ip' => '192.168.1.1',
|
||||
]);
|
||||
```
|
||||
|
||||
### Custom Attributes
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// Add New Relic custom attributes
|
||||
$logger->pushProcessor(function ($record) {
|
||||
if (function_exists('newrelic_add_custom_parameter')) {
|
||||
newrelic_add_custom_parameter('log_level', $record['level_name']);
|
||||
newrelic_add_custom_parameter('channel', $record['channel']);
|
||||
}
|
||||
return $record;
|
||||
});
|
||||
```
|
||||
|
||||
## Sentry
|
||||
|
||||
### Sentry Handler Integration
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Monolog\Logger;
|
||||
use Sentry\Monolog\Handler;
|
||||
use Sentry\State\Hub;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
|
||||
|
||||
\Sentry\init(['dsn' => 'https://key@sentry.io/project']);
|
||||
|
||||
$handler = new Handler(Hub::getCurrent());
|
||||
|
||||
$logger = new Logger('app');
|
||||
$logger->pushHandler($handler);
|
||||
|
||||
// IMPORTANT: Add GDPR processor BEFORE Sentry handler processes
|
||||
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
|
||||
|
||||
$logger->error('Payment failed', [
|
||||
'user_email' => 'customer@example.com',
|
||||
'card_number' => '4111111111111111',
|
||||
]);
|
||||
```
|
||||
|
||||
### Sentry Breadcrumbs
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Sentry\Breadcrumb;
|
||||
|
||||
// Add breadcrumb processor that respects GDPR
|
||||
$logger->pushProcessor(function ($record) {
|
||||
\Sentry\addBreadcrumb(new Breadcrumb(
|
||||
Breadcrumb::LEVEL_INFO,
|
||||
Breadcrumb::TYPE_DEFAULT,
|
||||
$record['channel'],
|
||||
$record['message'], // Already masked by GDPR processor
|
||||
$record['context'] // Already masked
|
||||
));
|
||||
return $record;
|
||||
});
|
||||
```
|
||||
|
||||
## Papertrail
|
||||
|
||||
### Papertrail Handler Integration
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Monolog\Logger;
|
||||
use Monolog\Handler\SyslogUdpHandler;
|
||||
use Monolog\Formatter\LineFormatter;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
|
||||
|
||||
$handler = new SyslogUdpHandler(
|
||||
'logs.papertrailapp.com',
|
||||
12345 // Your Papertrail port
|
||||
);
|
||||
|
||||
$formatter = new LineFormatter(
|
||||
"%channel%.%level_name%: %message% %context% %extra%\n",
|
||||
null,
|
||||
true,
|
||||
true
|
||||
);
|
||||
$handler->setFormatter($formatter);
|
||||
|
||||
$logger = new Logger('my-app');
|
||||
$logger->pushHandler($handler);
|
||||
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
|
||||
```
|
||||
|
||||
## Loggly
|
||||
|
||||
### Loggly Handler Integration
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Monolog\Logger;
|
||||
use Monolog\Handler\LogglyHandler;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
|
||||
|
||||
$handler = new LogglyHandler('your-loggly-token/tag/monolog');
|
||||
|
||||
$logger = new Logger('app');
|
||||
$logger->pushHandler($handler);
|
||||
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
|
||||
|
||||
$logger->info('User registered', [
|
||||
'email' => 'newuser@example.com',
|
||||
'phone' => '+1-555-123-4567',
|
||||
]);
|
||||
```
|
||||
|
||||
## AWS CloudWatch
|
||||
|
||||
### CloudWatch Handler Integration
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Monolog\Logger;
|
||||
use Aws\CloudWatchLogs\CloudWatchLogsClient;
|
||||
use Maxbanton\Cwh\Handler\CloudWatch;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
|
||||
|
||||
$client = new CloudWatchLogsClient([
|
||||
'region' => 'us-east-1',
|
||||
'version' => 'latest',
|
||||
]);
|
||||
|
||||
$handler = new CloudWatch(
|
||||
$client,
|
||||
'app-log-group',
|
||||
'app-log-stream',
|
||||
retentionDays: 14
|
||||
);
|
||||
|
||||
$logger = new Logger('app');
|
||||
$logger->pushHandler($handler);
|
||||
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
|
||||
|
||||
$logger->info('API request', [
|
||||
'user_email' => 'api-user@example.com',
|
||||
'endpoint' => '/api/v1/users',
|
||||
]);
|
||||
```
|
||||
|
||||
### CloudWatch with Laravel
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// config/logging.php
|
||||
return [
|
||||
'channels' => [
|
||||
'cloudwatch' => [
|
||||
'driver' => 'custom',
|
||||
'via' => App\Logging\CloudWatchLoggerFactory::class,
|
||||
'retention' => 14,
|
||||
'group' => env('CLOUDWATCH_LOG_GROUP', 'laravel'),
|
||||
'stream' => env('CLOUDWATCH_LOG_STREAM', 'app'),
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// app/Logging/CloudWatchLoggerFactory.php
|
||||
namespace App\Logging;
|
||||
|
||||
use Aws\CloudWatchLogs\CloudWatchLogsClient;
|
||||
use Maxbanton\Cwh\Handler\CloudWatch;
|
||||
use Monolog\Logger;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
|
||||
|
||||
class CloudWatchLoggerFactory
|
||||
{
|
||||
public function __invoke(array $config): Logger
|
||||
{
|
||||
$client = new CloudWatchLogsClient([
|
||||
'region' => config('services.aws.region'),
|
||||
'version' => 'latest',
|
||||
]);
|
||||
|
||||
$handler = new CloudWatch(
|
||||
$client,
|
||||
$config['group'],
|
||||
$config['stream'],
|
||||
$config['retention']
|
||||
);
|
||||
|
||||
$logger = new Logger('cloudwatch');
|
||||
$logger->pushHandler($handler);
|
||||
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
|
||||
|
||||
return $logger;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Google Cloud Logging
|
||||
|
||||
### Google Cloud Handler Integration
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Monolog\Logger;
|
||||
use Google\Cloud\Logging\LoggingClient;
|
||||
use Google\Cloud\Logging\PsrLogger;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
|
||||
|
||||
$logging = new LoggingClient([
|
||||
'projectId' => 'your-project-id',
|
||||
]);
|
||||
|
||||
$psrLogger = $logging->psrLogger('app-logs');
|
||||
|
||||
// Wrap in Monolog for processor support
|
||||
$monologLogger = new Logger('app');
|
||||
$monologLogger->pushHandler(new \Monolog\Handler\PsrHandler($psrLogger));
|
||||
$monologLogger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
|
||||
|
||||
$monologLogger->info('User action', [
|
||||
'email' => 'user@example.com',
|
||||
'action' => 'login',
|
||||
]);
|
||||
```
|
||||
|
||||
## Fluentd/Fluent Bit
|
||||
|
||||
### Fluentd Integration
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Monolog\Logger;
|
||||
use Monolog\Handler\SocketHandler;
|
||||
use Monolog\Formatter\JsonFormatter;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
|
||||
|
||||
// Send to Fluentd forward input
|
||||
$handler = new SocketHandler('tcp://fluentd:24224');
|
||||
$handler->setFormatter(new JsonFormatter());
|
||||
|
||||
$logger = new Logger('app');
|
||||
$logger->pushHandler($handler);
|
||||
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
|
||||
|
||||
// Add Fluentd tag
|
||||
$logger->pushProcessor(function ($record) {
|
||||
$record['extra']['fluent_tag'] = 'app.logs';
|
||||
return $record;
|
||||
});
|
||||
```
|
||||
|
||||
Fluentd configuration:
|
||||
|
||||
```ruby
|
||||
<source>
|
||||
@type forward
|
||||
port 24224
|
||||
</source>
|
||||
|
||||
<match app.**>
|
||||
@type elasticsearch
|
||||
host elasticsearch
|
||||
port 9200
|
||||
index_name app-logs
|
||||
</match>
|
||||
```
|
||||
|
||||
### Fluent Bit with File Tail
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Monolog\Logger;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Formatter\JsonFormatter;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
|
||||
|
||||
// Write JSON logs to file for Fluent Bit to tail
|
||||
$handler = new StreamHandler('/var/log/app/app.json.log');
|
||||
$handler->setFormatter(new JsonFormatter());
|
||||
|
||||
$logger = new Logger('app');
|
||||
$logger->pushHandler($handler);
|
||||
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
|
||||
```
|
||||
|
||||
Fluent Bit configuration:
|
||||
|
||||
```ini
|
||||
[INPUT]
|
||||
Name tail
|
||||
Path /var/log/app/*.json.log
|
||||
Parser json
|
||||
|
||||
[OUTPUT]
|
||||
Name es
|
||||
Host elasticsearch
|
||||
Port 9200
|
||||
Index app-logs
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Process Before Sending
|
||||
|
||||
Ensure the GDPR processor runs before logs leave your application:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// Correct order: GDPR processor added AFTER handlers
|
||||
$logger = new Logger('app');
|
||||
$logger->pushHandler($externalHandler);
|
||||
$logger->pushProcessor(new GdprProcessor($patterns)); // Runs before handlers
|
||||
```
|
||||
|
||||
### 2. Add Compliance Metadata
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
$logger->pushProcessor(function ($record) {
|
||||
$record['extra']['gdpr'] = [
|
||||
'processed' => true,
|
||||
'processor_version' => '3.0.0',
|
||||
'timestamp' => date('c'),
|
||||
];
|
||||
return $record;
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Monitor for Leaks
|
||||
|
||||
Set up alerts in your logging platform for unmasked PII patterns:
|
||||
|
||||
```json
|
||||
{
|
||||
"query": {
|
||||
"regexp": {
|
||||
"message": "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Retention Policies
|
||||
|
||||
Configure retention aligned with GDPR requirements:
|
||||
|
||||
- Most platforms support automatic log deletion
|
||||
- Set retention to 30 days for most operational logs
|
||||
- Archive critical audit logs separately with longer retention
|
||||
453
docs/performance-tuning.md
Normal file
453
docs/performance-tuning.md
Normal file
@@ -0,0 +1,453 @@
|
||||
# Performance Tuning Guide
|
||||
|
||||
This guide covers optimization strategies for the Monolog GDPR Filter library in high-throughput environments.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Benchmarking Your Setup](#benchmarking-your-setup)
|
||||
- [Pattern Optimization](#pattern-optimization)
|
||||
- [Memory Management](#memory-management)
|
||||
- [Caching Strategies](#caching-strategies)
|
||||
- [Rate Limiting](#rate-limiting)
|
||||
- [Streaming Large Logs](#streaming-large-logs)
|
||||
- [Production Configuration](#production-configuration)
|
||||
|
||||
## Benchmarking Your Setup
|
||||
|
||||
Before optimizing, establish baseline metrics:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
|
||||
|
||||
$processor = new GdprProcessor(DefaultPatterns::all());
|
||||
|
||||
$record = [
|
||||
'message' => 'User john@example.com logged in from 192.168.1.100',
|
||||
'context' => [
|
||||
'user' => ['email' => 'john@example.com', 'ssn' => '123-45-6789'],
|
||||
'ip' => '192.168.1.100',
|
||||
],
|
||||
'level' => 200,
|
||||
'level_name' => 'INFO',
|
||||
'channel' => 'app',
|
||||
'datetime' => new DateTimeImmutable(),
|
||||
'extra' => [],
|
||||
];
|
||||
|
||||
// Benchmark
|
||||
$iterations = 10000;
|
||||
$start = microtime(true);
|
||||
|
||||
for ($i = 0; $i < $iterations; $i++) {
|
||||
$processor($record);
|
||||
}
|
||||
|
||||
$elapsed = microtime(true) - $start;
|
||||
$perSecond = $iterations / $elapsed;
|
||||
|
||||
echo "Processed {$iterations} records in {$elapsed:.4f} seconds\n";
|
||||
echo "Throughput: {$perSecond:.0f} records/second\n";
|
||||
```
|
||||
|
||||
**Target benchmarks:**
|
||||
|
||||
- Simple patterns: 50,000+ records/second
|
||||
- Complex patterns with nested context: 10,000+ records/second
|
||||
- With audit logging: 5,000+ records/second
|
||||
|
||||
## Pattern Optimization
|
||||
|
||||
### 1. Order Patterns by Frequency
|
||||
|
||||
Place most frequently matched patterns first:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants;
|
||||
|
||||
// Good: Email (common) before SSN (rare)
|
||||
$patterns = [
|
||||
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => MaskConstants::MASK_EMAIL,
|
||||
'/\b\d{3}-\d{2}-\d{4}\b/' => MaskConstants::MASK_SSN,
|
||||
];
|
||||
|
||||
$processor = new GdprProcessor($patterns);
|
||||
```
|
||||
|
||||
### 2. Use Specific Patterns Over Generic
|
||||
|
||||
Specific patterns are faster than broad ones:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// Slow: Generic catch-all
|
||||
$slowPattern = '/\b\d{3}[-.\s]?\d{3}[-.\s]?\d{4}\b/';
|
||||
|
||||
// Fast: Specific format
|
||||
$fastPattern = '/\b\d{3}-\d{3}-\d{4}\b/';
|
||||
```
|
||||
|
||||
### 3. Avoid Catastrophic Backtracking
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// Bad: Potential backtracking issues
|
||||
$badPattern = '/.*@.*\..*/';
|
||||
|
||||
// Good: Bounded repetition
|
||||
$goodPattern = '/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/';
|
||||
```
|
||||
|
||||
### 4. Use Non-Capturing Groups
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// Slower: Capturing groups
|
||||
$slowPattern = '/(foo|bar|baz)/';
|
||||
|
||||
// Faster: Non-capturing groups
|
||||
$fastPattern = '/(?:foo|bar|baz)/';
|
||||
```
|
||||
|
||||
### 5. Pre-validate Patterns
|
||||
|
||||
Use the PatternValidator to cache validation results:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\PatternValidator;
|
||||
|
||||
$validator = new PatternValidator();
|
||||
|
||||
// Cache all patterns at startup
|
||||
$validator->cacheAllPatterns($patterns);
|
||||
```
|
||||
|
||||
## Memory Management
|
||||
|
||||
### 1. Limit Recursion Depth
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
|
||||
// Default is 10, reduce for memory-constrained environments
|
||||
$processor = new GdprProcessor(
|
||||
patterns: $patterns,
|
||||
maxDepth: 5 // Limit nested array processing
|
||||
);
|
||||
```
|
||||
|
||||
### 2. Use Streaming for Large Logs
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Streaming\StreamingProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\MaskingOrchestrator;
|
||||
|
||||
$orchestrator = new MaskingOrchestrator($patterns);
|
||||
$streaming = new StreamingProcessor(
|
||||
orchestrator: $orchestrator,
|
||||
chunkSize: 500 // Process 500 records at a time
|
||||
);
|
||||
|
||||
// Process large file with constant memory usage
|
||||
$lineParser = fn(string $line): array => [
|
||||
'message' => $line,
|
||||
'context' => [],
|
||||
];
|
||||
|
||||
foreach ($streaming->processFile('/var/log/large.log', $lineParser) as $record) {
|
||||
// Handle processed record
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Disable Audit Logging in High-Volume Scenarios
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
|
||||
// No audit logger = less memory allocation
|
||||
$processor = new GdprProcessor(
|
||||
patterns: $patterns,
|
||||
auditLogger: null
|
||||
);
|
||||
```
|
||||
|
||||
## Caching Strategies
|
||||
|
||||
### 1. Pattern Compilation Caching
|
||||
|
||||
Patterns are compiled once and cached internally. Ensure you reuse processor instances:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// Good: Singleton pattern
|
||||
class ProcessorFactory
|
||||
{
|
||||
private static ?GdprProcessor $instance = null;
|
||||
|
||||
public static function getInstance(): GdprProcessor
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new GdprProcessor(DefaultPatterns::all());
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Result Caching for Repeated Values
|
||||
|
||||
For applications processing similar data repeatedly:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
class CachedGdprProcessor
|
||||
{
|
||||
private GdprProcessor $processor;
|
||||
private array $cache = [];
|
||||
private int $maxCacheSize = 1000;
|
||||
|
||||
public function __construct(GdprProcessor $processor)
|
||||
{
|
||||
$this->processor = $processor;
|
||||
}
|
||||
|
||||
public function process(array $record): array
|
||||
{
|
||||
$key = md5(serialize($record['message'] . json_encode($record['context'])));
|
||||
|
||||
if (isset($this->cache[$key])) {
|
||||
return $this->cache[$key];
|
||||
}
|
||||
|
||||
$result = ($this->processor)($record);
|
||||
|
||||
if (count($this->cache) >= $this->maxCacheSize) {
|
||||
array_shift($this->cache);
|
||||
}
|
||||
|
||||
$this->cache[$key] = $result;
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
### 1. Rate-Limited Audit Logging
|
||||
|
||||
Prevent audit log flooding:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\RateLimitedAuditLogger;
|
||||
use Ivuorinen\MonologGdprFilter\RateLimiter;
|
||||
|
||||
$rateLimiter = new RateLimiter(
|
||||
maxEvents: 100, // Max 100 events
|
||||
windowSeconds: 60, // Per 60 seconds
|
||||
burstLimit: 20 // Allow burst of 20
|
||||
);
|
||||
|
||||
$auditLogger = new RateLimitedAuditLogger(
|
||||
baseLogger: fn($path, $original, $masked) => error_log("Masked: $path"),
|
||||
rateLimiter: $rateLimiter
|
||||
);
|
||||
```
|
||||
|
||||
### 2. Sampling for High-Volume Logging
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
class SampledProcessor
|
||||
{
|
||||
private GdprProcessor $processor;
|
||||
private float $sampleRate;
|
||||
|
||||
public function __construct(GdprProcessor $processor, float $sampleRate = 0.1)
|
||||
{
|
||||
$this->processor = $processor;
|
||||
$this->sampleRate = $sampleRate;
|
||||
}
|
||||
|
||||
public function __invoke(array $record): array
|
||||
{
|
||||
// Only process sample of records for audit
|
||||
$shouldAudit = (mt_rand() / mt_getrandmax()) < $this->sampleRate;
|
||||
|
||||
if (!$shouldAudit) {
|
||||
// Process without audit logging
|
||||
return $this->processWithoutAudit($record);
|
||||
}
|
||||
|
||||
return ($this->processor)($record);
|
||||
}
|
||||
|
||||
private function processWithoutAudit(array $record): array
|
||||
{
|
||||
// Implement lightweight processing
|
||||
return $record;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Streaming Large Logs
|
||||
|
||||
### 1. Chunk Size Optimization
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Streaming\StreamingProcessor;
|
||||
|
||||
// For memory-constrained environments
|
||||
$smallChunks = new StreamingProcessor($orchestrator, chunkSize: 100);
|
||||
|
||||
// For throughput-optimized environments
|
||||
$largeChunks = new StreamingProcessor($orchestrator, chunkSize: 1000);
|
||||
```
|
||||
|
||||
### 2. Parallel Processing
|
||||
|
||||
For multi-core systems, process chunks in parallel:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// Using pcntl_fork for parallel processing
|
||||
function processInParallel(array $files, StreamingProcessor $processor): void
|
||||
{
|
||||
$pids = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$pid = pcntl_fork();
|
||||
|
||||
if ($pid === 0) {
|
||||
// Child process
|
||||
$lineParser = fn(string $line): array => ['message' => $line, 'context' => []];
|
||||
foreach ($processor->processFile($file, $lineParser) as $record) {
|
||||
// Process record
|
||||
}
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$pids[] = $pid;
|
||||
}
|
||||
|
||||
// Wait for all children
|
||||
foreach ($pids as $pid) {
|
||||
pcntl_waitpid($pid, $status);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Production Configuration
|
||||
|
||||
### 1. Minimal Pattern Set
|
||||
|
||||
Only include patterns you actually need:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
|
||||
// Instead of DefaultPatterns::all(), use specific patterns
|
||||
$patterns = array_merge(
|
||||
DefaultPatterns::emails(),
|
||||
DefaultPatterns::creditCards(),
|
||||
// Only what you need
|
||||
);
|
||||
|
||||
$processor = new GdprProcessor($patterns);
|
||||
```
|
||||
|
||||
### 2. Disable Debug Features
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Builder\GdprProcessorBuilder;
|
||||
|
||||
$processor = (new GdprProcessorBuilder())
|
||||
->withDefaultPatterns()
|
||||
->withMaxDepth(5) // Limit recursion
|
||||
->withAuditLogger(null) // Disable audit logging
|
||||
->build();
|
||||
```
|
||||
|
||||
### 3. OPcache Configuration
|
||||
|
||||
Ensure OPcache is properly configured in `php.ini`:
|
||||
|
||||
```ini
|
||||
opcache.enable=1
|
||||
opcache.memory_consumption=256
|
||||
opcache.interned_strings_buffer=16
|
||||
opcache.max_accelerated_files=10000
|
||||
opcache.jit=1255
|
||||
opcache.jit_buffer_size=128M
|
||||
```
|
||||
|
||||
### 4. Preloading (PHP 8.0+)
|
||||
|
||||
Create a preload script:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// preload.php
|
||||
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
// Preload core classes
|
||||
$classes = [
|
||||
\Ivuorinen\MonologGdprFilter\GdprProcessor::class,
|
||||
\Ivuorinen\MonologGdprFilter\MaskingOrchestrator::class,
|
||||
\Ivuorinen\MonologGdprFilter\DefaultPatterns::class,
|
||||
\Ivuorinen\MonologGdprFilter\PatternValidator::class,
|
||||
];
|
||||
|
||||
foreach ($classes as $class) {
|
||||
class_exists($class);
|
||||
}
|
||||
```
|
||||
|
||||
Configure in `php.ini`:
|
||||
|
||||
```ini
|
||||
opcache.preload=/path/to/preload.php
|
||||
opcache.preload_user=www-data
|
||||
```
|
||||
|
||||
## Performance Checklist
|
||||
|
||||
- [ ] Benchmark baseline performance
|
||||
- [ ] Order patterns by frequency
|
||||
- [ ] Use specific patterns over generic
|
||||
- [ ] Limit recursion depth appropriately
|
||||
- [ ] Use streaming for large log files
|
||||
- [ ] Implement rate limiting for audit logs
|
||||
- [ ] Enable OPcache with JIT
|
||||
- [ ] Consider preloading in production
|
||||
- [ ] Reuse processor instances (singleton)
|
||||
- [ ] Disable unnecessary features in production
|
||||
599
docs/plugin-development.md
Normal file
599
docs/plugin-development.md
Normal file
@@ -0,0 +1,599 @@
|
||||
# Plugin Development Guide
|
||||
|
||||
This guide explains how to create custom plugins for the Monolog GDPR Filter library.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Introduction](#introduction)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Plugin Interface](#plugin-interface)
|
||||
- [Abstract Base Class](#abstract-base-class)
|
||||
- [Registration](#registration)
|
||||
- [Hook Execution Order](#hook-execution-order)
|
||||
- [Priority System](#priority-system)
|
||||
- [Configuration Contribution](#configuration-contribution)
|
||||
- [Use Cases](#use-cases)
|
||||
- [Best Practices](#best-practices)
|
||||
|
||||
## Introduction
|
||||
|
||||
Plugins extend the GDPR processor's functionality without modifying core code. Use plugins when you need to:
|
||||
|
||||
- Add custom masking patterns for your domain
|
||||
- Transform messages before or after standard masking
|
||||
- Enrich context with metadata
|
||||
- Integrate with external systems
|
||||
- Apply organization-specific compliance rules
|
||||
|
||||
### When to Use Plugins vs. Configuration
|
||||
|
||||
| Scenario | Use Plugin | Use Configuration |
|
||||
| -------- | --------- | ----------------- |
|
||||
| Add regex patterns | ✅ (via `getPatterns()`) | ✅ (via constructor) |
|
||||
| Custom transformation logic | ✅ | ❌ |
|
||||
| Conditional processing | ✅ | ❌ |
|
||||
| Multiple reusable rules | ✅ | ❌ |
|
||||
| Simple field masking | ❌ | ✅ |
|
||||
|
||||
## Quick Start
|
||||
|
||||
Create a minimal plugin in three steps:
|
||||
|
||||
### Step 1: Create the Plugin Class
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Logging\Plugins;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Plugins\AbstractMaskingPlugin;
|
||||
|
||||
class MyCompanyPlugin extends AbstractMaskingPlugin
|
||||
{
|
||||
public function getName(): string
|
||||
{
|
||||
return 'my-company-plugin';
|
||||
}
|
||||
|
||||
public function getPatterns(): array
|
||||
{
|
||||
return [
|
||||
'/INTERNAL-\d{6}/' => '[INTERNAL-ID]', // Internal ID format
|
||||
'/EMP-[A-Z]{2}\d{4}/' => '[EMPLOYEE-ID]', // Employee IDs
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Register the Plugin
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Builder\GdprProcessorBuilder;
|
||||
use App\Logging\Plugins\MyCompanyPlugin;
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->withDefaultPatterns()
|
||||
->addPlugin(new MyCompanyPlugin())
|
||||
->buildWithPlugins();
|
||||
```
|
||||
|
||||
### Step 3: Use with Monolog
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Monolog\Logger;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
|
||||
$logger = new Logger('app');
|
||||
$logger->pushHandler(new StreamHandler('app.log'));
|
||||
$logger->pushProcessor($processor);
|
||||
|
||||
// Internal IDs and employee IDs are now masked
|
||||
$logger->info('User INTERNAL-123456 (EMP-AB1234) logged in');
|
||||
// Output: User [INTERNAL-ID] ([EMPLOYEE-ID]) logged in
|
||||
```
|
||||
|
||||
## Plugin Interface
|
||||
|
||||
All plugins must implement `MaskingPluginInterface`:
|
||||
|
||||
```php
|
||||
interface MaskingPluginInterface
|
||||
{
|
||||
// Identification
|
||||
public function getName(): string;
|
||||
|
||||
// Pre-processing hooks (before standard masking)
|
||||
public function preProcessContext(array $context): array;
|
||||
public function preProcessMessage(string $message): string;
|
||||
|
||||
// Post-processing hooks (after standard masking)
|
||||
public function postProcessContext(array $context): array;
|
||||
public function postProcessMessage(string $message): string;
|
||||
|
||||
// Configuration contribution
|
||||
public function getPatterns(): array;
|
||||
public function getFieldPaths(): array;
|
||||
|
||||
// Execution order control
|
||||
public function getPriority(): int;
|
||||
}
|
||||
```
|
||||
|
||||
### Method Reference
|
||||
|
||||
| Method | Purpose | When Called |
|
||||
| ------ | ------- | ----------- |
|
||||
| `getName()` | Unique identifier for debugging | On registration |
|
||||
| `preProcessContext()` | Modify context before masking | Before core masking |
|
||||
| `preProcessMessage()` | Modify message before masking | Before core masking |
|
||||
| `postProcessContext()` | Modify context after masking | After core masking |
|
||||
| `postProcessMessage()` | Modify message after masking | After core masking |
|
||||
| `getPatterns()` | Provide regex patterns | During build |
|
||||
| `getFieldPaths()` | Provide field paths to mask | During build |
|
||||
| `getPriority()` | Control execution order | During sorting |
|
||||
|
||||
## Abstract Base Class
|
||||
|
||||
Extend `AbstractMaskingPlugin` to avoid implementing unused methods:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Plugins;
|
||||
|
||||
abstract class AbstractMaskingPlugin implements MaskingPluginInterface
|
||||
{
|
||||
public function __construct(protected readonly int $priority = 100)
|
||||
{
|
||||
}
|
||||
|
||||
// Default implementations return input unchanged
|
||||
public function preProcessContext(array $context): array { return $context; }
|
||||
public function postProcessContext(array $context): array { return $context; }
|
||||
public function preProcessMessage(string $message): string { return $message; }
|
||||
public function postProcessMessage(string $message): string { return $message; }
|
||||
public function getPatterns(): array { return []; }
|
||||
public function getFieldPaths(): array { return []; }
|
||||
public function getPriority(): int { return $this->priority; }
|
||||
}
|
||||
```
|
||||
|
||||
### Benefits
|
||||
|
||||
- Override only the methods you need
|
||||
- Default priority of 100 (customizable via constructor)
|
||||
- All hooks pass data through unchanged by default
|
||||
|
||||
## Registration
|
||||
|
||||
Register plugins using `GdprProcessorBuilder`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Builder\GdprProcessorBuilder;
|
||||
|
||||
// Single plugin
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPlugin($plugin)
|
||||
->buildWithPlugins();
|
||||
|
||||
// Multiple plugins
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPlugins([$plugin1, $plugin2, $plugin3])
|
||||
->buildWithPlugins();
|
||||
|
||||
// With other configuration
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->withDefaultPatterns()
|
||||
->addPattern('/custom/', '[MASKED]')
|
||||
->addFieldPath('secret', FieldMaskConfig::remove())
|
||||
->addPlugin($plugin)
|
||||
->withAuditLogger($auditLogger)
|
||||
->buildWithPlugins();
|
||||
```
|
||||
|
||||
### Return Types
|
||||
|
||||
```php
|
||||
// No plugins: returns GdprProcessor (no wrapper overhead)
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->withDefaultPatterns()
|
||||
->buildWithPlugins(); // GdprProcessor
|
||||
|
||||
// With plugins: returns PluginAwareProcessor (wraps GdprProcessor)
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPlugin($plugin)
|
||||
->buildWithPlugins(); // PluginAwareProcessor
|
||||
```
|
||||
|
||||
## Hook Execution Order
|
||||
|
||||
Understanding execution order is critical for plugins that interact:
|
||||
|
||||
```text
|
||||
1. preProcessMessage() - Plugins in priority order (10, 20, 30...)
|
||||
2. preProcessContext() - Plugins in priority order (10, 20, 30...)
|
||||
3. [Core GdprProcessor masking]
|
||||
4. postProcessMessage() - Plugins in REVERSE order (30, 20, 10...)
|
||||
5. postProcessContext() - Plugins in REVERSE order (30, 20, 10...)
|
||||
```
|
||||
|
||||
### Why Reverse Order for Post-Processing?
|
||||
|
||||
Post-processing runs in reverse to properly "unwrap" transformations:
|
||||
|
||||
```php
|
||||
// Plugin A (priority 10) wraps: "data" -> "[A:data:A]"
|
||||
// Plugin B (priority 20) wraps: "[A:data:A]" -> "[B:[A:data:A]:B]"
|
||||
|
||||
// Post-processing reverse order ensures proper unwrapping:
|
||||
// Plugin B runs first: "[B:[A:masked:A]:B]" -> "[A:masked:A]"
|
||||
// Plugin A runs second: "[A:masked:A]" -> "masked"
|
||||
```
|
||||
|
||||
## Priority System
|
||||
|
||||
Lower numbers execute earlier in pre-processing:
|
||||
|
||||
```php
|
||||
class HighPriorityPlugin extends AbstractMaskingPlugin
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(priority: 10); // Runs early
|
||||
}
|
||||
}
|
||||
|
||||
class NormalPriorityPlugin extends AbstractMaskingPlugin
|
||||
{
|
||||
// Default priority: 100
|
||||
}
|
||||
|
||||
class LowPriorityPlugin extends AbstractMaskingPlugin
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(priority: 200); // Runs late
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Recommended Priority Ranges
|
||||
|
||||
| Range | Use Case | Example |
|
||||
| ----- | -------- | ------- |
|
||||
| 1-50 | Security/validation | Input sanitization |
|
||||
| 50-100 | Standard processing | Pattern masking |
|
||||
| 100-150 | Business logic | Domain-specific rules |
|
||||
| 150-200 | Enrichment | Adding metadata |
|
||||
| 200+ | Cleanup/finalization | Removing temp fields |
|
||||
|
||||
## Configuration Contribution
|
||||
|
||||
Plugins can contribute patterns and field paths that are merged into the processor:
|
||||
|
||||
### Adding Patterns
|
||||
|
||||
```php
|
||||
public function getPatterns(): array
|
||||
{
|
||||
return [
|
||||
'/ACME-\d{8}/' => '[ACME-ORDER]',
|
||||
'/INV-[A-Z]{2}-\d+/' => '[INVOICE]',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Adding Field Paths
|
||||
|
||||
```php
|
||||
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
|
||||
|
||||
public function getFieldPaths(): array
|
||||
{
|
||||
return [
|
||||
// Static replacement
|
||||
'api_key' => FieldMaskConfig::replace('[API_KEY]'),
|
||||
|
||||
// Remove field entirely
|
||||
'internal.debug' => FieldMaskConfig::remove(),
|
||||
|
||||
// Apply regex to field value
|
||||
'user.notes' => FieldMaskConfig::regexMask('/\d{3}-\d{2}-\d{4}/', '[SSN]'),
|
||||
|
||||
// Use processor's global patterns
|
||||
'user.bio' => FieldMaskConfig::useProcessorPatterns(),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Use Case 1: Message Transformation
|
||||
|
||||
Transform messages before masking:
|
||||
|
||||
```php
|
||||
class NormalizePlugin extends AbstractMaskingPlugin
|
||||
{
|
||||
public function getName(): string
|
||||
{
|
||||
return 'normalize-plugin';
|
||||
}
|
||||
|
||||
public function preProcessMessage(string $message): string
|
||||
{
|
||||
// Normalize whitespace before masking
|
||||
return preg_replace('/\s+/', ' ', trim($message));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Use Case 2: Domain-Specific Patterns
|
||||
|
||||
Add patterns for your organization:
|
||||
|
||||
```php
|
||||
class HealthcarePlugin extends AbstractMaskingPlugin
|
||||
{
|
||||
public function getName(): string
|
||||
{
|
||||
return 'healthcare-plugin';
|
||||
}
|
||||
|
||||
public function getPatterns(): array
|
||||
{
|
||||
return [
|
||||
// Medical Record Number
|
||||
'/MRN-\d{10}/' => '[MRN]',
|
||||
// National Provider Identifier
|
||||
'/NPI-\d{10}/' => '[NPI]',
|
||||
// DEA Number
|
||||
'/DEA-[A-Z]{2}\d{7}/' => '[DEA]',
|
||||
];
|
||||
}
|
||||
|
||||
public function getFieldPaths(): array
|
||||
{
|
||||
return [
|
||||
'patient.diagnosis' => FieldMaskConfig::replace('[PHI]'),
|
||||
'patient.medications' => FieldMaskConfig::remove(),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Use Case 3: Context Enrichment
|
||||
|
||||
Add metadata to context:
|
||||
|
||||
```php
|
||||
class AuditPlugin extends AbstractMaskingPlugin
|
||||
{
|
||||
public function getName(): string
|
||||
{
|
||||
return 'audit-plugin';
|
||||
}
|
||||
|
||||
public function __construct(private readonly string $environment)
|
||||
{
|
||||
parent::__construct(priority: 150); // Run late
|
||||
}
|
||||
|
||||
public function postProcessContext(array $context): array
|
||||
{
|
||||
$context['_audit'] = [
|
||||
'processed_at' => date('c'),
|
||||
'environment' => $this->environment,
|
||||
'plugin_version' => '1.0.0',
|
||||
];
|
||||
return $context;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Use Case 4: Conditional Masking
|
||||
|
||||
Apply masking based on conditions:
|
||||
|
||||
```php
|
||||
class EnvironmentAwarePlugin extends AbstractMaskingPlugin
|
||||
{
|
||||
public function getName(): string
|
||||
{
|
||||
return 'environment-aware-plugin';
|
||||
}
|
||||
|
||||
public function preProcessContext(array $context): array
|
||||
{
|
||||
// Only mask in production
|
||||
if (getenv('APP_ENV') !== 'production') {
|
||||
return $context;
|
||||
}
|
||||
|
||||
// Add extra masking for production
|
||||
if (isset($context['debug_info'])) {
|
||||
$context['debug_info'] = '[REDACTED IN PRODUCTION]';
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Use Case 5: External Integration
|
||||
|
||||
Integrate with external services:
|
||||
|
||||
```php
|
||||
class CompliancePlugin extends AbstractMaskingPlugin
|
||||
{
|
||||
public function getName(): string
|
||||
{
|
||||
return 'compliance-plugin';
|
||||
}
|
||||
|
||||
public function __construct(
|
||||
private readonly ComplianceService $service
|
||||
) {
|
||||
parent::__construct(priority: 50);
|
||||
}
|
||||
|
||||
public function postProcessContext(array $context): array
|
||||
{
|
||||
// Log to compliance system
|
||||
$this->service->recordMaskingEvent(
|
||||
fields: array_keys($context),
|
||||
timestamp: new \DateTimeImmutable()
|
||||
);
|
||||
|
||||
return $context;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Keep Plugins Focused
|
||||
|
||||
Each plugin should have a single responsibility:
|
||||
|
||||
```php
|
||||
// Good: Single purpose
|
||||
class EmailPatternPlugin extends AbstractMaskingPlugin { /* ... */ }
|
||||
class PhonePatternPlugin extends AbstractMaskingPlugin { /* ... */ }
|
||||
|
||||
// Avoid: Multiple unrelated responsibilities
|
||||
class EverythingPlugin extends AbstractMaskingPlugin { /* ... */ }
|
||||
```
|
||||
|
||||
### 2. Use Descriptive Names
|
||||
|
||||
Plugin names should be unique and descriptive:
|
||||
|
||||
```php
|
||||
// Good
|
||||
public function getName(): string
|
||||
{
|
||||
return 'acme-healthcare-hipaa-v2';
|
||||
}
|
||||
|
||||
// Avoid
|
||||
public function getName(): string
|
||||
{
|
||||
return 'plugin1';
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Handle Errors Gracefully
|
||||
|
||||
Plugins should not throw exceptions that break logging:
|
||||
|
||||
```php
|
||||
public function preProcessContext(array $context): array
|
||||
{
|
||||
try {
|
||||
// Risky operation
|
||||
$context['processed'] = $this->riskyTransform($context);
|
||||
} catch (\Throwable $e) {
|
||||
// Log error but don't break logging
|
||||
error_log("Plugin error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return $context; // Always return context
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Document Your Patterns
|
||||
|
||||
Add comments explaining pattern purpose:
|
||||
|
||||
```php
|
||||
public function getPatterns(): array
|
||||
{
|
||||
return [
|
||||
// ACME internal order numbers: ACME-YYYYMMDD-NNNN
|
||||
'/ACME-\d{8}-\d{4}/' => '[ORDER-ID]',
|
||||
|
||||
// Employee badges: EMP followed by 6 digits
|
||||
'/EMP\d{6}/' => '[EMPLOYEE]',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Test Your Plugins
|
||||
|
||||
Create comprehensive tests:
|
||||
|
||||
```php
|
||||
class MyPluginTest extends TestCase
|
||||
{
|
||||
public function testPatternMasking(): void
|
||||
{
|
||||
$plugin = new MyPlugin();
|
||||
$patterns = $plugin->getPatterns();
|
||||
|
||||
// Test each pattern
|
||||
foreach ($patterns as $pattern => $replacement) {
|
||||
$this->assertMatchesRegularExpression($pattern, 'INTERNAL-123456');
|
||||
}
|
||||
}
|
||||
|
||||
public function testPreProcessing(): void
|
||||
{
|
||||
$plugin = new MyPlugin();
|
||||
$context = ['sensitive' => 'value'];
|
||||
|
||||
$result = $plugin->preProcessContext($context);
|
||||
|
||||
$this->assertArrayHasKey('sensitive', $result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Consider Performance
|
||||
|
||||
Avoid expensive operations in hooks that run for every log entry:
|
||||
|
||||
```php
|
||||
// Good: Simple operations
|
||||
public function preProcessMessage(string $message): string
|
||||
{
|
||||
return trim($message);
|
||||
}
|
||||
|
||||
// Avoid: Heavy operations for every log
|
||||
public function preProcessMessage(string $message): string
|
||||
{
|
||||
return $this->httpClient->validateMessage($message); // Slow!
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Use Priority Thoughtfully
|
||||
|
||||
Consider how your plugin interacts with others:
|
||||
|
||||
```php
|
||||
// Security validation should run early
|
||||
class SecurityPlugin extends AbstractMaskingPlugin
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(priority: 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata enrichment should run late
|
||||
class MetadataPlugin extends AbstractMaskingPlugin
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(priority: 180);
|
||||
}
|
||||
}
|
||||
```
|
||||
334
docs/psr3-decorator.md
Normal file
334
docs/psr3-decorator.md
Normal file
@@ -0,0 +1,334 @@
|
||||
# PSR-3 Logger Decorator Guide
|
||||
|
||||
This guide explains how to wrap any PSR-3 compatible logger with GDPR masking capabilities.
|
||||
|
||||
## Overview
|
||||
|
||||
The PSR-3 decorator pattern allows you to add GDPR filtering to any logger that implements `Psr\Log\LoggerInterface`, making the library compatible with virtually any PHP logging framework.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Creating a PSR-3 Wrapper
|
||||
|
||||
Here's a simple decorator that wraps any PSR-3 logger:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace YourApp\Logging;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\LogLevel;
|
||||
use Monolog\Level;
|
||||
use Monolog\LogRecord;
|
||||
use DateTimeImmutable;
|
||||
use Stringable;
|
||||
|
||||
class GdprLoggerDecorator implements LoggerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $innerLogger,
|
||||
private readonly GdprProcessor $gdprProcessor
|
||||
) {
|
||||
}
|
||||
|
||||
public function emergency(string|Stringable $message, array $context = []): void
|
||||
{
|
||||
$this->log(LogLevel::EMERGENCY, $message, $context);
|
||||
}
|
||||
|
||||
public function alert(string|Stringable $message, array $context = []): void
|
||||
{
|
||||
$this->log(LogLevel::ALERT, $message, $context);
|
||||
}
|
||||
|
||||
public function critical(string|Stringable $message, array $context = []): void
|
||||
{
|
||||
$this->log(LogLevel::CRITICAL, $message, $context);
|
||||
}
|
||||
|
||||
public function error(string|Stringable $message, array $context = []): void
|
||||
{
|
||||
$this->log(LogLevel::ERROR, $message, $context);
|
||||
}
|
||||
|
||||
public function warning(string|Stringable $message, array $context = []): void
|
||||
{
|
||||
$this->log(LogLevel::WARNING, $message, $context);
|
||||
}
|
||||
|
||||
public function notice(string|Stringable $message, array $context = []): void
|
||||
{
|
||||
$this->log(LogLevel::NOTICE, $message, $context);
|
||||
}
|
||||
|
||||
public function info(string|Stringable $message, array $context = []): void
|
||||
{
|
||||
$this->log(LogLevel::INFO, $message, $context);
|
||||
}
|
||||
|
||||
public function debug(string|Stringable $message, array $context = []): void
|
||||
{
|
||||
$this->log(LogLevel::DEBUG, $message, $context);
|
||||
}
|
||||
|
||||
public function log($level, string|Stringable $message, array $context = []): void
|
||||
{
|
||||
// Convert PSR-3 level to Monolog level
|
||||
$monologLevel = $this->convertLevel($level);
|
||||
|
||||
// Create a Monolog LogRecord for processing
|
||||
$record = new LogRecord(
|
||||
datetime: new DateTimeImmutable(),
|
||||
channel: 'app',
|
||||
level: $monologLevel,
|
||||
message: (string) $message,
|
||||
context: $context
|
||||
);
|
||||
|
||||
// Apply GDPR processing
|
||||
$processedRecord = ($this->gdprProcessor)($record);
|
||||
|
||||
// Pass to inner logger
|
||||
$this->innerLogger->log($level, $processedRecord->message, $processedRecord->context);
|
||||
}
|
||||
|
||||
private function convertLevel(mixed $level): Level
|
||||
{
|
||||
return match ($level) {
|
||||
LogLevel::EMERGENCY => Level::Emergency,
|
||||
LogLevel::ALERT => Level::Alert,
|
||||
LogLevel::CRITICAL => Level::Critical,
|
||||
LogLevel::ERROR => Level::Error,
|
||||
LogLevel::WARNING => Level::Warning,
|
||||
LogLevel::NOTICE => Level::Notice,
|
||||
LogLevel::INFO => Level::Info,
|
||||
LogLevel::DEBUG => Level::Debug,
|
||||
default => Level::Info,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### With Any PSR-3 Logger
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use YourApp\Logging\GdprLoggerDecorator;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Monolog\Logger;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
|
||||
// Your existing PSR-3 logger (could be Monolog, any other, etc.)
|
||||
$existingLogger = new Logger('app');
|
||||
$existingLogger->pushHandler(new StreamHandler('php://stdout'));
|
||||
|
||||
// Create GDPR processor
|
||||
$gdprProcessor = new GdprProcessor([
|
||||
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => '[email]',
|
||||
'/\b\d{3}-\d{2}-\d{4}\b/' => '***-**-****',
|
||||
]);
|
||||
|
||||
// Wrap with GDPR decorator
|
||||
$logger = new GdprLoggerDecorator($existingLogger, $gdprProcessor);
|
||||
|
||||
// Use as normal
|
||||
$logger->info('User john@example.com logged in with SSN 123-45-6789');
|
||||
// Output: User [email] logged in with SSN ***-**-****
|
||||
```
|
||||
|
||||
### With Dependency Injection
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use YourApp\Logging\GdprLoggerDecorator;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class UserService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $logger
|
||||
) {
|
||||
}
|
||||
|
||||
public function createUser(string $email, string $ssn): void
|
||||
{
|
||||
// Log will be automatically GDPR-filtered
|
||||
$this->logger->info("Creating user: {email}, SSN: {ssn}", [
|
||||
'email' => $email,
|
||||
'ssn' => $ssn,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Container configuration (pseudo-code)
|
||||
$container->register(GdprProcessor::class, function () {
|
||||
return new GdprProcessor([
|
||||
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => '[email]',
|
||||
]);
|
||||
});
|
||||
|
||||
$container->register(LoggerInterface::class, function ($container) {
|
||||
return new GdprLoggerDecorator(
|
||||
$container->get('original_logger'),
|
||||
$container->get(GdprProcessor::class)
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Enhanced Decorator with Channel Support
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace YourApp\Logging;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\LogLevel;
|
||||
use Monolog\Level;
|
||||
use Monolog\LogRecord;
|
||||
use DateTimeImmutable;
|
||||
use Stringable;
|
||||
|
||||
class GdprLoggerDecorator implements LoggerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $innerLogger,
|
||||
private readonly GdprProcessor $gdprProcessor,
|
||||
private readonly string $channel = 'app'
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance with a different channel.
|
||||
*/
|
||||
public function withChannel(string $channel): self
|
||||
{
|
||||
return new self($this->innerLogger, $this->gdprProcessor, $channel);
|
||||
}
|
||||
|
||||
public function log($level, string|Stringable $message, array $context = []): void
|
||||
{
|
||||
$record = new LogRecord(
|
||||
datetime: new DateTimeImmutable(),
|
||||
channel: $this->channel,
|
||||
level: $this->convertLevel($level),
|
||||
message: (string) $message,
|
||||
context: $context
|
||||
);
|
||||
|
||||
$processedRecord = ($this->gdprProcessor)($record);
|
||||
|
||||
$this->innerLogger->log($level, $processedRecord->message, $processedRecord->context);
|
||||
}
|
||||
|
||||
// ... other methods remain the same
|
||||
}
|
||||
```
|
||||
|
||||
## Using with Popular Frameworks
|
||||
|
||||
### Laravel
|
||||
|
||||
```php
|
||||
<?php
|
||||
// app/Providers/LoggingServiceProvider.php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Logging\GdprLoggerDecorator;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class LoggingServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->extend(LoggerInterface::class, function ($logger) {
|
||||
$processor = new GdprProcessor(
|
||||
config('gdpr.patterns', [])
|
||||
);
|
||||
|
||||
return new GdprLoggerDecorator($logger, $processor);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Slim Framework
|
||||
|
||||
```php
|
||||
<?php
|
||||
// config/container.php
|
||||
|
||||
use DI\Container;
|
||||
use YourApp\Logging\GdprLoggerDecorator;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Monolog\Logger;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
return [
|
||||
LoggerInterface::class => function (Container $c) {
|
||||
$baseLogger = new Logger('app');
|
||||
$baseLogger->pushHandler(new StreamHandler('logs/app.log'));
|
||||
|
||||
$processor = new GdprProcessor([
|
||||
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => '[email]',
|
||||
]);
|
||||
|
||||
return new GdprLoggerDecorator($baseLogger, $processor);
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
## Testing Your Decorator
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Tests\Logging;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use YourApp\Logging\GdprLoggerDecorator;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\LogLevel;
|
||||
|
||||
class GdprLoggerDecoratorTest extends TestCase
|
||||
{
|
||||
public function testEmailIsMasked(): void
|
||||
{
|
||||
$logs = [];
|
||||
$mockLogger = $this->createMock(LoggerInterface::class);
|
||||
$mockLogger->method('log')
|
||||
->willReturnCallback(function ($level, $message, $context) use (&$logs) {
|
||||
$logs[] = ['level' => $level, 'message' => $message, 'context' => $context];
|
||||
});
|
||||
|
||||
$processor = new GdprProcessor([
|
||||
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => '[email]',
|
||||
]);
|
||||
|
||||
$decorator = new GdprLoggerDecorator($mockLogger, $processor);
|
||||
$decorator->info('Contact: john@example.com');
|
||||
|
||||
$this->assertCount(1, $logs);
|
||||
$this->assertStringContainsString('[email]', $logs[0]['message']);
|
||||
$this->assertStringNotContainsString('john@example.com', $logs[0]['message']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [Symfony Integration](symfony-integration.md)
|
||||
- [Framework Examples](framework-examples.md)
|
||||
264
docs/symfony-integration.md
Normal file
264
docs/symfony-integration.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# Symfony Integration Guide
|
||||
|
||||
This guide explains how to integrate the Monolog GDPR Filter with Symfony applications.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
composer require ivuorinen/monolog-gdpr-filter
|
||||
```
|
||||
|
||||
## Basic Service Configuration
|
||||
|
||||
Add the GDPR processor as a service in `config/services.yaml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
App\Logging\GdprProcessor:
|
||||
class: Ivuorinen\MonologGdprFilter\GdprProcessor
|
||||
arguments:
|
||||
$patterns: '%gdpr.patterns%'
|
||||
$fieldPaths: '%gdpr.field_paths%'
|
||||
$customCallbacks: []
|
||||
$auditLogger: null
|
||||
$maxDepth: 100
|
||||
$dataTypeMasks: []
|
||||
$conditionalRules: []
|
||||
```
|
||||
|
||||
## Parameters Configuration
|
||||
|
||||
Define GDPR patterns in `config/services.yaml` or a dedicated parameters file:
|
||||
|
||||
```yaml
|
||||
parameters:
|
||||
gdpr.patterns:
|
||||
'/\b\d{3}-\d{2}-\d{4}\b/': '***-**-****' # US SSN
|
||||
'/\b[A-Z]{2}\d{2}[A-Z0-9]{4}\d{7}([A-Z0-9]?){0,16}\b/': '****' # IBAN
|
||||
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/': '[email]' # Email
|
||||
'/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/': '****-****-****-****' # Credit Card
|
||||
|
||||
gdpr.field_paths:
|
||||
'user.password': '***REMOVED***'
|
||||
'user.ssn': '***-**-****'
|
||||
'payment.card_number': '****-****-****-****'
|
||||
```
|
||||
|
||||
## Monolog Handler Configuration
|
||||
|
||||
Configure Monolog to use the GDPR processor in `config/packages/monolog.yaml`:
|
||||
|
||||
```yaml
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
channels: ["!event"]
|
||||
formatter: monolog.formatter.json
|
||||
processor: ['@App\Logging\GdprProcessor']
|
||||
|
||||
# For production with file rotation
|
||||
production:
|
||||
type: rotating_file
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: info
|
||||
max_files: 14
|
||||
processor: ['@App\Logging\GdprProcessor']
|
||||
```
|
||||
|
||||
## Environment-Specific Configuration
|
||||
|
||||
Create environment-specific configurations:
|
||||
|
||||
### config/packages/dev/monolog.yaml
|
||||
|
||||
```yaml
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
# In dev, you might want less aggressive masking
|
||||
```
|
||||
|
||||
### config/packages/prod/monolog.yaml
|
||||
|
||||
```yaml
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: fingers_crossed
|
||||
action_level: error
|
||||
handler: nested
|
||||
excluded_http_codes: [404, 405]
|
||||
buffer_size: 50
|
||||
|
||||
nested:
|
||||
type: rotating_file
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: info
|
||||
max_files: 14
|
||||
processor: ['@App\Logging\GdprProcessor']
|
||||
```
|
||||
|
||||
## Advanced Configuration with Audit Logging
|
||||
|
||||
Enable audit logging for compliance tracking:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
App\Logging\AuditLogger:
|
||||
class: Ivuorinen\MonologGdprFilter\RateLimitedAuditLogger
|
||||
arguments:
|
||||
$auditLogger: '@App\Logging\AuditCallback'
|
||||
$maxRequestsPerMinute: 100
|
||||
$windowSeconds: 60
|
||||
|
||||
App\Logging\AuditCallback:
|
||||
class: Closure
|
||||
factory: ['App\Logging\AuditCallbackFactory', 'create']
|
||||
arguments:
|
||||
$logger: '@monolog.logger.audit'
|
||||
|
||||
App\Logging\GdprProcessor:
|
||||
class: Ivuorinen\MonologGdprFilter\GdprProcessor
|
||||
arguments:
|
||||
$patterns: '%gdpr.patterns%'
|
||||
$fieldPaths: '%gdpr.field_paths%'
|
||||
$auditLogger: '@App\Logging\AuditLogger'
|
||||
```
|
||||
|
||||
Create the factory class:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// src/Logging/AuditCallbackFactory.php
|
||||
|
||||
namespace App\Logging;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class AuditCallbackFactory
|
||||
{
|
||||
public static function create(LoggerInterface $logger): callable
|
||||
{
|
||||
return function (string $path, mixed $original, mixed $masked) use ($logger): void {
|
||||
$logger->info('GDPR masking applied', [
|
||||
'path' => $path,
|
||||
'original_type' => gettype($original),
|
||||
'masked_preview' => substr((string) $masked, 0, 20) . '...',
|
||||
]);
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Conditional Masking by Environment
|
||||
|
||||
Apply different masking rules based on log level or channel:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
App\Logging\ConditionalRuleFactory:
|
||||
class: App\Logging\ConditionalRuleFactory
|
||||
|
||||
App\Logging\GdprProcessor:
|
||||
class: Ivuorinen\MonologGdprFilter\GdprProcessor
|
||||
arguments:
|
||||
$conditionalRules:
|
||||
error_only: '@=service("App\\Logging\\ConditionalRuleFactory").createErrorOnlyRule()'
|
||||
```
|
||||
|
||||
```php
|
||||
<?php
|
||||
// src/Logging/ConditionalRuleFactory.php
|
||||
|
||||
namespace App\Logging;
|
||||
|
||||
use Monolog\Level;
|
||||
use Monolog\LogRecord;
|
||||
|
||||
class ConditionalRuleFactory
|
||||
{
|
||||
public function createErrorOnlyRule(): callable
|
||||
{
|
||||
return fn(LogRecord $record): bool =>
|
||||
$record->level->value >= Level::Error->value;
|
||||
}
|
||||
|
||||
public function createChannelRule(array $channels): callable
|
||||
{
|
||||
return fn(LogRecord $record): bool =>
|
||||
in_array($record->channel, $channels, true);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing in Symfony
|
||||
|
||||
Create a test to verify GDPR filtering works:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// tests/Logging/GdprProcessorTest.php
|
||||
|
||||
namespace App\Tests\Logging;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Monolog\Level;
|
||||
use Monolog\LogRecord;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use DateTimeImmutable;
|
||||
|
||||
class GdprProcessorTest extends TestCase
|
||||
{
|
||||
public function testEmailMasking(): void
|
||||
{
|
||||
$processor = new GdprProcessor([
|
||||
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => '[email]',
|
||||
]);
|
||||
|
||||
$record = new LogRecord(
|
||||
datetime: new DateTimeImmutable(),
|
||||
channel: 'test',
|
||||
level: Level::Info,
|
||||
message: 'User logged in: user@example.com',
|
||||
context: []
|
||||
);
|
||||
|
||||
$result = $processor($record);
|
||||
|
||||
$this->assertStringContainsString('[email]', $result->message);
|
||||
$this->assertStringNotContainsString('user@example.com', $result->message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Patterns Not Matching
|
||||
|
||||
1. Verify regex patterns are valid: `preg_match('/your-pattern/', 'test-string')`
|
||||
2. Check pattern escaping in YAML (may need quotes)
|
||||
3. Enable debug mode to see which patterns are applied
|
||||
|
||||
### Performance Issues
|
||||
|
||||
1. Use the rate-limited audit logger
|
||||
2. Consider caching pattern validation results
|
||||
3. Profile with Symfony profiler
|
||||
|
||||
### Memory Issues
|
||||
|
||||
1. Set appropriate `maxDepth` to prevent deep recursion
|
||||
2. Monitor rate limiter statistics
|
||||
3. Use cleanup intervals for long-running processes
|
||||
|
||||
## See Also
|
||||
|
||||
- [PSR-3 Decorator Guide](psr3-decorator.md)
|
||||
- [Framework Examples](framework-examples.md)
|
||||
- [Docker Development](docker-development.md)
|
||||
530
docs/troubleshooting.md
Normal file
530
docs/troubleshooting.md
Normal file
@@ -0,0 +1,530 @@
|
||||
# Troubleshooting Guide
|
||||
|
||||
This guide helps diagnose and resolve common issues with the Monolog GDPR Filter library.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Installation Issues](#installation-issues)
|
||||
- [Pattern Matching Problems](#pattern-matching-problems)
|
||||
- [Performance Issues](#performance-issues)
|
||||
- [Memory Problems](#memory-problems)
|
||||
- [Integration Issues](#integration-issues)
|
||||
- [Audit Logging Issues](#audit-logging-issues)
|
||||
- [Error Messages Reference](#error-messages-reference)
|
||||
|
||||
## Installation Issues
|
||||
|
||||
### Composer Installation Fails
|
||||
|
||||
**Symptom:** `composer require` fails with dependency conflicts.
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Check PHP version
|
||||
php -v # Must be 8.4 or higher
|
||||
|
||||
# Clear Composer cache
|
||||
composer clear-cache
|
||||
|
||||
# Update Composer
|
||||
composer self-update
|
||||
|
||||
# Try again with verbose output
|
||||
composer require ivuorinen/monolog-gdpr-filter -vvv
|
||||
```
|
||||
|
||||
### Class Not Found Errors
|
||||
|
||||
**Symptom:** `Class 'Ivuorinen\MonologGdprFilter\GdprProcessor' not found`
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Regenerate autoloader:
|
||||
|
||||
```bash
|
||||
composer dump-autoload
|
||||
```
|
||||
|
||||
2. Verify installation:
|
||||
|
||||
```bash
|
||||
composer show ivuorinen/monolog-gdpr-filter
|
||||
```
|
||||
|
||||
3. Check namespace in your code:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// Correct
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
|
||||
// Wrong
|
||||
use MonologGdprFilter\GdprProcessor;
|
||||
```
|
||||
|
||||
## Pattern Matching Problems
|
||||
|
||||
### Pattern Not Matching Expected Data
|
||||
|
||||
**Symptom:** Sensitive data is not being masked.
|
||||
|
||||
**Diagnostic steps:**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\PatternValidator;
|
||||
|
||||
$validator = new PatternValidator();
|
||||
$pattern = '/your-pattern-here/';
|
||||
|
||||
// Test 1: Validate pattern syntax
|
||||
$result = $validator->validate($pattern);
|
||||
if (!$result['valid']) {
|
||||
echo "Invalid pattern: " . $result['error'] . "\n";
|
||||
}
|
||||
|
||||
// Test 2: Test pattern directly
|
||||
$testData = 'your test data with sensitive@email.com';
|
||||
if (preg_match($pattern, $testData, $matches)) {
|
||||
echo "Pattern matches: " . print_r($matches, true);
|
||||
} else {
|
||||
echo "Pattern does not match\n";
|
||||
}
|
||||
|
||||
// Test 3: Test with processor
|
||||
$processor = new GdprProcessor([$pattern => '[MASKED]']);
|
||||
$record = [
|
||||
'message' => $testData,
|
||||
'context' => [],
|
||||
'level' => 200,
|
||||
'level_name' => 'INFO',
|
||||
'channel' => 'app',
|
||||
'datetime' => new DateTimeImmutable(),
|
||||
'extra' => [],
|
||||
];
|
||||
|
||||
$result = $processor($record);
|
||||
echo "Result: " . $result['message'] . "\n";
|
||||
```
|
||||
|
||||
### Pattern Matches Too Much
|
||||
|
||||
**Symptom:** Non-sensitive data is being masked.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Add word boundaries:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// Too broad
|
||||
$pattern = '/\d{4}/'; // Matches any 4 digits
|
||||
|
||||
// Better - with boundaries
|
||||
$pattern = '/\b\d{4}\b/'; // Matches standalone 4-digit numbers
|
||||
```
|
||||
|
||||
2. Use more specific patterns:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// Too broad for credit cards
|
||||
$pattern = '/\d{16}/';
|
||||
|
||||
// Better - credit card format
|
||||
$pattern = '/\b(?:\d{4}[-\s]?){3}\d{4}\b/';
|
||||
```
|
||||
|
||||
3. Add negative lookahead/lookbehind:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// Avoid matching dates that look like years
|
||||
$pattern = '/(?<!\d{2}\/)\b\d{4}\b(?!\/\d{2})/';
|
||||
```
|
||||
|
||||
### Special Characters in Patterns
|
||||
|
||||
**Symptom:** Pattern with special characters fails.
|
||||
|
||||
**Solution:** Escape special regex characters:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// Wrong - unescaped special chars
|
||||
$pattern = '/user.name@domain.com/';
|
||||
|
||||
// Correct - escaped dots
|
||||
$pattern = '/user\.name@domain\.com/';
|
||||
|
||||
// Using preg_quote for dynamic patterns
|
||||
$email = 'user.name@domain.com';
|
||||
$pattern = '/' . preg_quote($email, '/') . '/';
|
||||
```
|
||||
|
||||
## Performance Issues
|
||||
|
||||
### Slow Processing
|
||||
|
||||
**Symptom:** Log processing is slower than expected.
|
||||
|
||||
**Diagnostic:**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
$start = microtime(true);
|
||||
for ($i = 0; $i < 1000; $i++) {
|
||||
$processor($record);
|
||||
}
|
||||
$elapsed = microtime(true) - $start;
|
||||
echo "1000 records: {$elapsed}s\n";
|
||||
```
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Reduce pattern count:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// Only include patterns you need
|
||||
$patterns = DefaultPatterns::emails() + DefaultPatterns::creditCards();
|
||||
```
|
||||
|
||||
2. Simplify complex patterns:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// Slow: Complex pattern with many alternatives
|
||||
$slow = '/(january|february|march|april|may|june|july|august|september|october|november|december)/i';
|
||||
|
||||
// Faster: Simpler pattern
|
||||
$fast = '/\b[A-Z][a-z]{2,8}\b/';
|
||||
```
|
||||
|
||||
3. Limit recursion depth:
|
||||
|
||||
```php
|
||||
<?php
|
||||
$processor = new GdprProcessor($patterns, [], [], null, 5); // Max depth 5
|
||||
```
|
||||
|
||||
See [Performance Tuning Guide](performance-tuning.md) for detailed optimization strategies.
|
||||
|
||||
### High CPU Usage
|
||||
|
||||
**Symptom:** Processing causes CPU spikes.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Check for catastrophic backtracking:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// Problematic pattern
|
||||
$bad = '/.*@.*\..*/'; // Can cause backtracking
|
||||
|
||||
// Fixed pattern
|
||||
$good = '/[^@]+@[^.]+\.[a-z]+/i';
|
||||
```
|
||||
|
||||
2. Add pattern timeout (PHP 7.3+):
|
||||
|
||||
```php
|
||||
<?php
|
||||
// Set PCRE backtrack limit
|
||||
ini_set('pcre.backtrack_limit', '100000');
|
||||
```
|
||||
|
||||
## Memory Problems
|
||||
|
||||
### Out of Memory Errors
|
||||
|
||||
**Symptom:** `Allowed memory size exhausted`
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Use streaming for large files:
|
||||
|
||||
```php
|
||||
<?php
|
||||
use Ivuorinen\MonologGdprFilter\Streaming\StreamingProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\MaskingOrchestrator;
|
||||
|
||||
$orchestrator = new MaskingOrchestrator($patterns);
|
||||
$streaming = new StreamingProcessor($orchestrator, chunkSize: 100);
|
||||
|
||||
// Process file without loading entirely into memory
|
||||
$lineParser = fn(string $line): array => ['message' => $line, 'context' => []];
|
||||
foreach ($streaming->processFile($largefile, $lineParser) as $record) {
|
||||
// Process one record at a time
|
||||
}
|
||||
```
|
||||
|
||||
2. Reduce recursion depth:
|
||||
|
||||
```php
|
||||
<?php
|
||||
$processor = new GdprProcessor($patterns, [], [], null, 3);
|
||||
```
|
||||
|
||||
3. Disable audit logging:
|
||||
|
||||
```php
|
||||
<?php
|
||||
$processor = new GdprProcessor($patterns, [], [], null); // No audit logger
|
||||
```
|
||||
|
||||
### Memory Leaks
|
||||
|
||||
**Symptom:** Memory usage grows over time in long-running processes.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Clear caches periodically:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// In long-running workers
|
||||
if ($processedCount % 10000 === 0) {
|
||||
gc_collect_cycles();
|
||||
}
|
||||
```
|
||||
|
||||
2. Use fresh processor instances for batch jobs:
|
||||
|
||||
```php
|
||||
<?php
|
||||
foreach ($batches as $batch) {
|
||||
$processor = new GdprProcessor($patterns); // Fresh instance
|
||||
foreach ($batch as $record) {
|
||||
$processor($record);
|
||||
}
|
||||
unset($processor); // Release memory
|
||||
}
|
||||
```
|
||||
|
||||
## Integration Issues
|
||||
|
||||
### Laravel Integration
|
||||
|
||||
**Symptom:** Processor not being applied to logs.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Verify service provider registration:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// config/app.php
|
||||
'providers' => [
|
||||
Ivuorinen\MonologGdprFilter\Laravel\GdprServiceProvider::class,
|
||||
],
|
||||
```
|
||||
|
||||
2. Check logging configuration:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// config/logging.php
|
||||
'channels' => [
|
||||
'stack' => [
|
||||
'driver' => 'stack',
|
||||
'channels' => ['gdpr'],
|
||||
],
|
||||
'gdpr' => [
|
||||
'driver' => 'single',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'tap' => [GdprLogTap::class],
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
3. Clear config cache:
|
||||
|
||||
```bash
|
||||
php artisan config:clear
|
||||
php artisan cache:clear
|
||||
```
|
||||
|
||||
### Monolog Integration
|
||||
|
||||
**Symptom:** Processor not working with Monolog logger.
|
||||
|
||||
**Solution:** Ensure processor is pushed to logger:
|
||||
|
||||
```php
|
||||
<?php
|
||||
use Monolog\Logger;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
|
||||
$logger = new Logger('app');
|
||||
$logger->pushHandler(new StreamHandler('app.log'));
|
||||
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
|
||||
|
||||
// Test it
|
||||
$logger->info('User email: test@example.com');
|
||||
```
|
||||
|
||||
### Symfony Integration
|
||||
|
||||
See [Symfony Integration Guide](symfony-integration.md) for detailed setup.
|
||||
|
||||
## Audit Logging Issues
|
||||
|
||||
### Audit Logger Not Receiving Events
|
||||
|
||||
**Symptom:** Audit callback never called.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Verify audit logger is set:
|
||||
|
||||
```php
|
||||
<?php
|
||||
$auditLogs = [];
|
||||
$auditLogger = function (string $path, mixed $original, mixed $masked) use (&$auditLogs): void {
|
||||
$auditLogs[] = compact('path', 'original', 'masked');
|
||||
};
|
||||
|
||||
$processor = new GdprProcessor(
|
||||
patterns: $patterns,
|
||||
auditLogger: $auditLogger
|
||||
);
|
||||
```
|
||||
|
||||
2. Verify masking is actually occurring:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// Audit is only called when data is actually masked
|
||||
$record = ['message' => 'No sensitive data here', 'context' => []];
|
||||
// This won't trigger audit because nothing is masked
|
||||
```
|
||||
|
||||
### Rate-Limited Audit Missing Events
|
||||
|
||||
**Symptom:** Some audit events are being dropped.
|
||||
|
||||
**Solution:** Adjust rate limit settings:
|
||||
|
||||
```php
|
||||
<?php
|
||||
use Ivuorinen\MonologGdprFilter\RateLimiter;
|
||||
use Ivuorinen\MonologGdprFilter\RateLimitedAuditLogger;
|
||||
|
||||
$rateLimiter = new RateLimiter(
|
||||
maxEvents: 1000, // Increase limit
|
||||
windowSeconds: 60,
|
||||
burstLimit: 100 // Increase burst
|
||||
);
|
||||
|
||||
$rateLimitedLogger = new RateLimitedAuditLogger($baseLogger, $rateLimiter);
|
||||
```
|
||||
|
||||
## Error Messages Reference
|
||||
|
||||
### InvalidRegexPatternException
|
||||
|
||||
**Message:** `Invalid regex pattern: [pattern]`
|
||||
|
||||
**Cause:** The pattern has invalid regex syntax.
|
||||
|
||||
**Solution:**
|
||||
|
||||
```php
|
||||
<?php
|
||||
// Test pattern before using
|
||||
$pattern = '/[invalid/';
|
||||
if (@preg_match($pattern, '') === false) {
|
||||
echo "Invalid pattern: " . preg_last_error_msg();
|
||||
}
|
||||
```
|
||||
|
||||
### RecursionDepthExceededException
|
||||
|
||||
**Message:** `Maximum recursion depth exceeded`
|
||||
|
||||
**Cause:** Nested data structure exceeds max depth.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
```php
|
||||
<?php
|
||||
// Increase max depth
|
||||
$processor = new GdprProcessor($patterns, [], [], null, 20);
|
||||
|
||||
// Or flatten your data before processing
|
||||
$flatContext = iterator_to_array(
|
||||
new RecursiveIteratorIterator(
|
||||
new RecursiveArrayIterator($context)
|
||||
),
|
||||
false
|
||||
);
|
||||
```
|
||||
|
||||
### MaskingOperationFailedException
|
||||
|
||||
**Message:** `Masking operation failed: [details]`
|
||||
|
||||
**Cause:** An error occurred during masking.
|
||||
|
||||
**Solution:** Enable recovery mode:
|
||||
|
||||
```php
|
||||
<?php
|
||||
use Ivuorinen\MonologGdprFilter\Recovery\FallbackMaskStrategy;
|
||||
use Ivuorinen\MonologGdprFilter\Recovery\FailureMode;
|
||||
|
||||
$fallback = new FallbackMaskStrategy(FailureMode::FAIL_SAFE);
|
||||
// Use with your processor
|
||||
```
|
||||
|
||||
### InvalidConfigurationException
|
||||
|
||||
**Message:** `Invalid configuration: [details]`
|
||||
|
||||
**Cause:** Invalid processor configuration.
|
||||
|
||||
**Solution:** Validate configuration:
|
||||
|
||||
```php
|
||||
<?php
|
||||
use Ivuorinen\MonologGdprFilter\Builder\GdprProcessorBuilder;
|
||||
|
||||
try {
|
||||
$processor = (new GdprProcessorBuilder())
|
||||
->addPattern('/valid-pattern/', '[MASKED]')
|
||||
->build();
|
||||
} catch (InvalidConfigurationException $e) {
|
||||
echo "Configuration error: " . $e->getMessage();
|
||||
}
|
||||
```
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you're still experiencing issues:
|
||||
|
||||
1. **Check the tests:** The test suite contains many usage examples:
|
||||
|
||||
```bash
|
||||
ls tests/
|
||||
```
|
||||
|
||||
2. **Enable debug mode:** Add verbose logging:
|
||||
|
||||
```php
|
||||
<?php
|
||||
$auditLogger = function ($path, $original, $masked): void {
|
||||
error_log("GDPR Mask: $path | $original -> $masked");
|
||||
};
|
||||
```
|
||||
|
||||
3. **Report issues:** Open an issue on GitHub with:
|
||||
- PHP version (`php -v`)
|
||||
- Library version (`composer show ivuorinen/monolog-gdpr-filter`)
|
||||
- Minimal reproduction code
|
||||
- Expected vs actual behavior
|
||||
258
examples/conditional-masking.php
Normal file
258
examples/conditional-masking.php
Normal file
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
/**
|
||||
* Conditional Masking Examples
|
||||
*
|
||||
* This file demonstrates various ways to use conditional masking
|
||||
* to apply GDPR processing only when certain conditions are met.
|
||||
*/
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\ConditionalRuleFactory;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Level;
|
||||
use Monolog\Logger;
|
||||
use Monolog\LogRecord;
|
||||
|
||||
// Example 1: Level-based conditional masking
|
||||
// Only mask sensitive data in ERROR and CRITICAL logs
|
||||
echo "=== Example 1: Level-based Conditional Masking ===\n";
|
||||
|
||||
$levelBasedProcessor = new GdprProcessor(
|
||||
['/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/' => '***EMAIL***'],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
100,
|
||||
[],
|
||||
[
|
||||
'error_levels_only' => ConditionalRuleFactory::createLevelBasedRule(['Error', 'Critical'])
|
||||
]
|
||||
);
|
||||
|
||||
$logger = new Logger('example');
|
||||
$logger->pushHandler(new StreamHandler('php://stdout', Level::Debug));
|
||||
$logger->pushProcessor($levelBasedProcessor);
|
||||
|
||||
$logger->info('User john@example.com logged in successfully'); // Email NOT masked
|
||||
$logger->error('Failed login attempt for admin@company.com'); // Email WILL be masked
|
||||
|
||||
echo "\n";
|
||||
|
||||
// Example 2: Channel-based conditional masking
|
||||
// Only mask data in security and audit channels
|
||||
echo "=== Example 2: Channel-based Conditional Masking ===\n";
|
||||
|
||||
$channelBasedProcessor = new GdprProcessor(
|
||||
['/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/' => '***EMAIL***'],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
100,
|
||||
[],
|
||||
[
|
||||
'security_channels' => ConditionalRuleFactory::createChannelBasedRule(['security', 'audit'])
|
||||
]
|
||||
);
|
||||
|
||||
$securityLogger = new Logger('security');
|
||||
$securityLogger->pushHandler(new StreamHandler('php://stdout', Level::Debug));
|
||||
$securityLogger->pushProcessor($channelBasedProcessor);
|
||||
|
||||
$appLogger = new Logger('application');
|
||||
$appLogger->pushHandler(new StreamHandler('php://stdout', Level::Debug));
|
||||
$appLogger->pushProcessor($channelBasedProcessor);
|
||||
|
||||
$securityLogger->info('Security event: user@example.com accessed admin panel'); // WILL be masked
|
||||
$appLogger->info('Application event: user@example.com placed order'); // NOT masked
|
||||
|
||||
echo "\n";
|
||||
|
||||
// Example 3: Context-based conditional masking
|
||||
// Only mask when specific fields are present in context
|
||||
echo "=== Example 3: Context-based Conditional Masking ===\n";
|
||||
|
||||
$contextBasedProcessor = new GdprProcessor(
|
||||
['/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/' => '***EMAIL***'],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
100,
|
||||
[],
|
||||
[
|
||||
'gdpr_consent_required' => ConditionalRuleFactory::createContextFieldRule('user.gdpr_consent')
|
||||
]
|
||||
);
|
||||
|
||||
$contextLogger = new Logger('context');
|
||||
$contextLogger->pushHandler(new StreamHandler('php://stdout', Level::Debug));
|
||||
$contextLogger->pushProcessor($contextBasedProcessor);
|
||||
|
||||
// This will be masked because gdpr_consent field is present
|
||||
$contextLogger->info('User action performed', [
|
||||
'email' => 'user@example.com',
|
||||
'user' => ['id' => 123, 'gdpr_consent' => true]
|
||||
]);
|
||||
|
||||
// This will NOT be masked because gdpr_consent field is missing
|
||||
$contextLogger->info('System action performed', [
|
||||
'email' => 'system@example.com',
|
||||
'user' => ['id' => 1]
|
||||
]);
|
||||
|
||||
echo "\n";
|
||||
|
||||
// Example 4: Environment-based conditional masking
|
||||
// Only mask in production environment
|
||||
echo "=== Example 4: Environment-based Conditional Masking ===\n";
|
||||
|
||||
$envBasedProcessor = new GdprProcessor(
|
||||
['/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/' => '***EMAIL***'],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
100,
|
||||
[],
|
||||
[
|
||||
'production_only' => ConditionalRuleFactory::createContextValueRule('env', 'production')
|
||||
]
|
||||
);
|
||||
|
||||
$envLogger = new Logger('env');
|
||||
$envLogger->pushHandler(new StreamHandler('php://stdout', Level::Debug));
|
||||
$envLogger->pushProcessor($envBasedProcessor);
|
||||
|
||||
// This will be masked because env=production
|
||||
$envLogger->info('Production log entry', [
|
||||
'email' => 'prod@example.com',
|
||||
'env' => 'production'
|
||||
]);
|
||||
|
||||
// This will NOT be masked because env=development
|
||||
$envLogger->info('Development log entry', [
|
||||
'email' => 'dev@example.com',
|
||||
'env' => 'development'
|
||||
]);
|
||||
|
||||
echo "\n";
|
||||
|
||||
// Example 5: Multiple conditional rules (AND logic)
|
||||
// Only mask when ALL conditions are met
|
||||
echo "=== Example 5: Multiple Conditional Rules ===\n";
|
||||
|
||||
$multiRuleProcessor = new GdprProcessor(
|
||||
['/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/' => '***EMAIL***'],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
100,
|
||||
[],
|
||||
[
|
||||
'error_level' => ConditionalRuleFactory::createLevelBasedRule(['Error', 'Critical']),
|
||||
'production_env' => ConditionalRuleFactory::createContextValueRule('env', 'production'),
|
||||
'security_channel' => ConditionalRuleFactory::createChannelBasedRule(['security'])
|
||||
]
|
||||
);
|
||||
|
||||
$multiLogger = new Logger('security');
|
||||
$multiLogger->pushHandler(new StreamHandler('php://stdout', Level::Debug));
|
||||
$multiLogger->pushProcessor($multiRuleProcessor);
|
||||
|
||||
// This WILL be masked - all conditions met: Error level + production env + security channel
|
||||
$multiLogger->error('Security error in production', [
|
||||
'email' => 'admin@example.com',
|
||||
'env' => 'production'
|
||||
]);
|
||||
|
||||
// This will NOT be masked - wrong level (Info instead of Error)
|
||||
$multiLogger->info('Security info in production', [
|
||||
'email' => 'admin@example.com',
|
||||
'env' => 'production'
|
||||
]);
|
||||
|
||||
echo "\n";
|
||||
|
||||
// Example 6: Custom conditional rule
|
||||
// Create a custom rule based on complex logic
|
||||
echo "=== Example 6: Custom Conditional Rule ===\n";
|
||||
|
||||
$customRule = function (LogRecord $record): bool {
|
||||
// Only mask for high-privilege users (user_id > 1000) during business hours
|
||||
$context = $record->context;
|
||||
$isHighPrivilegeUser = isset($context['user_id']) && $context['user_id'] > 1000;
|
||||
$isBusinessHours = (int)date('H') >= 9 && (int)date('H') <= 17;
|
||||
|
||||
return $isHighPrivilegeUser && $isBusinessHours;
|
||||
};
|
||||
|
||||
$customProcessor = new GdprProcessor(
|
||||
['/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/' => '***EMAIL***'],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
100,
|
||||
[],
|
||||
[
|
||||
'high_privilege_business_hours' => $customRule
|
||||
]
|
||||
);
|
||||
|
||||
$customLogger = new Logger('custom');
|
||||
$customLogger->pushHandler(new StreamHandler('php://stdout', Level::Debug));
|
||||
$customLogger->pushProcessor($customProcessor);
|
||||
|
||||
// This will be masked if user_id > 1000 AND it's business hours
|
||||
$customLogger->info('High privilege user action', [
|
||||
'email' => 'admin@example.com',
|
||||
'user_id' => 1001,
|
||||
'action' => 'delete_user'
|
||||
]);
|
||||
|
||||
// This will NOT be masked (user_id <= 1000)
|
||||
$customLogger->info('Regular user action', [
|
||||
'email' => 'user@example.com',
|
||||
'user_id' => 500,
|
||||
'action' => 'view_profile'
|
||||
]);
|
||||
|
||||
echo "\n";
|
||||
|
||||
// Example 7: Combining conditional masking with data type masking
|
||||
echo "=== Example 7: Conditional + Data Type Masking ===\n";
|
||||
|
||||
$combinedProcessor = new GdprProcessor(
|
||||
['/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/' => '***EMAIL***'],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
100,
|
||||
[
|
||||
'integer' => '***INT***',
|
||||
'string' => '***STRING***'
|
||||
],
|
||||
[
|
||||
'error_level' => ConditionalRuleFactory::createLevelBasedRule(['Error'])
|
||||
]
|
||||
);
|
||||
|
||||
$combinedLogger = new Logger('combined');
|
||||
$combinedLogger->pushHandler(new StreamHandler('php://stdout', Level::Debug));
|
||||
$combinedLogger->pushProcessor($combinedProcessor);
|
||||
|
||||
// ERROR level: both regex patterns AND data type masking will be applied
|
||||
$combinedLogger->error('Error occurred', [
|
||||
'email' => 'error@example.com', // Will be masked by regex
|
||||
'user_id' => 12345, // Will be masked by data type rule
|
||||
'message' => 'Something went wrong' // Will be masked by data type rule
|
||||
]);
|
||||
|
||||
// INFO level: no masking will be applied due to conditional rule
|
||||
$combinedLogger->info('Info message', [
|
||||
'email' => 'info@example.com', // Will NOT be masked
|
||||
'user_id' => 67890, // Will NOT be masked
|
||||
'message' => 'Everything is fine' // Will NOT be masked
|
||||
]);
|
||||
|
||||
echo "\nConditional masking examples completed.\n";
|
||||
417
examples/laravel-integration.md
Normal file
417
examples/laravel-integration.md
Normal file
@@ -0,0 +1,417 @@
|
||||
# Laravel Integration Examples
|
||||
|
||||
This document provides comprehensive examples for integrating the Monolog GDPR Filter with Laravel applications.
|
||||
|
||||
## Installation and Setup
|
||||
|
||||
### 1. Install the Package
|
||||
|
||||
```bash
|
||||
composer require ivuorinen/monolog-gdpr-filter
|
||||
```
|
||||
|
||||
### 2. Register the Service Provider
|
||||
|
||||
Add the service provider to your `config/app.php`:
|
||||
|
||||
```php
|
||||
'providers' => [
|
||||
// Other providers...
|
||||
Ivuorinen\MonologGdprFilter\Laravel\GdprServiceProvider::class,
|
||||
],
|
||||
```
|
||||
|
||||
### 3. Add the Facade (Optional)
|
||||
|
||||
```php
|
||||
'aliases' => [
|
||||
// Other aliases...
|
||||
'Gdpr' => Ivuorinen\MonologGdprFilter\Laravel\Facades\Gdpr::class,
|
||||
],
|
||||
```
|
||||
|
||||
### 4. Publish the Configuration
|
||||
|
||||
```bash
|
||||
php artisan vendor:publish --tag=gdpr-config
|
||||
```
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
### Basic Configuration (`config/gdpr.php`)
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
return [
|
||||
'auto_register' => true,
|
||||
'channels' => ['single', 'daily', 'stack'],
|
||||
|
||||
'field_paths' => [
|
||||
'user.email' => '', // Mask with regex
|
||||
'user.ssn' => GdprProcessor::removeField(),
|
||||
'payment.card_number' => GdprProcessor::replaceWith('[CARD]'),
|
||||
'request.password' => GdprProcessor::removeField(),
|
||||
],
|
||||
|
||||
'custom_callbacks' => [
|
||||
'user.ip' => fn($value) => hash('sha256', $value), // Hash IPs
|
||||
'user.name' => fn($value) => strtoupper($value), // Transform names
|
||||
],
|
||||
|
||||
'max_depth' => 100,
|
||||
|
||||
'audit_logging' => [
|
||||
'enabled' => env('GDPR_AUDIT_ENABLED', false),
|
||||
'channel' => 'gdpr-audit',
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
|
||||
return [
|
||||
'patterns' => [
|
||||
// Custom patterns for your application
|
||||
'/\binternal-id-\d+\b/' => '***INTERNAL***',
|
||||
'/\bcustomer-\d{6}\b/' => '***CUSTOMER***',
|
||||
],
|
||||
|
||||
'field_paths' => [
|
||||
// User data
|
||||
'user.email' => '',
|
||||
'user.phone' => GdprProcessor::replaceWith('[PHONE]'),
|
||||
'user.address' => GdprProcessor::removeField(),
|
||||
|
||||
// Payment data
|
||||
'payment.card_number' => GdprProcessor::replaceWith('[CARD]'),
|
||||
'payment.cvv' => GdprProcessor::removeField(),
|
||||
'payment.account_number' => GdprProcessor::replaceWith('[ACCOUNT]'),
|
||||
|
||||
// Request data
|
||||
'request.password' => GdprProcessor::removeField(),
|
||||
'request.token' => GdprProcessor::replaceWith('[TOKEN]'),
|
||||
'headers.authorization' => GdprProcessor::replaceWith('[AUTH]'),
|
||||
],
|
||||
|
||||
'custom_callbacks' => [
|
||||
// Hash sensitive identifiers
|
||||
'user.ip' => fn($ip) => 'ip_' . substr(hash('sha256', $ip), 0, 8),
|
||||
'session.id' => fn($id) => 'sess_' . substr(hash('sha256', $id), 0, 12),
|
||||
|
||||
// Mask parts of identifiers
|
||||
'user.username' => function($username) {
|
||||
if (strlen($username) <= 3) return '***';
|
||||
return substr($username, 0, 2) . str_repeat('*', strlen($username) - 2);
|
||||
},
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### 1. Using the Facade
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Laravel\Facades\Gdpr;
|
||||
|
||||
// Mask a message directly
|
||||
$maskedMessage = Gdpr::regExpMessage('Contact john.doe@example.com for details');
|
||||
// Result: "Contact ***EMAIL*** for details"
|
||||
|
||||
// Get default patterns
|
||||
$patterns = Gdpr::getDefaultPatterns();
|
||||
|
||||
// Test pattern validation
|
||||
try {
|
||||
Gdpr::validatePatterns(['/\btest\b/' => '***TEST***']);
|
||||
echo "Pattern is valid!";
|
||||
} catch (InvalidArgumentException $e) {
|
||||
echo "Pattern error: " . $e->getMessage();
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Manual Integration with Specific Channels
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// In a service provider or middleware
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
|
||||
$processor = app('gdpr.processor');
|
||||
|
||||
// Add to specific channel
|
||||
Log::channel('api')->pushProcessor($processor);
|
||||
Log::channel('audit')->pushProcessor($processor);
|
||||
```
|
||||
|
||||
### 3. Custom Logging with GDPR Protection
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class UserService
|
||||
{
|
||||
public function createUser(array $userData)
|
||||
{
|
||||
// This will automatically be GDPR filtered
|
||||
Log::info('Creating user', [
|
||||
'user_data' => $userData, // Contains email, phone, etc.
|
||||
'request_ip' => request()->ip(),
|
||||
'timestamp' => now(),
|
||||
]);
|
||||
|
||||
// User creation logic...
|
||||
}
|
||||
|
||||
public function loginAttempt(string $email, bool $success)
|
||||
{
|
||||
Log::info('Login attempt', [
|
||||
'email' => $email, // Will be masked
|
||||
'success' => $success,
|
||||
'ip' => request()->ip(), // Will be hashed if configured
|
||||
'user_agent' => request()->userAgent(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Artisan Commands
|
||||
|
||||
### Test Regex Patterns
|
||||
|
||||
```bash
|
||||
# Test a pattern against sample data
|
||||
php artisan gdpr:test-pattern '/\b\d{3}-\d{2}-\d{4}\b/' '***SSN***' '123-45-6789'
|
||||
|
||||
# With validation
|
||||
php artisan gdpr:test-pattern '/\b\d{16}\b/' '***CARD***' '4111111111111111' --validate
|
||||
```
|
||||
|
||||
### Debug Configuration
|
||||
|
||||
```bash
|
||||
# Show current configuration
|
||||
php artisan gdpr:debug --show-config
|
||||
|
||||
# Show all patterns
|
||||
php artisan gdpr:debug --show-patterns
|
||||
|
||||
# Test with sample data
|
||||
php artisan gdpr:debug \
|
||||
--test-data='{
|
||||
"message":"Email: test@example.com", "context":{"user":{"email":"user@example.com"}}
|
||||
}'
|
||||
```
|
||||
|
||||
## Middleware Integration
|
||||
|
||||
### HTTP Request/Response Logging
|
||||
|
||||
Register the middleware in `app/Http/Kernel.php`:
|
||||
|
||||
```php
|
||||
protected $middleware = [
|
||||
// Other middleware...
|
||||
\Ivuorinen\MonologGdprFilter\Laravel\Middleware\GdprLogMiddleware::class,
|
||||
];
|
||||
```
|
||||
|
||||
Or apply to specific routes:
|
||||
|
||||
```php
|
||||
Route::middleware(['gdpr.log'])->group(function () {
|
||||
Route::post('/api/users', [UserController::class, 'store']);
|
||||
Route::put('/api/users/{id}', [UserController::class, 'update']);
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Middleware Example
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Ivuorinen\MonologGdprFilter\Laravel\Facades\Gdpr;
|
||||
|
||||
class ApiRequestLogger
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
|
||||
// Log request
|
||||
Log::info('API Request', [
|
||||
'method' => $request->method(),
|
||||
'url' => $request->fullUrl(),
|
||||
'headers' => $request->headers->all(),
|
||||
'body' => $request->all(),
|
||||
]);
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
// Log response
|
||||
Log::info('API Response', [
|
||||
'status' => $response->getStatusCode(),
|
||||
'duration' => round((microtime(true) - $startTime) * 1000, 2),
|
||||
'memory' => memory_get_peak_usage(true),
|
||||
]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Testing with GDPR
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Tests\TestCase;
|
||||
use Ivuorinen\MonologGdprFilter\Laravel\Facades\Gdpr;
|
||||
|
||||
class GdprTest extends TestCase
|
||||
{
|
||||
public function test_email_masking()
|
||||
{
|
||||
$result = Gdpr::regExpMessage('Contact john@example.com');
|
||||
$this->assertStringContains('***EMAIL***', $result);
|
||||
}
|
||||
|
||||
public function test_custom_pattern()
|
||||
{
|
||||
$processor = new GdprProcessor([
|
||||
'/\bcustomer-\d+\b/' => '***CUSTOMER***'
|
||||
]);
|
||||
|
||||
$result = $processor->regExpMessage('Order for customer-12345');
|
||||
$this->assertEquals('Order for ***CUSTOMER***', $result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Tests\TestCase;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class GdprLoggingTest extends TestCase
|
||||
{
|
||||
public function test_user_creation_logging()
|
||||
{
|
||||
Log::shouldReceive('info')
|
||||
->once()
|
||||
->with('Creating user', \Mockery::on(function ($context) {
|
||||
// Verify that email is masked
|
||||
return str_contains($context['user_data']['email'], '***EMAIL***');
|
||||
}));
|
||||
|
||||
$response = $this->postJson('/api/users', [
|
||||
'name' => 'John Doe',
|
||||
'email' => 'john@example.com',
|
||||
'phone' => '+1234567890',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Optimize for Large Applications
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// config/gdpr.php
|
||||
return [
|
||||
'performance' => [
|
||||
'chunk_size' => 500, // Smaller chunks for memory-constrained environments
|
||||
'garbage_collection_threshold' => 5000, // More frequent GC
|
||||
],
|
||||
|
||||
// Use more specific patterns to reduce processing time
|
||||
'patterns' => [
|
||||
'/\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b/' => '***EMAIL***',
|
||||
'/\b\d{3}-\d{2}-\d{4}\b/' => '***SSN***',
|
||||
// Avoid overly broad patterns
|
||||
],
|
||||
|
||||
// Prefer field paths over regex for known locations
|
||||
'field_paths' => [
|
||||
'user.email' => '',
|
||||
'request.email' => '',
|
||||
'customer.email_address' => '',
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
### Channel-Specific Configuration
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// Apply GDPR only to specific channels
|
||||
'channels' => [
|
||||
'single', // Local development
|
||||
'daily', // Production file logs
|
||||
'database', // Database logging
|
||||
// Skip 'stderr' for performance-critical error logging
|
||||
],
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **GDPR not working**: Check if auto_register is true and channels are correctly configured
|
||||
2. **Performance issues**: Reduce pattern count, use field_paths instead of regex
|
||||
3. **Over-masking**: Make patterns more specific, check pattern order
|
||||
4. **Memory issues**: Reduce chunk_size and garbage_collection_threshold
|
||||
|
||||
### Debug Steps
|
||||
|
||||
```bash
|
||||
# Check configuration
|
||||
php artisan gdpr:debug --show-config
|
||||
|
||||
# Test patterns
|
||||
php artisan gdpr:test-pattern '/your-pattern/' '***MASKED***' 'test-string'
|
||||
|
||||
# View current patterns
|
||||
php artisan gdpr:debug --show-patterns
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use field paths over regex** when you know the exact location of sensitive data
|
||||
2. **Test patterns thoroughly** before deploying to production
|
||||
3. **Monitor performance** with large datasets
|
||||
4. **Use audit logging** for compliance requirements
|
||||
5. **Regularly review patterns** to ensure they're not over-masking
|
||||
6. **Consider data retention** policies for logged data
|
||||
174
examples/rate-limiting.php
Normal file
174
examples/rate-limiting.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Rate Limiting for Audit Logging Examples
|
||||
*
|
||||
* This file demonstrates how to use rate limiting to prevent
|
||||
* audit log flooding while maintaining system performance.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\RateLimitedAuditLogger;
|
||||
use Monolog\Logger;
|
||||
use Monolog\LogRecord;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Level;
|
||||
|
||||
// Example 1: Basic Rate-Limited Audit Logging
|
||||
echo "=== Example 1: Basic Rate-Limited Audit Logging ===\n";
|
||||
|
||||
$auditLogs = [];
|
||||
$baseAuditLogger = function (string $path, mixed $original, mixed $masked) use (&$auditLogs): void {
|
||||
$auditLogs[] = [
|
||||
'timestamp' => date('Y-m-d H:i:s'),
|
||||
'path' => $path,
|
||||
'original' => $original,
|
||||
'masked' => $masked
|
||||
];
|
||||
echo sprintf('AUDIT: %s - %s -> %s%s', $path, $original, $masked, PHP_EOL);
|
||||
};
|
||||
|
||||
// Wrap with rate limiting (100 per minute by default)
|
||||
$rateLimitedLogger = new RateLimitedAuditLogger($baseAuditLogger, 5, 60); // 5 per minute for demo
|
||||
|
||||
$processor = new GdprProcessor(
|
||||
['/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/' => '***EMAIL***'],
|
||||
['user.email' => 'masked@example.com'],
|
||||
[],
|
||||
$rateLimitedLogger
|
||||
);
|
||||
|
||||
$logger = new Logger('rate-limited');
|
||||
$logger->pushHandler(new StreamHandler('php://stdout', Level::Debug));
|
||||
$logger->pushProcessor($processor);
|
||||
|
||||
// Simulate high-volume logging that would exceed rate limits
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$logger->info('User activity', [
|
||||
'user' => ['email' => sprintf('user%d@example.com', $i)],
|
||||
'action' => 'login'
|
||||
]);
|
||||
}
|
||||
|
||||
echo "\nTotal audit logs: " . count($auditLogs) . "\n";
|
||||
echo "Expected: 5 regular logs + rate limit warnings\n\n";
|
||||
|
||||
// Example 2: Using Predefined Rate Limiting Profiles
|
||||
echo "=== Example 2: Rate Limiting Profiles ===\n";
|
||||
|
||||
$auditLogs2 = [];
|
||||
/** @psalm-suppress DeprecatedMethod - Example demonstrates deprecated factory method */
|
||||
$baseLogger2 = GdprProcessor::createArrayAuditLogger($auditLogs2, false);
|
||||
|
||||
// Available profiles: 'strict', 'default', 'relaxed', 'testing'
|
||||
$strictLogger = RateLimitedAuditLogger::create($baseLogger2, 'strict'); // 50/min
|
||||
$relaxedLogger = RateLimitedAuditLogger::create($baseLogger2, 'relaxed'); // 200/min
|
||||
$testingLogger = RateLimitedAuditLogger::create($baseLogger2, 'testing'); // 1000/min
|
||||
|
||||
echo "Strict profile: " . ($strictLogger->isOperationAllowed('general_operations')
|
||||
? 'Available' : 'Rate limited') . "\n";
|
||||
echo "Relaxed profile: " . ($relaxedLogger->isOperationAllowed('general_operations')
|
||||
? 'Available' : 'Rate limited') . "\n";
|
||||
echo "Testing profile: " . ($testingLogger->isOperationAllowed('general_operations')
|
||||
? 'Available' : 'Rate limited') . "\n\n";
|
||||
|
||||
// Example 3: Using GdprProcessor Helper Methods
|
||||
echo "=== Example 3: GdprProcessor Helper Methods ===\n";
|
||||
|
||||
$auditLogs3 = [];
|
||||
// Create rate-limited logger using GdprProcessor helper
|
||||
/** @psalm-suppress DeprecatedMethod - Example demonstrates deprecated factory methods */
|
||||
$rateLimitedAuditLogger = GdprProcessor::createRateLimitedAuditLogger(
|
||||
GdprProcessor::createArrayAuditLogger($auditLogs3, false),
|
||||
'default'
|
||||
);
|
||||
|
||||
$processor3 = new GdprProcessor(
|
||||
['/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/' => '***EMAIL***'],
|
||||
['sensitive_data' => '***REDACTED***'],
|
||||
[],
|
||||
$rateLimitedAuditLogger
|
||||
);
|
||||
|
||||
// Process some logs
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$logRecord = new LogRecord(
|
||||
new DateTimeImmutable(),
|
||||
'app',
|
||||
Level::Info,
|
||||
sprintf('Processing user%d@example.com', $i),
|
||||
['sensitive_data' => 'secret_value_' . $i]
|
||||
);
|
||||
|
||||
$result = $processor3($logRecord);
|
||||
echo "Processed: " . $result->message . "\n";
|
||||
}
|
||||
|
||||
echo "Audit logs generated: " . count($auditLogs3) . "\n\n";
|
||||
|
||||
// Example 4: Rate Limit Statistics and Monitoring
|
||||
echo "=== Example 4: Rate Limit Statistics ===\n";
|
||||
|
||||
$rateLimitedLogger4 = new RateLimitedAuditLogger($baseAuditLogger, 10, 60);
|
||||
|
||||
// Generate some activity
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$rateLimitedLogger4('test_operation', 'value_' . $i, 'masked_' . $i);
|
||||
}
|
||||
|
||||
// Check statistics
|
||||
$stats = $rateLimitedLogger4->getRateLimitStats();
|
||||
echo "Rate Limit Statistics:\n";
|
||||
foreach ($stats as $operationType => $stat) {
|
||||
if ($stat['current_requests'] > 0) {
|
||||
echo " {$operationType}:\n";
|
||||
echo sprintf(' Current requests: %d%s', $stat['current_requests'], PHP_EOL);
|
||||
echo sprintf(' Remaining requests: %d%s', $stat['remaining_requests'], PHP_EOL);
|
||||
echo " Time until reset: {$stat['time_until_reset']} seconds\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
|
||||
// Example 5: Different Operation Types
|
||||
echo "=== Example 5: Operation Type Classification ===\n";
|
||||
|
||||
$rateLimitedLogger5 = new RateLimitedAuditLogger($baseAuditLogger, 2, 60); // Very restrictive
|
||||
|
||||
echo "Testing different operation types (2 per minute limit):\n";
|
||||
|
||||
// These will be classified into different operation types
|
||||
$rateLimitedLogger5('json_masked', '{"key": "value"}', '{"key": "***MASKED***"}');
|
||||
$rateLimitedLogger5('conditional_skip', 'skip_reason', 'Level not matched');
|
||||
$rateLimitedLogger5('regex_error', '/invalid[/', 'Pattern compilation failed');
|
||||
$rateLimitedLogger5('preg_replace_error', 'input', 'PCRE error occurred');
|
||||
|
||||
// Try to exceed limits for each type
|
||||
echo "\nTesting rate limiting per operation type:\n";
|
||||
$rateLimitedLogger5('json_encode_error', 'data', 'JSON encoding failed'); // json_operations
|
||||
$rateLimitedLogger5('json_decode_error', 'data', 'JSON decoding failed'); // json_operations (should be limited)
|
||||
$rateLimitedLogger5('conditional_error', 'rule', 'Rule evaluation failed'); // conditional_operations
|
||||
$rateLimitedLogger5('regex_validation', 'pattern', 'Pattern is invalid'); // regex_operations
|
||||
|
||||
echo "\nOperation type stats:\n";
|
||||
$stats5 = $rateLimitedLogger5->getRateLimitStats();
|
||||
foreach ($stats5 as $type => $stat) {
|
||||
if ($stat['current_requests'] > 0) {
|
||||
$current = $stat['current_requests'];
|
||||
$all = $stat['current_requests'] + $stat['remaining_requests'];
|
||||
echo " {$type}: {$current}/{$all} used\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n=== Rate Limiting Examples Completed ===\n";
|
||||
echo "\nKey Benefits:\n";
|
||||
echo "• Prevents audit log flooding during high-volume operations\n";
|
||||
echo "• Maintains system performance by limiting resource usage\n";
|
||||
echo "• Provides configurable rate limits for different environments\n";
|
||||
echo "• Separate rate limits for different operation types\n";
|
||||
echo "• Built-in statistics and monitoring capabilities\n";
|
||||
echo "• Graceful degradation with rate limit warnings\n";
|
||||
12
phpcs.xml
12
phpcs.xml
@@ -1,9 +1,13 @@
|
||||
<?xml version="1.0"?>
|
||||
<ruleset name="PSR12"
|
||||
<?xml version="1.0" ?>
|
||||
<ruleset
|
||||
name="PSR12"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/squizlabs/php_codesniffer/phpcs.xsd">
|
||||
xsi:noNamespaceSchemaLocation="vendor/squizlabs/php_codesniffer/phpcs.xsd"
|
||||
>
|
||||
<description>PHP_CodeSniffer configuration for PSR-12 coding standard.</description>
|
||||
<rule ref="PSR12" />
|
||||
<rule ref="PSR12">
|
||||
<exclude name="Squiz.WhiteSpace.ScopeClosingBrace.ContentBefore" />
|
||||
</rule>
|
||||
<file>src/</file>
|
||||
<file>tests/</file>
|
||||
<file>rector.php</file>
|
||||
|
||||
109
phpstan.neon
Normal file
109
phpstan.neon
Normal file
@@ -0,0 +1,109 @@
|
||||
includes: []
|
||||
|
||||
parameters:
|
||||
level: 6
|
||||
paths:
|
||||
- src
|
||||
- tests
|
||||
- examples
|
||||
- config
|
||||
|
||||
# Conservative settings
|
||||
reportUnmatchedIgnoredErrors: false
|
||||
treatPhpDocTypesAsCertain: false
|
||||
|
||||
# Ignore specific patterns that are acceptable
|
||||
ignoreErrors:
|
||||
# Allow mixed types for backward compatibility
|
||||
- '#Parameter \#\d+ \$\w+ of method .* expects .*, mixed given#'
|
||||
- '#Method .* return type has no value type specified in iterable type array#'
|
||||
- '#Property .* type has no value type specified in iterable type array#'
|
||||
|
||||
# Allow callable types validated at runtime
|
||||
- '#Cannot call callable .* on .* type callable#'
|
||||
- '#Parameter \#\d+ .* expects callable.*: callable given#'
|
||||
|
||||
# Allow reflection patterns in tests
|
||||
- '#Call to method .* on an unknown class ReflectionClass#'
|
||||
- '#Access to an undefined property ReflectionClass::\$.*#'
|
||||
- '#Call to an undefined method ReflectionMethod::.*#'
|
||||
|
||||
# Allow PHPUnit patterns
|
||||
- '#Call to an undefined method PHPUnit\\Framework\\.*::(assert.*|expect.*)#'
|
||||
- '#Parameter \#\d+ \$.*Test::.* expects .*, .* given#'
|
||||
|
||||
# Allow Laravel function calls
|
||||
- '#Function config not found#'
|
||||
- '#Function app not found#'
|
||||
- '#Function now not found#'
|
||||
- '#Function config_path not found#'
|
||||
- '#Function env not found#'
|
||||
|
||||
# Allow configuration array access patterns
|
||||
- '#Offset .* does not exist on array#'
|
||||
- '#Cannot access offset .* on mixed#'
|
||||
|
||||
# Allow intentional mixed usage in flexible APIs
|
||||
- '#Argument of an invalid type mixed supplied for foreach#'
|
||||
- '#Parameter \#\d+ .* expects .*, mixed given#'
|
||||
- '#Cannot call method .* on mixed#'
|
||||
|
||||
# Allow string manipulation patterns
|
||||
- '#Binary operation .* between .* and .* results in an error#'
|
||||
|
||||
# Allow test-specific patterns
|
||||
- '#Call to function not_callable#'
|
||||
- '#Method DateTimeImmutable::offsetGet\(\) invoked with \d+ parameter#'
|
||||
|
||||
# Allow complex return types in GdprProcessor
|
||||
- '#Method Ivuorinen\\MonologGdprFilter\\GdprProcessor::getDefaultPatterns\(\) should return array.* but returns array.*#'
|
||||
|
||||
# Allow intentional validation test failures
|
||||
- '#Parameter .* of (method|class) Ivuorinen\\MonologGdprFilter\\(GdprProcessor|RateLimitedAuditLogger).*(constructor|__construct).* expects .*, .* given#'
|
||||
- '#Parameter \#1 \$patterns of static method Ivuorinen\\MonologGdprFilter\\InputValidator::validatePatterns\(\) expects array<string, string>, array.* given#'
|
||||
- '#Parameter \#1 \$fieldPaths of static method Ivuorinen\\MonologGdprFilter\\InputValidator::validateFieldPaths\(\) expects .*, array.* given#'
|
||||
- '#Parameter \#1 \$customCallbacks of static method Ivuorinen\\MonologGdprFilter\\InputValidator::validateCustomCallbacks\(\) expects .*, array.* given#'
|
||||
- '#Parameter \#1 \$auditLogger of static method Ivuorinen\\MonologGdprFilter\\InputValidator::validateAuditLogger\(\) expects .*, .* given#'
|
||||
- '#Parameter \#1 \$dataTypeMasks of static method Ivuorinen\\MonologGdprFilter\\InputValidator::validateDataTypeMasks\(\) expects array<string, string>, array.* given#'
|
||||
- '#Parameter \#1 \$conditionalRules of static method Ivuorinen\\MonologGdprFilter\\InputValidator::validateConditionalRules\(\) expects .*, array.* given#'
|
||||
- '#Parameter \#1 \$typeMasks of class Ivuorinen\\MonologGdprFilter\\Strategies\\DataTypeMaskingStrategy constructor expects array<string, string>, array.* given#'
|
||||
- '#Parameter \#1 \$fieldConfigs of class Ivuorinen\\MonologGdprFilter\\Strategies\\FieldPathMaskingStrategy constructor expects .*, array.* given#'
|
||||
|
||||
# Allow test helper methods in anonymous classes (AbstractMaskingStrategyTest)
|
||||
- '#Call to an undefined method Ivuorinen\\MonologGdprFilter\\Strategies\\AbstractMaskingStrategy::test.*#'
|
||||
- '#Method Ivuorinen\\MonologGdprFilter\\Strategies\\AbstractMaskingStrategy@anonymous/.* has parameter .* with no value type specified in iterable type array#'
|
||||
|
||||
# Allow test assertions that intentionally validate known types
|
||||
- '#Call to method PHPUnit\\Framework\\Assert::(assertIsArray|assertIsInt|assertTrue|assertContainsOnlyInstancesOf)\(\) .* will always evaluate to true#'
|
||||
- '#Call to method PHPUnit\\Framework\\Assert::(assertIsString|assertIsFloat|assertIsBool)\(\) with .* will always evaluate to true#'
|
||||
|
||||
# Allow PHPUnit attributes with named arguments
|
||||
- '#Attribute class PHPUnit\\Framework\\Attributes\\.*#'
|
||||
|
||||
# Allow intentional static method calls in tests
|
||||
- '#Static call to instance method#'
|
||||
- '#Method .* invoked with \d+ parameter.*, \d+ required#'
|
||||
|
||||
# Allow nullsafe operator usage
|
||||
- '#Using nullsafe method call on non-nullable type#'
|
||||
|
||||
# Allow unused test constants (used by trait)
|
||||
- '#Constant Tests\\.*::.* is unused#'
|
||||
|
||||
# PHP version for analysis
|
||||
phpVersion: 80400
|
||||
|
||||
# Stub files for missing functions/classes
|
||||
stubFiles: []
|
||||
|
||||
# Bootstrap files
|
||||
bootstrapFiles: []
|
||||
|
||||
# Exclude analysis paths
|
||||
excludePaths:
|
||||
- vendor/*
|
||||
- .phpunit.cache/*
|
||||
- src/Laravel/*
|
||||
|
||||
# Custom rules (none for now)
|
||||
customRulesetUsed: false
|
||||
@@ -21,8 +21,4 @@
|
||||
</source>
|
||||
|
||||
<coverage/>
|
||||
|
||||
<php>
|
||||
<ini name="xdebug.mode" value="coverage"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
||||
137
psalm.xml
137
psalm.xml
@@ -1,23 +1,138 @@
|
||||
<?xml version="1.0"?>
|
||||
<?xml version="1.0" ?>
|
||||
<psalm
|
||||
errorLevel="3"
|
||||
errorLevel="5"
|
||||
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"
|
||||
phpVersion="8.4"
|
||||
noCache="false"
|
||||
findUnusedPsalmSuppress="true"
|
||||
skipChecksOnUnresolvableIncludes="true"
|
||||
allowPhpStormGenerics="true"
|
||||
allowStringToStandInForClass="true"
|
||||
memoizeMethodCallResults="true"
|
||||
hoistConstants="true"
|
||||
addParamTypehint="false"
|
||||
checkForThrowsDocblock="false"
|
||||
checkForThrowsInGlobalScope="false"
|
||||
sealAllMethods="false"
|
||||
sealAllProperties="false"
|
||||
>
|
||||
<projectFiles>
|
||||
<directory name="src"/>
|
||||
<directory name="src" />
|
||||
<directory name="examples" />
|
||||
<directory name="config" />
|
||||
<directory name="tests" />
|
||||
<ignoreFiles>
|
||||
<directory name="vendor"/>
|
||||
<directory name="vendor" />
|
||||
<directory name="src/Laravel" />
|
||||
</ignoreFiles>
|
||||
</projectFiles>
|
||||
|
||||
<plugins>
|
||||
<pluginClass class="Psalm\PhpUnitPlugin\Plugin"/>
|
||||
<pluginClass class="Orklah\StrictEquality\Plugin"/>
|
||||
<pluginClass class="Guuzen\PsalmEnumPlugin\Plugin"/>
|
||||
<pluginClass class="Psalm\PhpUnitPlugin\Plugin" />
|
||||
</plugins>
|
||||
|
||||
<issueHandlers>
|
||||
<!-- Laravel function compatibility -->
|
||||
<UndefinedFunction>
|
||||
<errorLevel type="suppress">
|
||||
<referencedFunction name="config" />
|
||||
<referencedFunction name="app" />
|
||||
<referencedFunction name="now" />
|
||||
<referencedFunction name="config_path" />
|
||||
<referencedFunction name="env" />
|
||||
</errorLevel>
|
||||
</UndefinedFunction>
|
||||
|
||||
<!-- Complex return type issues in GdprProcessor -->
|
||||
<InvalidReturnType>
|
||||
<errorLevel type="suppress">
|
||||
<file name="src/GdprProcessor.php" />
|
||||
</errorLevel>
|
||||
</InvalidReturnType>
|
||||
|
||||
<!-- Override attributes - suppress for now to avoid breaking changes -->
|
||||
<MissingOverrideAttribute errorLevel="suppress" />
|
||||
|
||||
<!-- Class finalization - suppress to avoid API breaking changes -->
|
||||
<ClassMustBeFinal errorLevel="suppress" />
|
||||
|
||||
<!-- Mixed types - necessary for flexible APIs -->
|
||||
<MixedArgument errorLevel="suppress" />
|
||||
<MixedAssignment errorLevel="suppress" />
|
||||
<MixedMethodCall errorLevel="suppress" />
|
||||
<MixedPropertyFetch errorLevel="suppress" />
|
||||
<MixedArrayAccess errorLevel="suppress" />
|
||||
|
||||
<!-- Missing type annotations - backward compatibility -->
|
||||
<MissingReturnType errorLevel="suppress" />
|
||||
<MissingParamType errorLevel="suppress" />
|
||||
<MissingPropertyType errorLevel="suppress" />
|
||||
|
||||
<!-- Prevent Psalm from adding complex nested return types -->
|
||||
<MismatchingDocblockReturnType errorLevel="suppress" />
|
||||
<MoreSpecificReturnType errorLevel="suppress" />
|
||||
<LessSpecificReturnStatement errorLevel="suppress" />
|
||||
|
||||
<!-- Test-specific suppressions -->
|
||||
|
||||
<!-- Redundant test assertions - provide defensive runtime validation -->
|
||||
<RedundantCondition>
|
||||
<errorLevel type="suppress">
|
||||
<directory name="tests" />
|
||||
</errorLevel>
|
||||
</RedundantCondition>
|
||||
<RedundantConditionGivenDocblockType>
|
||||
<errorLevel type="suppress">
|
||||
<directory name="tests" />
|
||||
</errorLevel>
|
||||
</RedundantConditionGivenDocblockType>
|
||||
<ArgumentTypeCoercion>
|
||||
<errorLevel type="suppress">
|
||||
<directory name="tests" />
|
||||
</errorLevel>
|
||||
</ArgumentTypeCoercion>
|
||||
|
||||
<!-- Test validation issues -->
|
||||
<InvalidArgument>
|
||||
<errorLevel type="suppress">
|
||||
<directory name="tests" />
|
||||
</errorLevel>
|
||||
</InvalidArgument>
|
||||
|
||||
<!-- Test helper methods in anonymous classes -->
|
||||
<UndefinedMethod>
|
||||
<errorLevel type="suppress">
|
||||
<file name="tests/Strategies/AbstractMaskingStrategyTest.php" />
|
||||
</errorLevel>
|
||||
</UndefinedMethod>
|
||||
|
||||
<!-- Test function calls -->
|
||||
<UndefinedFunction>
|
||||
<errorLevel type="suppress">
|
||||
<directory name="tests" />
|
||||
</errorLevel>
|
||||
</UndefinedFunction>
|
||||
|
||||
<!-- Laravel-specific patterns -->
|
||||
<!-- (Laravel directory is excluded from scanning) -->
|
||||
|
||||
<!-- Intentional design choices -->
|
||||
<PropertyNotSetInConstructor errorLevel="suppress" />
|
||||
<PossiblyUnusedMethod errorLevel="suppress" />
|
||||
<PossiblyUnusedProperty errorLevel="suppress" />
|
||||
|
||||
<!-- Array access patterns for configuration -->
|
||||
<PossiblyUndefinedArrayOffset errorLevel="suppress" />
|
||||
<PossiblyInvalidArrayOffset errorLevel="suppress" />
|
||||
|
||||
<!-- Closure patterns in callbacks -->
|
||||
<UnusedClosureParam errorLevel="suppress" />
|
||||
<MissingClosureParamType errorLevel="suppress" />
|
||||
<MissingClosureReturnType errorLevel="suppress" />
|
||||
|
||||
<!-- String manipulation patterns -->
|
||||
<PossiblyInvalidCast errorLevel="suppress" />
|
||||
</issueHandlers>
|
||||
</psalm>
|
||||
|
||||
87
rector.php
87
rector.php
@@ -3,26 +3,69 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use Rector\Config\RectorConfig;
|
||||
use Rector\Exception\Configuration\InvalidConfigurationException;
|
||||
use Rector\Set\ValueObject\SetList;
|
||||
use Rector\Php82\Rector\Class_\ReadOnlyClassRector;
|
||||
use Rector\TypeDeclaration\Rector\Property\TypedPropertyFromStrictConstructorRector;
|
||||
use Rector\CodeQuality\Rector\FuncCall\SimplifyRegexPatternRector;
|
||||
|
||||
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);
|
||||
}
|
||||
return RectorConfig::configure()
|
||||
->withPaths([
|
||||
__DIR__ . '/src',
|
||||
__DIR__ . '/tests',
|
||||
__DIR__ . '/examples',
|
||||
__DIR__ . '/config',
|
||||
])
|
||||
->withPhpSets(
|
||||
php82: true,
|
||||
)
|
||||
->withSets([
|
||||
// Only use very conservative, safe rule sets
|
||||
SetList::CODE_QUALITY, // Safe code quality improvements
|
||||
SetList::TYPE_DECLARATION, // Type declarations (generally safe)
|
||||
])
|
||||
->withSkip([
|
||||
// Skip risky transformations that can break existing functionality
|
||||
|
||||
// Skip readonly class conversion - can break existing usage
|
||||
ReadOnlyClassRector::class,
|
||||
|
||||
// Skip automatic property typing - can break existing flexibility
|
||||
TypedPropertyFromStrictConstructorRector::class,
|
||||
|
||||
// Skip regex pattern simplification - can break regex behavior ([0-9] vs \d with unicode)
|
||||
SimplifyRegexPatternRector::class,
|
||||
|
||||
// Skip entire directories for certain transformations
|
||||
'*/tests/*' => [
|
||||
// Don't modify test methods or assertions - they have specific requirements
|
||||
],
|
||||
|
||||
// Skip specific files that are sensitive
|
||||
__DIR__ . '/src/GdprProcessor.php' => [
|
||||
// Don't modify the main processor class structure
|
||||
],
|
||||
|
||||
// Skip Laravel integration files - they have specific requirements
|
||||
__DIR__ . '/src/Laravel/*' => [
|
||||
// Don't modify Laravel-specific code
|
||||
],
|
||||
])
|
||||
->withImportNames(
|
||||
importNames: true,
|
||||
importDocBlockNames: false, // Don't modify docblock imports - can break documentation
|
||||
importShortClasses: false, // Don't import short class names - can cause conflicts
|
||||
removeUnusedImports: true, // This is generally safe
|
||||
)
|
||||
// Conservative PHP version targeting
|
||||
->withPhpVersion(80400)
|
||||
// Don't use prepared sets - they're too aggressive
|
||||
->withPreparedSets(
|
||||
deadCode: false, // Disable dead code removal
|
||||
codingStyle: false, // Disable coding style changes
|
||||
earlyReturn: false, // Disable early return changes
|
||||
phpunitCodeQuality: false, // Disable PHPUnit modifications
|
||||
strictBooleans: false, // Disable strict boolean changes
|
||||
privatization: false, // Disable privatization changes
|
||||
naming: false, // Disable naming changes
|
||||
typeDeclarations: false, // Disable type declaration changes
|
||||
);
|
||||
|
||||
48
src/Anonymization/GeneralizationStrategy.php
Normal file
48
src/Anonymization/GeneralizationStrategy.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Anonymization;
|
||||
|
||||
/**
|
||||
* Represents a generalization strategy for k-anonymity.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class GeneralizationStrategy
|
||||
{
|
||||
/**
|
||||
* @var callable(mixed):string
|
||||
*/
|
||||
private $generalizer;
|
||||
|
||||
/**
|
||||
* @param callable(mixed):string $generalizer Function that generalizes a value
|
||||
* @param string $type Type identifier for the strategy
|
||||
*/
|
||||
public function __construct(
|
||||
callable $generalizer,
|
||||
private readonly string $type = 'custom'
|
||||
) {
|
||||
$this->generalizer = $generalizer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the generalization to a value.
|
||||
*
|
||||
* @param mixed $value The value to generalize
|
||||
* @return string The generalized value
|
||||
*/
|
||||
public function generalize(mixed $value): string
|
||||
{
|
||||
return ($this->generalizer)($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the strategy type.
|
||||
*/
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
}
|
||||
212
src/Anonymization/KAnonymizer.php
Normal file
212
src/Anonymization/KAnonymizer.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Anonymization;
|
||||
|
||||
/**
|
||||
* K-Anonymity implementation for GDPR compliance.
|
||||
*
|
||||
* K-anonymity is a privacy model ensuring that each record in a dataset
|
||||
* is indistinguishable from at least k-1 other records with respect to
|
||||
* certain identifying attributes (quasi-identifiers).
|
||||
*
|
||||
* Common use cases:
|
||||
* - Age generalization (25 -> "20-29")
|
||||
* - Location generalization (specific address -> region)
|
||||
* - Date generalization (specific date -> month/year)
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class KAnonymizer
|
||||
{
|
||||
/**
|
||||
* @var array<string,GeneralizationStrategy>
|
||||
*/
|
||||
private array $strategies = [];
|
||||
|
||||
/**
|
||||
* @var callable(string,mixed,mixed):void|null
|
||||
*/
|
||||
private $auditLogger;
|
||||
|
||||
/**
|
||||
* @param callable(string,mixed,mixed):void|null $auditLogger Optional audit logger
|
||||
*/
|
||||
public function __construct(?callable $auditLogger = null)
|
||||
{
|
||||
$this->auditLogger = $auditLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a generalization strategy for a field.
|
||||
*/
|
||||
public function registerStrategy(string $field, GeneralizationStrategy $strategy): self
|
||||
{
|
||||
$this->strategies[$field] = $strategy;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an age generalization strategy.
|
||||
*
|
||||
* @param int $rangeSize Size of age ranges (e.g., 10 for 20-29, 30-39)
|
||||
*/
|
||||
public function registerAgeStrategy(string $field, int $rangeSize = 10): self
|
||||
{
|
||||
$this->strategies[$field] = new GeneralizationStrategy(
|
||||
static function (mixed $value) use ($rangeSize): string {
|
||||
$age = (int) $value;
|
||||
$lowerBound = (int) floor($age / $rangeSize) * $rangeSize;
|
||||
$upperBound = $lowerBound + $rangeSize - 1;
|
||||
return "{$lowerBound}-{$upperBound}";
|
||||
},
|
||||
'age'
|
||||
);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a date generalization strategy.
|
||||
*
|
||||
* @param string $precision 'year', 'month', 'quarter'
|
||||
*/
|
||||
public function registerDateStrategy(string $field, string $precision = 'month'): self
|
||||
{
|
||||
$this->strategies[$field] = new GeneralizationStrategy(
|
||||
static function (mixed $value) use ($precision): string {
|
||||
if (!$value instanceof \DateTimeInterface) {
|
||||
$value = new \DateTimeImmutable((string) $value);
|
||||
}
|
||||
|
||||
return match ($precision) {
|
||||
'year' => $value->format('Y'),
|
||||
'quarter' => $value->format('Y') . '-Q' . (int) ceil((int) $value->format('n') / 3),
|
||||
default => $value->format('Y-m'),
|
||||
};
|
||||
},
|
||||
'date'
|
||||
);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a location/ZIP code generalization strategy.
|
||||
*
|
||||
* @param int $prefixLength Number of characters to keep
|
||||
*/
|
||||
public function registerLocationStrategy(string $field, int $prefixLength = 3): self
|
||||
{
|
||||
$this->strategies[$field] = new GeneralizationStrategy(
|
||||
static function (mixed $value) use ($prefixLength): string {
|
||||
$value = (string) $value;
|
||||
if (strlen($value) <= $prefixLength) {
|
||||
return $value;
|
||||
}
|
||||
return substr($value, 0, $prefixLength) . str_repeat('*', strlen($value) - $prefixLength);
|
||||
},
|
||||
'location'
|
||||
);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a numeric range generalization strategy.
|
||||
*
|
||||
* @param int $rangeSize Size of numeric ranges
|
||||
*/
|
||||
public function registerNumericRangeStrategy(string $field, int $rangeSize = 10): self
|
||||
{
|
||||
$this->strategies[$field] = new GeneralizationStrategy(
|
||||
static function (mixed $value) use ($rangeSize): string {
|
||||
$num = (int) $value;
|
||||
$lowerBound = (int) floor($num / $rangeSize) * $rangeSize;
|
||||
$upperBound = $lowerBound + $rangeSize - 1;
|
||||
return "{$lowerBound}-{$upperBound}";
|
||||
},
|
||||
'numeric_range'
|
||||
);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a custom generalization strategy.
|
||||
*
|
||||
* @param callable(mixed):string $generalizer
|
||||
*/
|
||||
public function registerCustomStrategy(string $field, callable $generalizer): self
|
||||
{
|
||||
$this->strategies[$field] = new GeneralizationStrategy($generalizer, 'custom');
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymize a single record.
|
||||
*
|
||||
* @param array<string,mixed> $record The record to anonymize
|
||||
* @return array<string,mixed> The anonymized record
|
||||
*/
|
||||
public function anonymize(array $record): array
|
||||
{
|
||||
foreach ($this->strategies as $field => $strategy) {
|
||||
if (isset($record[$field])) {
|
||||
$original = $record[$field];
|
||||
$record[$field] = $strategy->generalize($original);
|
||||
|
||||
if ($this->auditLogger !== null && $record[$field] !== $original) {
|
||||
($this->auditLogger)(
|
||||
"k-anonymity.{$field}",
|
||||
$original,
|
||||
$record[$field]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymize a batch of records.
|
||||
*
|
||||
* @param list<array<string,mixed>> $records
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function anonymizeBatch(array $records): array
|
||||
{
|
||||
return array_map($this->anonymize(...), $records);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get registered strategies.
|
||||
*
|
||||
* @return array<string,GeneralizationStrategy>
|
||||
*/
|
||||
public function getStrategies(): array
|
||||
{
|
||||
return $this->strategies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the audit logger.
|
||||
*
|
||||
* @param callable(string,mixed,mixed):void|null $auditLogger
|
||||
*/
|
||||
public function setAuditLogger(?callable $auditLogger): void
|
||||
{
|
||||
$this->auditLogger = $auditLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pre-configured anonymizer for common GDPR scenarios.
|
||||
*/
|
||||
public static function createGdprDefault(?callable $auditLogger = null): self
|
||||
{
|
||||
return (new self($auditLogger))
|
||||
->registerAgeStrategy('age')
|
||||
->registerDateStrategy('birth_date', 'year')
|
||||
->registerDateStrategy('created_at', 'month')
|
||||
->registerLocationStrategy('zip_code', 3)
|
||||
->registerLocationStrategy('postal_code', 3);
|
||||
}
|
||||
}
|
||||
75
src/ArrayAccessor/ArrayAccessorFactory.php
Normal file
75
src/ArrayAccessor/ArrayAccessorFactory.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\ArrayAccessor;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Contracts\ArrayAccessorInterface;
|
||||
|
||||
/**
|
||||
* Factory for creating ArrayAccessor instances.
|
||||
*
|
||||
* This factory allows dependency injection of the accessor creation logic,
|
||||
* enabling easy swapping of implementations for testing or alternative libraries.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class ArrayAccessorFactory
|
||||
{
|
||||
/**
|
||||
* @var class-string<ArrayAccessorInterface>|callable(array<string, mixed>): ArrayAccessorInterface
|
||||
*/
|
||||
private $accessorClass;
|
||||
|
||||
/**
|
||||
* @param class-string<ArrayAccessorInterface>|callable(array<string, mixed>): ArrayAccessorInterface|null $accessorClass
|
||||
*/
|
||||
public function __construct(string|callable|null $accessorClass = null)
|
||||
{
|
||||
$this->accessorClass = $accessorClass ?? DotArrayAccessor::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new ArrayAccessor instance for the given data.
|
||||
*
|
||||
* @param array<string, mixed> $data Data array to wrap
|
||||
*/
|
||||
public function create(array $data): ArrayAccessorInterface
|
||||
{
|
||||
if (is_callable($this->accessorClass)) {
|
||||
return ($this->accessorClass)($data);
|
||||
}
|
||||
|
||||
$class = $this->accessorClass;
|
||||
|
||||
return new $class($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a factory with the default Dot implementation.
|
||||
*/
|
||||
public static function default(): self
|
||||
{
|
||||
return new self(DotArrayAccessor::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a factory with a custom accessor class.
|
||||
*
|
||||
* @param class-string<ArrayAccessorInterface> $accessorClass
|
||||
*/
|
||||
public static function withClass(string $accessorClass): self
|
||||
{
|
||||
return new self($accessorClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a factory with a custom callable.
|
||||
*
|
||||
* @param callable(array<string, mixed>): ArrayAccessorInterface $factory
|
||||
*/
|
||||
public static function withCallable(callable $factory): self
|
||||
{
|
||||
return new self($factory);
|
||||
}
|
||||
}
|
||||
80
src/ArrayAccessor/DotArrayAccessor.php
Normal file
80
src/ArrayAccessor/DotArrayAccessor.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\ArrayAccessor;
|
||||
|
||||
use Adbar\Dot;
|
||||
use Ivuorinen\MonologGdprFilter\Contracts\ArrayAccessorInterface;
|
||||
|
||||
/**
|
||||
* ArrayAccessor implementation using Adbar\Dot library.
|
||||
*
|
||||
* This class wraps the Adbar\Dot library to implement ArrayAccessorInterface,
|
||||
* allowing the library to be swapped without affecting consuming code.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class DotArrayAccessor implements ArrayAccessorInterface
|
||||
{
|
||||
/** @var Dot<array-key, mixed> */
|
||||
private readonly Dot $dot;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data Initial data array
|
||||
*/
|
||||
public function __construct(array $data = [])
|
||||
{
|
||||
$this->dot = new Dot($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create accessor from an existing array.
|
||||
*
|
||||
* @param array<string, mixed> $data Data array
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self($data);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function has(string $path): bool
|
||||
{
|
||||
return $this->dot->has($path);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function get(string $path, mixed $default = null): mixed
|
||||
{
|
||||
return $this->dot->get($path, $default);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function set(string $path, mixed $value): void
|
||||
{
|
||||
$this->dot->set($path, $value);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function delete(string $path): void
|
||||
{
|
||||
$this->dot->delete($path);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function all(): array
|
||||
{
|
||||
return $this->dot->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying Dot instance for advanced operations.
|
||||
*
|
||||
* @return Dot<array-key, mixed>
|
||||
*/
|
||||
public function getDot(): Dot
|
||||
{
|
||||
return $this->dot;
|
||||
}
|
||||
}
|
||||
216
src/Audit/AuditContext.php
Normal file
216
src/Audit/AuditContext.php
Normal file
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Audit;
|
||||
|
||||
/**
|
||||
* Structured context for audit log entries.
|
||||
*
|
||||
* Provides a standardized format for tracking masking operations,
|
||||
* including timing, retry attempts, and error information.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final readonly class AuditContext
|
||||
{
|
||||
public const STATUS_SUCCESS = 'success';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_RECOVERED = 'recovered';
|
||||
public const STATUS_SKIPPED = 'skipped';
|
||||
|
||||
public const OP_REGEX = 'regex';
|
||||
public const OP_FIELD_PATH = 'field_path';
|
||||
public const OP_CALLBACK = 'callback';
|
||||
public const OP_DATA_TYPE = 'data_type';
|
||||
public const OP_JSON = 'json';
|
||||
public const OP_CONDITIONAL = 'conditional';
|
||||
|
||||
/**
|
||||
* @param string $operationType Type of masking operation performed
|
||||
* @param string $status Operation result status
|
||||
* @param string|null $correlationId Unique ID linking related operations
|
||||
* @param int $attemptNumber Retry attempt number (1 = first attempt)
|
||||
* @param float $durationMs Operation duration in milliseconds
|
||||
* @param ErrorContext|null $error Error details if operation failed
|
||||
* @param array<string, mixed> $metadata Additional context information
|
||||
*/
|
||||
public function __construct(
|
||||
public string $operationType,
|
||||
public string $status = self::STATUS_SUCCESS,
|
||||
public ?string $correlationId = null,
|
||||
public int $attemptNumber = 1,
|
||||
public float $durationMs = 0.0,
|
||||
public ?ErrorContext $error = null,
|
||||
public array $metadata = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a success audit context.
|
||||
*
|
||||
* @param string $operationType The type of masking operation
|
||||
* @param float $durationMs Operation duration in milliseconds
|
||||
* @param array<string, mixed> $metadata Additional context
|
||||
*/
|
||||
public static function success(
|
||||
string $operationType,
|
||||
float $durationMs = 0.0,
|
||||
array $metadata = []
|
||||
): self {
|
||||
return new self(
|
||||
operationType: $operationType,
|
||||
status: self::STATUS_SUCCESS,
|
||||
durationMs: $durationMs,
|
||||
metadata: $metadata,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a failed audit context.
|
||||
*
|
||||
* @param string $operationType The type of masking operation
|
||||
* @param ErrorContext $error The error that occurred
|
||||
* @param int $attemptNumber Which attempt this was
|
||||
* @param float $durationMs Operation duration in milliseconds
|
||||
* @param array<string, mixed> $metadata Additional context
|
||||
*/
|
||||
public static function failed(
|
||||
string $operationType,
|
||||
ErrorContext $error,
|
||||
int $attemptNumber = 1,
|
||||
float $durationMs = 0.0,
|
||||
array $metadata = []
|
||||
): self {
|
||||
return new self(
|
||||
operationType: $operationType,
|
||||
status: self::STATUS_FAILED,
|
||||
attemptNumber: $attemptNumber,
|
||||
durationMs: $durationMs,
|
||||
error: $error,
|
||||
metadata: $metadata,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a recovered audit context (after retry/fallback).
|
||||
*
|
||||
* @param string $operationType The type of masking operation
|
||||
* @param int $attemptNumber Final attempt number before success
|
||||
* @param float $durationMs Total duration including retries
|
||||
* @param array<string, mixed> $metadata Additional context
|
||||
*/
|
||||
public static function recovered(
|
||||
string $operationType,
|
||||
int $attemptNumber,
|
||||
float $durationMs = 0.0,
|
||||
array $metadata = []
|
||||
): self {
|
||||
return new self(
|
||||
operationType: $operationType,
|
||||
status: self::STATUS_RECOVERED,
|
||||
attemptNumber: $attemptNumber,
|
||||
durationMs: $durationMs,
|
||||
metadata: $metadata,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a skipped audit context (conditional rule prevented masking).
|
||||
*
|
||||
* @param string $operationType The type of masking operation
|
||||
* @param string $reason Why the operation was skipped
|
||||
* @param array<string, mixed> $metadata Additional context
|
||||
*/
|
||||
public static function skipped(
|
||||
string $operationType,
|
||||
string $reason,
|
||||
array $metadata = []
|
||||
): self {
|
||||
return new self(
|
||||
operationType: $operationType,
|
||||
status: self::STATUS_SKIPPED,
|
||||
metadata: array_merge($metadata, ['skip_reason' => $reason]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a copy with a correlation ID.
|
||||
*/
|
||||
public function withCorrelationId(string $correlationId): self
|
||||
{
|
||||
return new self(
|
||||
operationType: $this->operationType,
|
||||
status: $this->status,
|
||||
correlationId: $correlationId,
|
||||
attemptNumber: $this->attemptNumber,
|
||||
durationMs: $this->durationMs,
|
||||
error: $this->error,
|
||||
metadata: $this->metadata,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a copy with additional metadata.
|
||||
*
|
||||
* @param array<string, mixed> $additionalMetadata
|
||||
*/
|
||||
public function withMetadata(array $additionalMetadata): self
|
||||
{
|
||||
return new self(
|
||||
operationType: $this->operationType,
|
||||
status: $this->status,
|
||||
correlationId: $this->correlationId,
|
||||
attemptNumber: $this->attemptNumber,
|
||||
durationMs: $this->durationMs,
|
||||
error: $this->error,
|
||||
metadata: array_merge($this->metadata, $additionalMetadata),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the operation succeeded.
|
||||
*/
|
||||
public function isSuccess(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_SUCCESS
|
||||
|| $this->status === self::STATUS_RECOVERED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for serialization/logging.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$data = [
|
||||
'operation_type' => $this->operationType,
|
||||
'status' => $this->status,
|
||||
'attempt_number' => $this->attemptNumber,
|
||||
'duration_ms' => round($this->durationMs, 3),
|
||||
];
|
||||
|
||||
if ($this->correlationId !== null) {
|
||||
$data['correlation_id'] = $this->correlationId;
|
||||
}
|
||||
|
||||
if ($this->error instanceof ErrorContext) {
|
||||
$data['error'] = $this->error->toArray();
|
||||
}
|
||||
|
||||
if ($this->metadata !== []) {
|
||||
$data['metadata'] = $this->metadata;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique correlation ID for tracking related operations.
|
||||
*/
|
||||
public static function generateCorrelationId(): string
|
||||
{
|
||||
return bin2hex(random_bytes(8));
|
||||
}
|
||||
}
|
||||
147
src/Audit/ErrorContext.php
Normal file
147
src/Audit/ErrorContext.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Audit;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Standardized error information for audit logging.
|
||||
*
|
||||
* Captures error details in a structured format while ensuring
|
||||
* sensitive information is sanitized before logging.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final readonly class ErrorContext
|
||||
{
|
||||
/**
|
||||
* @param string $errorType The type/class of error that occurred
|
||||
* @param string $message Sanitized error message (sensitive data removed)
|
||||
* @param int $code Error code if available
|
||||
* @param string|null $file File where error occurred (optional)
|
||||
* @param int|null $line Line number where error occurred (optional)
|
||||
* @param array<string, mixed> $metadata Additional error metadata
|
||||
*/
|
||||
public function __construct(
|
||||
public string $errorType,
|
||||
public string $message,
|
||||
public int $code = 0,
|
||||
public ?string $file = null,
|
||||
public ?int $line = null,
|
||||
public array $metadata = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an ErrorContext from a Throwable.
|
||||
*
|
||||
* @param Throwable $throwable The exception/error to capture
|
||||
* @param bool $includeSensitive Whether to include potentially sensitive details
|
||||
*/
|
||||
public static function fromThrowable(
|
||||
Throwable $throwable,
|
||||
bool $includeSensitive = false
|
||||
): self {
|
||||
$message = $includeSensitive
|
||||
? $throwable->getMessage()
|
||||
: self::sanitizeMessage($throwable->getMessage());
|
||||
|
||||
$metadata = [];
|
||||
if ($includeSensitive) {
|
||||
$metadata['trace'] = array_slice($throwable->getTrace(), 0, 5);
|
||||
}
|
||||
|
||||
return new self(
|
||||
errorType: $throwable::class,
|
||||
message: $message,
|
||||
code: (int) $throwable->getCode(),
|
||||
file: $includeSensitive ? $throwable->getFile() : null,
|
||||
line: $includeSensitive ? $throwable->getLine() : null,
|
||||
metadata: $metadata,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an ErrorContext for a generic error.
|
||||
*
|
||||
* @param string $errorType The type of error
|
||||
* @param string $message The error message
|
||||
* @param array<string, mixed> $metadata Additional context
|
||||
*/
|
||||
public static function create(
|
||||
string $errorType,
|
||||
string $message,
|
||||
array $metadata = []
|
||||
): self {
|
||||
return new self(
|
||||
errorType: $errorType,
|
||||
message: self::sanitizeMessage($message),
|
||||
metadata: $metadata,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize an error message to remove potentially sensitive information.
|
||||
*
|
||||
* @param string $message The original error message
|
||||
*/
|
||||
private static function sanitizeMessage(string $message): string
|
||||
{
|
||||
$patterns = [
|
||||
// Passwords and secrets
|
||||
'/password[=:]\s*[^\s,;]+/i' => 'password=[REDACTED]',
|
||||
'/secret[=:]\s*[^\s,;]+/i' => 'secret=[REDACTED]',
|
||||
'/api[_-]?key[=:]\s*[^\s,;]+/i' => 'api_key=[REDACTED]',
|
||||
'/token[=:]\s*[^\s,;]+/i' => 'token=[REDACTED]',
|
||||
'/bearer\s+\S+/i' => 'bearer [REDACTED]',
|
||||
|
||||
// Connection strings
|
||||
'/:[^@]+@/' => ':[REDACTED]@',
|
||||
'/user[=:]\s*[^\s,;@]+/i' => 'user=[REDACTED]',
|
||||
'/host[=:]\s*[^\s,;]+/i' => 'host=[REDACTED]',
|
||||
|
||||
// File paths (partial - keep filename)
|
||||
'/\/(?:var|home|etc|usr|opt)\/[^\s:]+/' => '/[PATH_REDACTED]',
|
||||
];
|
||||
|
||||
$sanitized = $message;
|
||||
foreach ($patterns as $pattern => $replacement) {
|
||||
$result = preg_replace($pattern, $replacement, $sanitized);
|
||||
if ($result !== null) {
|
||||
$sanitized = $result;
|
||||
}
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for serialization/logging.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$data = [
|
||||
'error_type' => $this->errorType,
|
||||
'message' => $this->message,
|
||||
'code' => $this->code,
|
||||
];
|
||||
|
||||
if ($this->file !== null) {
|
||||
$data['file'] = $this->file;
|
||||
}
|
||||
|
||||
if ($this->line !== null) {
|
||||
$data['line'] = $this->line;
|
||||
}
|
||||
|
||||
if ($this->metadata !== []) {
|
||||
$data['metadata'] = $this->metadata;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
232
src/Audit/StructuredAuditLogger.php
Normal file
232
src/Audit/StructuredAuditLogger.php
Normal file
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Audit;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\RateLimitedAuditLogger;
|
||||
|
||||
/**
|
||||
* Enhanced audit logger wrapper with structured context support.
|
||||
*
|
||||
* Wraps a base audit logger (callable or RateLimitedAuditLogger) and
|
||||
* provides structured context information for better audit trails.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class StructuredAuditLogger
|
||||
{
|
||||
/** @var callable(string, mixed, mixed): void */
|
||||
private $wrappedLogger;
|
||||
|
||||
/**
|
||||
* @param callable|RateLimitedAuditLogger $auditLogger Base logger to wrap
|
||||
* @param bool $includeTimestamp Whether to include timestamp in metadata
|
||||
* @param bool $includeDuration Whether to include operation duration
|
||||
*/
|
||||
public function __construct(
|
||||
callable|RateLimitedAuditLogger $auditLogger,
|
||||
private readonly bool $includeTimestamp = true,
|
||||
private readonly bool $includeDuration = true
|
||||
) {
|
||||
$this->wrappedLogger = $auditLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a structured audit logger from a base logger.
|
||||
*
|
||||
* @param callable|RateLimitedAuditLogger $auditLogger Base logger
|
||||
*/
|
||||
public static function wrap(
|
||||
callable|RateLimitedAuditLogger $auditLogger
|
||||
): self {
|
||||
return new self($auditLogger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an audit entry with structured context.
|
||||
*
|
||||
* @param string $path The field path being masked
|
||||
* @param mixed $original The original value
|
||||
* @param mixed $masked The masked value
|
||||
* @param AuditContext|null $context Structured audit context
|
||||
*/
|
||||
public function log(
|
||||
string $path,
|
||||
mixed $original,
|
||||
mixed $masked,
|
||||
?AuditContext $context = null
|
||||
): void {
|
||||
$enrichedContext = $context;
|
||||
|
||||
if ($enrichedContext instanceof AuditContext) {
|
||||
$metadata = [];
|
||||
|
||||
if ($this->includeTimestamp) {
|
||||
$metadata['timestamp'] = time();
|
||||
$metadata['timestamp_micro'] = microtime(true);
|
||||
}
|
||||
|
||||
if ($this->includeDuration && $enrichedContext->durationMs > 0) {
|
||||
$metadata['duration_ms'] = $enrichedContext->durationMs;
|
||||
}
|
||||
|
||||
if ($metadata !== []) {
|
||||
$enrichedContext = $enrichedContext->withMetadata($metadata);
|
||||
}
|
||||
}
|
||||
|
||||
// Call the wrapped logger
|
||||
// The wrapped logger may be a simple callable (3 params) or enhanced (4 params)
|
||||
($this->wrappedLogger)($path, $original, $masked);
|
||||
|
||||
// If we have context and the wrapped logger doesn't handle it,
|
||||
// we store it separately (could be extended to log to a separate channel)
|
||||
if ($enrichedContext instanceof AuditContext) {
|
||||
$this->logContext($path, $enrichedContext);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a success operation.
|
||||
*
|
||||
* @param string $path The field path
|
||||
* @param mixed $original The original value
|
||||
* @param mixed $masked The masked value
|
||||
* @param string $operationType Type of masking operation
|
||||
* @param float $durationMs Duration in milliseconds
|
||||
*/
|
||||
public function logSuccess(
|
||||
string $path,
|
||||
mixed $original,
|
||||
mixed $masked,
|
||||
string $operationType,
|
||||
float $durationMs = 0.0
|
||||
): void {
|
||||
$context = AuditContext::success($operationType, $durationMs, [
|
||||
'path' => $path,
|
||||
]);
|
||||
|
||||
$this->log($path, $original, $masked, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a failed operation.
|
||||
*
|
||||
* @param string $path The field path
|
||||
* @param mixed $original The original value
|
||||
* @param string $operationType Type of masking operation
|
||||
* @param ErrorContext $error Error information
|
||||
* @param int $attemptNumber Which attempt failed
|
||||
*/
|
||||
public function logFailure(
|
||||
string $path,
|
||||
mixed $original,
|
||||
string $operationType,
|
||||
ErrorContext $error,
|
||||
int $attemptNumber = 1
|
||||
): void {
|
||||
$context = AuditContext::failed(
|
||||
$operationType,
|
||||
$error,
|
||||
$attemptNumber,
|
||||
0.0,
|
||||
['path' => $path]
|
||||
);
|
||||
|
||||
// For failures, the "masked" value indicates the failure
|
||||
$this->log($path, $original, '[MASKING_FAILED]', $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a recovered operation (after retry/fallback).
|
||||
*
|
||||
* @param string $path The field path
|
||||
* @param mixed $original The original value
|
||||
* @param mixed $masked The masked value (from recovery)
|
||||
* @param string $operationType Type of masking operation
|
||||
* @param int $attemptNumber Final successful attempt number
|
||||
* @param float $totalDurationMs Total duration including retries
|
||||
*/
|
||||
public function logRecovery(
|
||||
string $path,
|
||||
mixed $original,
|
||||
mixed $masked,
|
||||
string $operationType,
|
||||
int $attemptNumber,
|
||||
float $totalDurationMs = 0.0
|
||||
): void {
|
||||
$context = AuditContext::recovered(
|
||||
$operationType,
|
||||
$attemptNumber,
|
||||
$totalDurationMs,
|
||||
['path' => $path]
|
||||
);
|
||||
|
||||
$this->log($path, $original, $masked, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a skipped operation.
|
||||
*
|
||||
* @param string $path The field path
|
||||
* @param mixed $value The value that was not masked
|
||||
* @param string $operationType Type of masking operation
|
||||
* @param string $reason Why masking was skipped
|
||||
*/
|
||||
public function logSkipped(
|
||||
string $path,
|
||||
mixed $value,
|
||||
string $operationType,
|
||||
string $reason
|
||||
): void {
|
||||
$context = AuditContext::skipped($operationType, $reason, [
|
||||
'path' => $path,
|
||||
]);
|
||||
|
||||
$this->log($path, $value, $value, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start timing an operation.
|
||||
*
|
||||
* @return float Start time in microseconds
|
||||
*/
|
||||
public function startTimer(): float
|
||||
{
|
||||
return microtime(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate elapsed time since start.
|
||||
*
|
||||
* @param float $startTime From startTimer()
|
||||
* @return float Duration in milliseconds
|
||||
*/
|
||||
public function elapsed(float $startTime): float
|
||||
{
|
||||
return (microtime(true) - $startTime) * 1000.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log structured context (for extended audit trails).
|
||||
*
|
||||
* Override this method to send context to a separate logging channel.
|
||||
*/
|
||||
protected function logContext(string $path, AuditContext $context): void
|
||||
{
|
||||
// Default implementation does nothing extra
|
||||
// Subclasses can override to log to a separate channel
|
||||
unset($path, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the wrapped logger for direct access if needed.
|
||||
*
|
||||
* @return callable
|
||||
*/
|
||||
public function getWrappedLogger(): callable
|
||||
{
|
||||
return $this->wrappedLogger;
|
||||
}
|
||||
}
|
||||
118
src/Builder/GdprProcessorBuilder.php
Normal file
118
src/Builder/GdprProcessorBuilder.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Builder;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\ArrayAccessor\ArrayAccessorFactory;
|
||||
use Ivuorinen\MonologGdprFilter\Builder\Traits\CallbackConfigurationTrait;
|
||||
use Ivuorinen\MonologGdprFilter\Builder\Traits\FieldPathConfigurationTrait;
|
||||
use Ivuorinen\MonologGdprFilter\Builder\Traits\PatternConfigurationTrait;
|
||||
use Ivuorinen\MonologGdprFilter\Builder\Traits\PluginConfigurationTrait;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
|
||||
/**
|
||||
* Fluent builder for GdprProcessor configuration.
|
||||
*
|
||||
* Provides a clean, chainable API for configuring GdprProcessor instances
|
||||
* with support for plugins, patterns, field paths, and callbacks.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class GdprProcessorBuilder
|
||||
{
|
||||
use PatternConfigurationTrait;
|
||||
use FieldPathConfigurationTrait;
|
||||
use CallbackConfigurationTrait;
|
||||
use PluginConfigurationTrait;
|
||||
|
||||
/**
|
||||
* @var callable(string,mixed,mixed):void|null
|
||||
*/
|
||||
private $auditLogger = null;
|
||||
|
||||
private int $maxDepth = 100;
|
||||
|
||||
private ?ArrayAccessorFactory $arrayAccessorFactory = null;
|
||||
|
||||
/**
|
||||
* Create a new builder instance.
|
||||
*/
|
||||
public static function create(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the audit logger.
|
||||
*
|
||||
* @param callable(string,mixed,mixed):void $auditLogger Audit logger callback
|
||||
*/
|
||||
public function withAuditLogger(callable $auditLogger): self
|
||||
{
|
||||
$this->auditLogger = $auditLogger;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the maximum recursion depth.
|
||||
*/
|
||||
public function withMaxDepth(int $maxDepth): self
|
||||
{
|
||||
$this->maxDepth = $maxDepth;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the array accessor factory.
|
||||
*/
|
||||
public function withArrayAccessorFactory(ArrayAccessorFactory $factory): self
|
||||
{
|
||||
$this->arrayAccessorFactory = $factory;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the GdprProcessor with all configured options.
|
||||
*
|
||||
* @throws \InvalidArgumentException When configuration is invalid
|
||||
*/
|
||||
public function build(): GdprProcessor
|
||||
{
|
||||
// Apply plugin configurations
|
||||
$this->applyPluginConfigurations();
|
||||
|
||||
return new GdprProcessor(
|
||||
$this->patterns,
|
||||
$this->fieldPaths,
|
||||
$this->customCallbacks,
|
||||
$this->auditLogger,
|
||||
$this->maxDepth,
|
||||
$this->dataTypeMasks,
|
||||
$this->conditionalRules,
|
||||
$this->arrayAccessorFactory
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a GdprProcessor wrapped with plugin hooks.
|
||||
*
|
||||
* Returns a PluginAwareProcessor if plugins are registered,
|
||||
* otherwise returns a standard GdprProcessor.
|
||||
*
|
||||
* @throws \InvalidArgumentException When configuration is invalid
|
||||
*/
|
||||
public function buildWithPlugins(): GdprProcessor|PluginAwareProcessor
|
||||
{
|
||||
$processor = $this->build();
|
||||
|
||||
if ($this->plugins === []) {
|
||||
return $processor;
|
||||
}
|
||||
|
||||
// Sort plugins by priority
|
||||
usort($this->plugins, fn($a, $b): int => $a->getPriority() <=> $b->getPriority());
|
||||
|
||||
return new PluginAwareProcessor($processor, $this->plugins);
|
||||
}
|
||||
}
|
||||
121
src/Builder/PluginAwareProcessor.php
Normal file
121
src/Builder/PluginAwareProcessor.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Builder;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Contracts\MaskingPluginInterface;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Monolog\LogRecord;
|
||||
use Monolog\Processor\ProcessorInterface;
|
||||
|
||||
/**
|
||||
* Wrapper that adds plugin hook support to GdprProcessor.
|
||||
*
|
||||
* Executes plugin pre/post processing hooks around the standard
|
||||
* GdprProcessor masking operations.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class PluginAwareProcessor implements ProcessorInterface
|
||||
{
|
||||
/**
|
||||
* @param GdprProcessor $processor The underlying processor
|
||||
* @param list<MaskingPluginInterface> $plugins Registered plugins (sorted by priority)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly GdprProcessor $processor,
|
||||
private readonly array $plugins
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a log record with plugin hooks.
|
||||
*
|
||||
* @param LogRecord $record The log record to process
|
||||
* @return LogRecord The processed log record
|
||||
*/
|
||||
#[\Override]
|
||||
public function __invoke(LogRecord $record): LogRecord
|
||||
{
|
||||
// Pre-process message through plugins
|
||||
$message = $record->message;
|
||||
foreach ($this->plugins as $plugin) {
|
||||
$message = $plugin->preProcessMessage($message);
|
||||
}
|
||||
|
||||
// Pre-process context through plugins
|
||||
$context = $record->context;
|
||||
foreach ($this->plugins as $plugin) {
|
||||
$context = $plugin->preProcessContext($context);
|
||||
}
|
||||
|
||||
// Create modified record for main processor
|
||||
$modifiedRecord = $record->with(message: $message, context: $context);
|
||||
|
||||
// Apply main processor
|
||||
$processedRecord = ($this->processor)($modifiedRecord);
|
||||
|
||||
// Post-process message through plugins (reverse order)
|
||||
$message = $processedRecord->message;
|
||||
foreach (array_reverse($this->plugins) as $plugin) {
|
||||
$message = $plugin->postProcessMessage($message);
|
||||
}
|
||||
|
||||
// Post-process context through plugins (reverse order)
|
||||
$context = $processedRecord->context;
|
||||
foreach (array_reverse($this->plugins) as $plugin) {
|
||||
$context = $plugin->postProcessContext($context);
|
||||
}
|
||||
|
||||
return $processedRecord->with(message: $message, context: $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying GdprProcessor.
|
||||
*/
|
||||
public function getProcessor(): GdprProcessor
|
||||
{
|
||||
return $this->processor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get registered plugins.
|
||||
*
|
||||
* @return list<MaskingPluginInterface>
|
||||
*/
|
||||
public function getPlugins(): array
|
||||
{
|
||||
return $this->plugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegate regExpMessage to underlying processor.
|
||||
*/
|
||||
public function regExpMessage(string $message = ''): string
|
||||
{
|
||||
return $this->processor->regExpMessage($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegate recursiveMask to underlying processor.
|
||||
*
|
||||
* @param array<mixed>|string $data
|
||||
* @param int $currentDepth
|
||||
* @return array<mixed>|string
|
||||
*/
|
||||
public function recursiveMask(array|string $data, int $currentDepth = 0): array|string
|
||||
{
|
||||
return $this->processor->recursiveMask($data, $currentDepth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegate setAuditLogger to underlying processor.
|
||||
*
|
||||
* @param callable(string,mixed,mixed):void|null $auditLogger
|
||||
*/
|
||||
public function setAuditLogger(?callable $auditLogger): void
|
||||
{
|
||||
$this->processor->setAuditLogger($auditLogger);
|
||||
}
|
||||
}
|
||||
100
src/Builder/Traits/CallbackConfigurationTrait.php
Normal file
100
src/Builder/Traits/CallbackConfigurationTrait.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Builder\Traits;
|
||||
|
||||
use Monolog\LogRecord;
|
||||
|
||||
/**
|
||||
* Provides callback configuration methods for GdprProcessorBuilder.
|
||||
*
|
||||
* Handles custom callbacks, data type masks, and conditional masking rules
|
||||
* for advanced masking scenarios.
|
||||
*/
|
||||
trait CallbackConfigurationTrait
|
||||
{
|
||||
/**
|
||||
* @var array<string,callable(mixed):string>
|
||||
*/
|
||||
private array $customCallbacks = [];
|
||||
|
||||
/**
|
||||
* @var array<string,string>
|
||||
*/
|
||||
private array $dataTypeMasks = [];
|
||||
|
||||
/**
|
||||
* @var array<string,callable(LogRecord):bool>
|
||||
*/
|
||||
private array $conditionalRules = [];
|
||||
|
||||
/**
|
||||
* Add a custom callback for a field path.
|
||||
*
|
||||
* @param string $path Dot-notation path
|
||||
* @param callable(mixed):string $callback Transformation callback
|
||||
*/
|
||||
public function addCallback(string $path, callable $callback): self
|
||||
{
|
||||
$this->customCallbacks[$path] = $callback;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple custom callbacks.
|
||||
*
|
||||
* @param array<string,callable(mixed):string> $callbacks Path => callback
|
||||
*/
|
||||
public function addCallbacks(array $callbacks): self
|
||||
{
|
||||
$this->customCallbacks = array_merge($this->customCallbacks, $callbacks);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a data type mask.
|
||||
*
|
||||
* @param string $type Data type (e.g., 'integer', 'double', 'boolean')
|
||||
* @param string $mask Replacement mask
|
||||
*/
|
||||
public function addDataTypeMask(string $type, string $mask): self
|
||||
{
|
||||
$this->dataTypeMasks[$type] = $mask;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple data type masks.
|
||||
*
|
||||
* @param array<string,string> $masks Type => mask
|
||||
*/
|
||||
public function addDataTypeMasks(array $masks): self
|
||||
{
|
||||
$this->dataTypeMasks = array_merge($this->dataTypeMasks, $masks);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a conditional masking rule.
|
||||
*
|
||||
* @param string $name Rule name
|
||||
* @param callable(LogRecord):bool $condition Condition callback
|
||||
*/
|
||||
public function addConditionalRule(string $name, callable $condition): self
|
||||
{
|
||||
$this->conditionalRules[$name] = $condition;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple conditional rules.
|
||||
*
|
||||
* @param array<string,callable(LogRecord):bool> $rules Name => condition
|
||||
*/
|
||||
public function addConditionalRules(array $rules): self
|
||||
{
|
||||
$this->conditionalRules = array_merge($this->conditionalRules, $rules);
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
65
src/Builder/Traits/FieldPathConfigurationTrait.php
Normal file
65
src/Builder/Traits/FieldPathConfigurationTrait.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Builder\Traits;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
|
||||
|
||||
/**
|
||||
* Provides field path configuration methods for GdprProcessorBuilder.
|
||||
*
|
||||
* Handles field path management for masking specific fields in log context
|
||||
* using dot notation (e.g., "user.email").
|
||||
*/
|
||||
trait FieldPathConfigurationTrait
|
||||
{
|
||||
/**
|
||||
* @var array<string,FieldMaskConfig|string>
|
||||
*/
|
||||
private array $fieldPaths = [];
|
||||
|
||||
/**
|
||||
* Add a field path to mask.
|
||||
*
|
||||
* @param string $path Dot-notation path
|
||||
* @param FieldMaskConfig|string $config Mask configuration or replacement string
|
||||
*/
|
||||
public function addFieldPath(string $path, FieldMaskConfig|string $config): self
|
||||
{
|
||||
$this->fieldPaths[$path] = $config;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple field paths.
|
||||
*
|
||||
* @param array<string,FieldMaskConfig|string> $fieldPaths Path => config
|
||||
*/
|
||||
public function addFieldPaths(array $fieldPaths): self
|
||||
{
|
||||
$this->fieldPaths = array_merge($this->fieldPaths, $fieldPaths);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set all field paths (replaces existing).
|
||||
*
|
||||
* @param array<string,FieldMaskConfig|string> $fieldPaths Path => config
|
||||
*/
|
||||
public function setFieldPaths(array $fieldPaths): self
|
||||
{
|
||||
$this->fieldPaths = $fieldPaths;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current field paths configuration.
|
||||
*
|
||||
* @return array<string,FieldMaskConfig|string>
|
||||
*/
|
||||
public function getFieldPaths(): array
|
||||
{
|
||||
return $this->fieldPaths;
|
||||
}
|
||||
}
|
||||
74
src/Builder/Traits/PatternConfigurationTrait.php
Normal file
74
src/Builder/Traits/PatternConfigurationTrait.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Builder\Traits;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
|
||||
|
||||
/**
|
||||
* Provides pattern configuration methods for GdprProcessorBuilder.
|
||||
*
|
||||
* Handles regex pattern management including adding, setting, and retrieving patterns
|
||||
* used for masking sensitive data in log records.
|
||||
*/
|
||||
trait PatternConfigurationTrait
|
||||
{
|
||||
/**
|
||||
* @var array<string,string>
|
||||
*/
|
||||
private array $patterns = [];
|
||||
|
||||
/**
|
||||
* Add a regex pattern.
|
||||
*
|
||||
* @param string $pattern Regex pattern
|
||||
* @param string $replacement Replacement string
|
||||
*/
|
||||
public function addPattern(string $pattern, string $replacement): self
|
||||
{
|
||||
$this->patterns[$pattern] = $replacement;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple patterns.
|
||||
*
|
||||
* @param array<string,string> $patterns Regex pattern => replacement
|
||||
*/
|
||||
public function addPatterns(array $patterns): self
|
||||
{
|
||||
$this->patterns = array_merge($this->patterns, $patterns);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set all patterns (replaces existing).
|
||||
*
|
||||
* @param array<string,string> $patterns Regex pattern => replacement
|
||||
*/
|
||||
public function setPatterns(array $patterns): self
|
||||
{
|
||||
$this->patterns = $patterns;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current patterns configuration.
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public function getPatterns(): array
|
||||
{
|
||||
return $this->patterns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start with default GDPR patterns.
|
||||
*/
|
||||
public function withDefaultPatterns(): self
|
||||
{
|
||||
$this->patterns = array_merge($this->patterns, DefaultPatterns::get());
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
67
src/Builder/Traits/PluginConfigurationTrait.php
Normal file
67
src/Builder/Traits/PluginConfigurationTrait.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Builder\Traits;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Contracts\MaskingPluginInterface;
|
||||
|
||||
/**
|
||||
* Provides plugin configuration methods for GdprProcessorBuilder.
|
||||
*
|
||||
* Handles registration and management of masking plugins that can extend
|
||||
* the processor's functionality with custom patterns and field paths.
|
||||
*/
|
||||
trait PluginConfigurationTrait
|
||||
{
|
||||
/**
|
||||
* @var list<MaskingPluginInterface>
|
||||
*/
|
||||
private array $plugins = [];
|
||||
|
||||
/**
|
||||
* Register a masking plugin.
|
||||
*/
|
||||
public function addPlugin(MaskingPluginInterface $plugin): self
|
||||
{
|
||||
$this->plugins[] = $plugin;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register multiple masking plugins.
|
||||
*
|
||||
* @param list<MaskingPluginInterface> $plugins
|
||||
*/
|
||||
public function addPlugins(array $plugins): self
|
||||
{
|
||||
foreach ($plugins as $plugin) {
|
||||
$this->plugins[] = $plugin;
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get registered plugins.
|
||||
*
|
||||
* @return list<MaskingPluginInterface>
|
||||
*/
|
||||
public function getPlugins(): array
|
||||
{
|
||||
return $this->plugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply plugin patterns and field paths to the builder configuration.
|
||||
*/
|
||||
private function applyPluginConfigurations(): void
|
||||
{
|
||||
// Sort plugins by priority before applying
|
||||
usort($this->plugins, fn($a, $b): int => $a->getPriority() <=> $b->getPriority());
|
||||
|
||||
foreach ($this->plugins as $plugin) {
|
||||
$this->patterns = array_merge($this->patterns, $plugin->getPatterns());
|
||||
$this->fieldPaths = array_merge($this->fieldPaths, $plugin->getFieldPaths());
|
||||
}
|
||||
}
|
||||
}
|
||||
117
src/ConditionalRuleFactory.php
Normal file
117
src/ConditionalRuleFactory.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\ArrayAccessor\ArrayAccessorFactory;
|
||||
use Closure;
|
||||
use Monolog\LogRecord;
|
||||
|
||||
/**
|
||||
* Factory for creating conditional masking rules.
|
||||
*
|
||||
* This class provides methods to create various types of
|
||||
* conditional rules that determine when masking should be applied.
|
||||
*
|
||||
* Can be used as an instance (for DI) or via static methods (backward compatible).
|
||||
*/
|
||||
final class ConditionalRuleFactory
|
||||
{
|
||||
private readonly ArrayAccessorFactory $accessorFactory;
|
||||
|
||||
public function __construct(?ArrayAccessorFactory $accessorFactory = null)
|
||||
{
|
||||
$this->accessorFactory = $accessorFactory ?? ArrayAccessorFactory::default();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a conditional rule based on log level.
|
||||
*
|
||||
* @param array<string> $levels Log levels that should trigger masking
|
||||
*
|
||||
* @psalm-return Closure(LogRecord):bool
|
||||
*/
|
||||
public static function createLevelBasedRule(array $levels): Closure
|
||||
{
|
||||
return fn(LogRecord $record): bool => in_array($record->level->name, $levels, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a conditional rule based on context field presence.
|
||||
*
|
||||
* @param string $fieldPath Dot-notation path to check
|
||||
*
|
||||
* @psalm-return Closure(LogRecord):bool
|
||||
*/
|
||||
public static function createContextFieldRule(string $fieldPath): Closure
|
||||
{
|
||||
$factory = ArrayAccessorFactory::default();
|
||||
return function (LogRecord $record) use ($fieldPath, $factory): bool {
|
||||
$accessor = $factory->create($record->context);
|
||||
return $accessor->has($fieldPath);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a conditional rule based on context field value.
|
||||
*
|
||||
* @param string $fieldPath Dot-notation path to check
|
||||
* @param mixed $expectedValue Expected value
|
||||
*
|
||||
* @psalm-return Closure(LogRecord):bool
|
||||
*/
|
||||
public static function createContextValueRule(string $fieldPath, mixed $expectedValue): Closure
|
||||
{
|
||||
$factory = ArrayAccessorFactory::default();
|
||||
return function (LogRecord $record) use ($fieldPath, $expectedValue, $factory): bool {
|
||||
$accessor = $factory->create($record->context);
|
||||
return $accessor->get($fieldPath) === $expectedValue;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a conditional rule based on channel name.
|
||||
*
|
||||
* @param array<string> $channels Channel names that should trigger masking
|
||||
*
|
||||
* @psalm-return Closure(LogRecord):bool
|
||||
*/
|
||||
public static function createChannelBasedRule(array $channels): Closure
|
||||
{
|
||||
return fn(LogRecord $record): bool => in_array($record->channel, $channels, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance method: Create a context field presence rule.
|
||||
*
|
||||
* @param string $fieldPath Dot-notation path to check
|
||||
*
|
||||
* @psalm-return Closure(LogRecord):bool
|
||||
*/
|
||||
public function contextFieldRule(string $fieldPath): Closure
|
||||
{
|
||||
$factory = $this->accessorFactory;
|
||||
return function (LogRecord $record) use ($fieldPath, $factory): bool {
|
||||
$accessor = $factory->create($record->context);
|
||||
return $accessor->has($fieldPath);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance method: Create a context field value rule.
|
||||
*
|
||||
* @param string $fieldPath Dot-notation path to check
|
||||
* @param mixed $expectedValue Expected value
|
||||
*
|
||||
* @psalm-return Closure(LogRecord):bool
|
||||
*/
|
||||
public function contextValueRule(string $fieldPath, mixed $expectedValue): Closure
|
||||
{
|
||||
$factory = $this->accessorFactory;
|
||||
return function (LogRecord $record) use ($fieldPath, $expectedValue, $factory): bool {
|
||||
$accessor = $factory->create($record->context);
|
||||
return $accessor->get($fieldPath) === $expectedValue;
|
||||
};
|
||||
}
|
||||
}
|
||||
171
src/ContextProcessor.php
Normal file
171
src/ContextProcessor.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Contracts\ArrayAccessorInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Handles context field processing operations for GDPR masking.
|
||||
*
|
||||
* This class extracts field-level masking logic from GdprProcessor
|
||||
* to reduce the main class's method count and improve separation of concerns.
|
||||
*
|
||||
* @internal This class is for internal use within the GDPR processor
|
||||
*/
|
||||
class ContextProcessor
|
||||
{
|
||||
/**
|
||||
* @param array<string,FieldMaskConfig|string> $fieldPaths Dot-notation path => FieldMaskConfig
|
||||
* @param array<string,callable(mixed):string> $customCallbacks Dot-notation path => callback
|
||||
* @param callable(string,mixed,mixed):void|null $auditLogger Optional audit logger callback
|
||||
* @param \Closure(string):string $regexProcessor Function to process strings with regex patterns
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $fieldPaths,
|
||||
private readonly array $customCallbacks,
|
||||
private $auditLogger,
|
||||
private readonly \Closure $regexProcessor
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask field paths in the context using the configured field masks.
|
||||
*
|
||||
* @param ArrayAccessorInterface $accessor
|
||||
* @return string[] Array of processed field paths
|
||||
* @psalm-return list<string>
|
||||
*/
|
||||
public function maskFieldPaths(ArrayAccessorInterface $accessor): array
|
||||
{
|
||||
$processedFields = [];
|
||||
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);
|
||||
$processedFields[] = $path;
|
||||
continue;
|
||||
}
|
||||
|
||||
$masked = $action['masked'];
|
||||
if ($masked !== null && $masked !== $value) {
|
||||
$accessor->set($path, $masked);
|
||||
$this->logAudit($path, $value, $masked);
|
||||
}
|
||||
|
||||
$processedFields[] = $path;
|
||||
}
|
||||
|
||||
return $processedFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process custom callbacks on context fields.
|
||||
*
|
||||
* @param ArrayAccessorInterface $accessor
|
||||
* @return string[] Array of processed field paths
|
||||
* @psalm-return list<string>
|
||||
*/
|
||||
public function processCustomCallbacks(ArrayAccessorInterface $accessor): array
|
||||
{
|
||||
$processedFields = [];
|
||||
foreach ($this->customCallbacks as $path => $callback) {
|
||||
if (!$accessor->has($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $accessor->get($path);
|
||||
try {
|
||||
$masked = $callback($value);
|
||||
if ($masked !== $value) {
|
||||
$accessor->set($path, $masked);
|
||||
$this->logAudit($path, $value, $masked);
|
||||
}
|
||||
|
||||
$processedFields[] = $path;
|
||||
} catch (Throwable $e) {
|
||||
// Log callback error but continue processing
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($e->getMessage());
|
||||
$errorMsg = 'Callback failed: ' . $sanitized;
|
||||
$this->logAudit($path . '_callback_error', $value, $errorMsg);
|
||||
$processedFields[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
return $processedFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask a single value according to config or callback.
|
||||
* Returns an array: ['masked' => value|null, 'remove' => bool]
|
||||
*
|
||||
* @psalm-return array{masked: mixed, remove: bool}
|
||||
* @psalm-param mixed $value
|
||||
*/
|
||||
public function maskValue(string $path, mixed $value, FieldMaskConfig|string|null $config): array
|
||||
{
|
||||
$result = ['masked' => null, 'remove' => false];
|
||||
if (array_key_exists($path, $this->customCallbacks)) {
|
||||
$callback = $this->customCallbacks[$path];
|
||||
$result['masked'] = $callback($value);
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ($config instanceof FieldMaskConfig) {
|
||||
switch ($config->type) {
|
||||
case FieldMaskConfig::MASK_REGEX:
|
||||
$result['masked'] = ($this->regexProcessor)((string) $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
|
||||
*/
|
||||
public 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the audit logger callable.
|
||||
*
|
||||
* @param callable(string,mixed,mixed):void|null $auditLogger
|
||||
*/
|
||||
public function setAuditLogger(?callable $auditLogger): void
|
||||
{
|
||||
$this->auditLogger = $auditLogger;
|
||||
}
|
||||
}
|
||||
54
src/Contracts/ArrayAccessorInterface.php
Normal file
54
src/Contracts/ArrayAccessorInterface.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Contracts;
|
||||
|
||||
/**
|
||||
* Interface for dot-notation array access.
|
||||
*
|
||||
* This abstraction allows swapping the underlying implementation
|
||||
* (e.g., Adbar\Dot) without modifying consuming code.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
interface ArrayAccessorInterface
|
||||
{
|
||||
/**
|
||||
* Check if a key exists using dot notation.
|
||||
*
|
||||
* @param string $path Dot-notation path (e.g., "user.email")
|
||||
*/
|
||||
public function has(string $path): bool;
|
||||
|
||||
/**
|
||||
* Get a value using dot notation.
|
||||
*
|
||||
* @param string $path Dot-notation path (e.g., "user.email")
|
||||
* @param mixed $default Default value if path doesn't exist
|
||||
* @return mixed The value at the path or default
|
||||
*/
|
||||
public function get(string $path, mixed $default = null): mixed;
|
||||
|
||||
/**
|
||||
* Set a value using dot notation.
|
||||
*
|
||||
* @param string $path Dot-notation path (e.g., "user.email")
|
||||
* @param mixed $value Value to set
|
||||
*/
|
||||
public function set(string $path, mixed $value): void;
|
||||
|
||||
/**
|
||||
* Delete a value using dot notation.
|
||||
*
|
||||
* @param string $path Dot-notation path (e.g., "user.email")
|
||||
*/
|
||||
public function delete(string $path): void;
|
||||
|
||||
/**
|
||||
* Get all data as an array.
|
||||
*
|
||||
* @return array<string, mixed> The complete data array
|
||||
*/
|
||||
public function all(): array;
|
||||
}
|
||||
72
src/Contracts/MaskingPluginInterface.php
Normal file
72
src/Contracts/MaskingPluginInterface.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Contracts;
|
||||
|
||||
/**
|
||||
* Interface for masking plugins that can extend GdprProcessor functionality.
|
||||
*
|
||||
* Plugins can hook into the masking process at various points to add
|
||||
* custom masking logic, transformations, or integrations.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
interface MaskingPluginInterface
|
||||
{
|
||||
/**
|
||||
* Get the unique plugin identifier.
|
||||
*/
|
||||
public function getName(): string;
|
||||
|
||||
/**
|
||||
* Process context data before standard masking is applied.
|
||||
*
|
||||
* @param array<string,mixed> $context The context data
|
||||
* @return array<string,mixed> The modified context data
|
||||
*/
|
||||
public function preProcessContext(array $context): array;
|
||||
|
||||
/**
|
||||
* Process context data after standard masking is applied.
|
||||
*
|
||||
* @param array<string,mixed> $context The masked context data
|
||||
* @return array<string,mixed> The modified context data
|
||||
*/
|
||||
public function postProcessContext(array $context): array;
|
||||
|
||||
/**
|
||||
* Process message before standard masking is applied.
|
||||
*
|
||||
* @param string $message The original message
|
||||
* @return string The modified message
|
||||
*/
|
||||
public function preProcessMessage(string $message): string;
|
||||
|
||||
/**
|
||||
* Process message after standard masking is applied.
|
||||
*
|
||||
* @param string $message The masked message
|
||||
* @return string The modified message
|
||||
*/
|
||||
public function postProcessMessage(string $message): string;
|
||||
|
||||
/**
|
||||
* Get additional patterns to add to the processor.
|
||||
*
|
||||
* @return array<string,string> Regex pattern => replacement
|
||||
*/
|
||||
public function getPatterns(): array;
|
||||
|
||||
/**
|
||||
* Get additional field paths to mask.
|
||||
*
|
||||
* @return array<string,\Ivuorinen\MonologGdprFilter\FieldMaskConfig|string>
|
||||
*/
|
||||
public function getFieldPaths(): array;
|
||||
|
||||
/**
|
||||
* Get the plugin's priority (lower = earlier execution).
|
||||
*/
|
||||
public function getPriority(): int;
|
||||
}
|
||||
195
src/DataTypeMasker.php
Normal file
195
src/DataTypeMasker.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants as Mask;
|
||||
|
||||
/**
|
||||
* Handles data type-based masking of values.
|
||||
*
|
||||
* This class applies masking based on PHP data types
|
||||
* according to configured masking rules.
|
||||
*/
|
||||
final class DataTypeMasker
|
||||
{
|
||||
/**
|
||||
* @param array<string,string> $dataTypeMasks Type-based masking: type => mask pattern
|
||||
* @param callable(string, mixed, mixed):void|null $auditLogger
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $dataTypeMasks,
|
||||
private $auditLogger = null
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get default data type masking configuration.
|
||||
*
|
||||
* @return string[]
|
||||
*
|
||||
* @psalm-return array{
|
||||
* integer: '***INT***',
|
||||
* double: '***FLOAT***',
|
||||
* string: '***STRING***',
|
||||
* boolean: '***BOOL***',
|
||||
* NULL: '***NULL***',
|
||||
* array: '***ARRAY***',
|
||||
* object: '***OBJECT***',
|
||||
* resource: '***RESOURCE***'
|
||||
* }
|
||||
*/
|
||||
public static function getDefaultMasks(): array
|
||||
{
|
||||
return [
|
||||
'integer' => Mask::MASK_INT,
|
||||
'double' => Mask::MASK_FLOAT,
|
||||
'string' => Mask::MASK_STRING,
|
||||
'boolean' => Mask::MASK_BOOL,
|
||||
'NULL' => Mask::MASK_NULL,
|
||||
'array' => Mask::MASK_ARRAY,
|
||||
'object' => Mask::MASK_OBJECT,
|
||||
'resource' => Mask::MASK_RESOURCE,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply data type-based masking to a value.
|
||||
*
|
||||
* @param mixed $value The value to mask.
|
||||
* @param (callable(array<mixed>|string, int=):(array<mixed>|string))|null $recursiveMaskCallback
|
||||
* @return mixed The masked value.
|
||||
*
|
||||
* @psalm-param mixed $value The value to mask.
|
||||
*/
|
||||
public function applyMasking(mixed $value, ?callable $recursiveMaskCallback = null): mixed
|
||||
{
|
||||
if ($this->dataTypeMasks === []) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$type = gettype($value);
|
||||
|
||||
if (!isset($this->dataTypeMasks[$type])) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$mask = $this->dataTypeMasks[$type];
|
||||
|
||||
// Special handling for different types
|
||||
return match ($type) {
|
||||
'integer' => is_numeric($mask) ? (int)$mask : $mask,
|
||||
'double' => is_numeric($mask) ? (float)$mask : $mask,
|
||||
'boolean' => $this->maskBoolean($mask, $value),
|
||||
'NULL' => $mask === 'preserve' ? null : $mask,
|
||||
'array' => $this->maskArray($mask, $value, $recursiveMaskCallback),
|
||||
'object' => (object) ['masked' => $mask, 'original_class' => $value::class],
|
||||
default => $mask,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask a boolean value.
|
||||
*/
|
||||
private function maskBoolean(string $mask, bool $value): bool|string
|
||||
{
|
||||
if ($mask === 'preserve') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($mask === 'true') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($mask === 'false') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $mask;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask an array value.
|
||||
*
|
||||
* @param array<mixed> $value
|
||||
* @param (callable(array<mixed>|string, int=):(array<mixed>|string))|null $recursiveMaskCallback
|
||||
* @return array<mixed>|string
|
||||
*/
|
||||
private function maskArray(string $mask, array $value, ?callable $recursiveMaskCallback): array|string
|
||||
{
|
||||
// For arrays, we can return a masked indicator or process recursively
|
||||
if ($mask === 'recursive' && $recursiveMaskCallback !== null) {
|
||||
return $recursiveMaskCallback($value, 0);
|
||||
}
|
||||
|
||||
return [$mask];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply data type masking to an entire context structure.
|
||||
*
|
||||
* @param array<mixed> $context
|
||||
* @param array<string> $processedFields Array of field paths already processed
|
||||
* @param string $currentPath Current dot-notation path for nested processing
|
||||
* @param (callable(array<mixed>|string, int=):(array<mixed>|string))|null $recursiveMaskCallback
|
||||
* @return array<mixed>
|
||||
*/
|
||||
public function applyToContext(
|
||||
array $context,
|
||||
array $processedFields = [],
|
||||
string $currentPath = '',
|
||||
?callable $recursiveMaskCallback = null
|
||||
): array {
|
||||
$result = [];
|
||||
foreach ($context as $key => $value) {
|
||||
$fieldPath = $currentPath === '' ? (string)$key : $currentPath . '.' . $key;
|
||||
|
||||
// Skip fields that have already been processed by field paths or custom callbacks
|
||||
if (in_array($fieldPath, $processedFields, true)) {
|
||||
$result[$key] = $value;
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[$key] = $this->processFieldValue(
|
||||
$value,
|
||||
$fieldPath,
|
||||
$processedFields,
|
||||
$recursiveMaskCallback
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single field value, applying masking if applicable.
|
||||
*
|
||||
* @param mixed $value
|
||||
* @param string $fieldPath
|
||||
* @param array<string> $processedFields
|
||||
* @param (callable(array<mixed>|string, int=):(array<mixed>|string))|null $recursiveMaskCallback
|
||||
* @return mixed
|
||||
*/
|
||||
private function processFieldValue(
|
||||
mixed $value,
|
||||
string $fieldPath,
|
||||
array $processedFields,
|
||||
?callable $recursiveMaskCallback
|
||||
): mixed {
|
||||
if (is_array($value)) {
|
||||
return $this->applyToContext($value, $processedFields, $fieldPath, $recursiveMaskCallback);
|
||||
}
|
||||
|
||||
$type = gettype($value);
|
||||
if (!isset($this->dataTypeMasks[$type])) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$masked = $this->applyMasking($value, $recursiveMaskCallback);
|
||||
if ($masked !== $value && $this->auditLogger !== null) {
|
||||
($this->auditLogger)($fieldPath, $value, $masked);
|
||||
}
|
||||
|
||||
return $masked;
|
||||
}
|
||||
}
|
||||
81
src/DefaultPatterns.php
Normal file
81
src/DefaultPatterns.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants as Mask;
|
||||
|
||||
/**
|
||||
* Provides default GDPR regex patterns for common sensitive data types.
|
||||
*/
|
||||
final class DefaultPatterns
|
||||
{
|
||||
/**
|
||||
* Get default GDPR regex patterns. Non-exhaustive, should be extended with your own.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function get(): array
|
||||
{
|
||||
return [
|
||||
// Finnish SSN (HETU)
|
||||
'/\b\d{6}[-+A]?\d{3}[A-Z]\b/u' => Mask::MASK_HETU,
|
||||
// US Social Security Number (strict: 3-2-4 digits)
|
||||
'/^\d{3}-\d{2}-\d{4}$/' => Mask::MASK_USSSN,
|
||||
// IBAN (strictly match Finnish IBAN with or without spaces, only valid groupings)
|
||||
'/^FI\d{2}(?: ?\d{4}){3} ?\d{2}$/u' => Mask::MASK_IBAN,
|
||||
// Also match fully compact Finnish IBAN (no spaces)
|
||||
'/^FI\d{16}$/u' => Mask::MASK_IBAN,
|
||||
// International phone numbers (E.164, +countrycode...)
|
||||
'/^\+\d{1,3}[\s-]?\d{1,4}[\s-]?\d{1,4}[\s-]?\d{1,9}$/' => Mask::MASK_PHONE,
|
||||
// Email address
|
||||
'/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/' => Mask::MASK_EMAIL,
|
||||
// Date of birth (YYYY-MM-DD)
|
||||
'/^(19|20)\d{2}-[01]\d\-[0-3]\d$/' => Mask::MASK_DOB,
|
||||
// Date of birth (DD/MM/YYYY)
|
||||
'/^[0-3]\d\/[01]\d\/(19|20)\d{2}$/' => Mask::MASK_DOB,
|
||||
// Passport numbers (A followed by 6 digits)
|
||||
'/^A\d{6}$/' => Mask::MASK_PASSPORT,
|
||||
// Credit card numbers (Visa, MC, Amex, Discover test numbers)
|
||||
'/^(4111 1111 1111 1111|5500-0000-0000-0004|340000000000009|6011000000000004)$/' => Mask::MASK_CC,
|
||||
// Generic 16-digit credit card (for test compatibility)
|
||||
'/\b[0-9]{16}\b/u' => Mask::MASK_CC,
|
||||
// Bearer tokens (JWT, at least 10 chars after Bearer)
|
||||
'/^Bearer [A-Za-z0-9\-\._~\+\/]{10,}$/' => Mask::MASK_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,})$/' => Mask::MASK_APIKEY,
|
||||
// MAC addresses
|
||||
'/^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/' => Mask::MASK_MAC,
|
||||
|
||||
// IP Addresses
|
||||
// IPv4 address (dotted decimal notation)
|
||||
'/\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b/' => '***IPv4***',
|
||||
|
||||
// Vehicle Registration Numbers (more specific patterns)
|
||||
// US License plates (specific formats: ABC-1234, ABC1234)
|
||||
'/\b[A-Z]{2,3}[-\s]?\d{3,4}\b/' => Mask::MASK_VEHICLE,
|
||||
// Reverse format (123-ABC)
|
||||
'/\b\d{3,4}[-\s]?[A-Z]{2,3}\b/' => Mask::MASK_VEHICLE,
|
||||
|
||||
// National ID Numbers
|
||||
// UK National Insurance Number (2 letters, 6 digits, 1 letter)
|
||||
'/\b[A-Z]{2}\d{6}[A-Z]\b/' => Mask::MASK_UKNI,
|
||||
// Canadian Social Insurance Number (3-3-3 format)
|
||||
'/\b\d{3}[-\s]\d{3}[-\s]\d{3}\b/' => Mask::MASK_CASIN,
|
||||
// UK Sort Code + Account (6 digits + 8 digits)
|
||||
'/\b\d{6}[-\s]\d{8}\b/' => Mask::MASK_UKBANK,
|
||||
// Canadian Transit + Account (5 digits + 7-12 digits)
|
||||
'/\b\d{5}[-\s]\d{7,12}\b/' => Mask::MASK_CABANK,
|
||||
|
||||
// Health Insurance Numbers
|
||||
// US Medicare number (various formats)
|
||||
'/\b\d{3}[-\s]\d{2}[-\s]\d{4}\b/' => Mask::MASK_MEDICARE,
|
||||
// European Health Insurance Card (starts with country code)
|
||||
'/\b\d{2}[-\s]\d{4}[-\s]\d{4}[-\s]\d{4}[-\s]\d{1,4}\b/' => Mask::MASK_EHIC,
|
||||
|
||||
// IPv6 address (specific pattern with colons)
|
||||
'/\b[0-9a-fA-F]{1,4}:[0-9a-fA-F:]{7,35}\b/' => '***IPv6***',
|
||||
];
|
||||
}
|
||||
}
|
||||
167
src/Exceptions/AuditLoggingException.php
Normal file
167
src/Exceptions/AuditLoggingException.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Exceptions;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Exception thrown when audit logging operations fail.
|
||||
*
|
||||
* This exception is thrown when:
|
||||
* - An audit logger callback throws an exception
|
||||
* - Audit log data cannot be serialized
|
||||
* - Rate-limited audit logging encounters errors
|
||||
* - Audit logger configuration is invalid
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class AuditLoggingException extends GdprProcessorException
|
||||
{
|
||||
/**
|
||||
* Create an exception for a failed audit logging callback.
|
||||
*
|
||||
* @param string $path The field path being audited
|
||||
* @param mixed $original The original value
|
||||
* @param mixed $masked The masked value
|
||||
* @param string $reason The reason for the failure
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function callbackFailed(
|
||||
string $path,
|
||||
mixed $original,
|
||||
mixed $masked,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Audit logging callback failed for path '%s': %s", $path, $reason);
|
||||
|
||||
return self::withContext($message, [
|
||||
'audit_type' => 'callback_failure',
|
||||
'path' => $path,
|
||||
'original_type' => gettype($original),
|
||||
'masked_type' => gettype($masked),
|
||||
'original_preview' => self::getValuePreview($original),
|
||||
'masked_preview' => self::getValuePreview($masked),
|
||||
'reason' => $reason,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for audit data serialization failure.
|
||||
*
|
||||
* @param string $path The field path being audited
|
||||
* @param mixed $value The value that failed to serialize
|
||||
* @param string $reason The reason for the serialization failure
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function serializationFailed(
|
||||
string $path,
|
||||
mixed $value,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Audit data serialization failed for path '%s': %s", $path, $reason);
|
||||
|
||||
return self::withContext($message, [
|
||||
'audit_type' => 'serialization_failure',
|
||||
'path' => $path,
|
||||
'value_type' => gettype($value),
|
||||
'reason' => $reason,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for rate-limited audit logging failures.
|
||||
*
|
||||
* @param string $operationType The operation type being rate limited
|
||||
* @param int $currentRequests Current number of requests
|
||||
* @param int $maxRequests Maximum allowed requests
|
||||
* @param string $reason The reason for the failure
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function rateLimitingFailed(
|
||||
string $operationType,
|
||||
int $currentRequests,
|
||||
int $maxRequests,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Rate-limited audit logging failed for operation '%s': %s", $operationType, $reason);
|
||||
|
||||
return self::withContext($message, [
|
||||
'audit_type' => 'rate_limiting_failure',
|
||||
'operation_type' => $operationType,
|
||||
'current_requests' => $currentRequests,
|
||||
'max_requests' => $maxRequests,
|
||||
'reason' => $reason,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for invalid audit logger configuration.
|
||||
*
|
||||
* @param string $configurationIssue Description of the configuration issue
|
||||
* @param array<string, mixed> $config The invalid configuration
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function invalidConfiguration(
|
||||
string $configurationIssue,
|
||||
array $config,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = 'Invalid audit logger configuration: ' . $configurationIssue;
|
||||
|
||||
return self::withContext($message, [
|
||||
'audit_type' => 'configuration_error',
|
||||
'configuration_issue' => $configurationIssue,
|
||||
'config' => $config,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for audit logger creation failure.
|
||||
*
|
||||
* @param string $loggerType The type of logger being created
|
||||
* @param string $reason The reason for the creation failure
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function loggerCreationFailed(
|
||||
string $loggerType,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Audit logger creation failed for type '%s': %s", $loggerType, $reason);
|
||||
|
||||
return self::withContext($message, [
|
||||
'audit_type' => 'logger_creation_failure',
|
||||
'logger_type' => $loggerType,
|
||||
'reason' => $reason,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a safe preview of a value for logging.
|
||||
*
|
||||
* @param mixed $value The value to preview
|
||||
* @return string Safe preview string
|
||||
*/
|
||||
private static function getValuePreview(mixed $value): string
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return substr($value, 0, 100) . (strlen($value) > 100 ? '...' : '');
|
||||
}
|
||||
|
||||
if (is_array($value) || is_object($value)) {
|
||||
$json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR);
|
||||
if ($json === false) {
|
||||
return '[Unable to serialize]';
|
||||
}
|
||||
|
||||
return substr($json, 0, 100) . (strlen($json) > 100 ? '...' : '');
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
}
|
||||
135
src/Exceptions/CommandExecutionException.php
Normal file
135
src/Exceptions/CommandExecutionException.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Exceptions;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Exception thrown when command execution fails.
|
||||
*
|
||||
* This exception is thrown when:
|
||||
* - Artisan commands encounter runtime errors
|
||||
* - Command input validation fails
|
||||
* - Command operations fail during execution
|
||||
* - Command result processing fails
|
||||
* - File operations within commands fail
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class CommandExecutionException extends GdprProcessorException
|
||||
{
|
||||
/**
|
||||
* Create an exception for command input validation failure.
|
||||
*
|
||||
* @param string $commandName The command that failed
|
||||
* @param string $inputName The input parameter that failed validation
|
||||
* @param mixed $inputValue The invalid input value
|
||||
* @param string $reason The reason for validation failure
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function forInvalidInput(
|
||||
string $commandName,
|
||||
string $inputName,
|
||||
mixed $inputValue,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf(
|
||||
"Command '%s' failed: invalid input '%s' - %s",
|
||||
$commandName,
|
||||
$inputName,
|
||||
$reason
|
||||
);
|
||||
|
||||
return self::withContext($message, [
|
||||
'command_name' => $commandName,
|
||||
'input_name' => $inputName,
|
||||
'input_value' => $inputValue,
|
||||
'reason' => $reason,
|
||||
'category' => 'input_validation',
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for command operation failure.
|
||||
*
|
||||
* @param string $commandName The command that failed
|
||||
* @param string $operation The operation that failed
|
||||
* @param string $reason The reason for failure
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function forOperation(
|
||||
string $commandName,
|
||||
string $operation,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf(
|
||||
"Command '%s' failed during operation '%s': %s",
|
||||
$commandName,
|
||||
$operation,
|
||||
$reason
|
||||
);
|
||||
|
||||
return self::withContext($message, [
|
||||
'command_name' => $commandName,
|
||||
'operation' => $operation,
|
||||
'reason' => $reason,
|
||||
'category' => 'operation_failure',
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for pattern testing failure.
|
||||
*
|
||||
* @param string $pattern The pattern that failed testing
|
||||
* @param string $testString The test string used
|
||||
* @param string $reason The reason for test failure
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function forPatternTest(
|
||||
string $pattern,
|
||||
string $testString,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Pattern test failed for '%s': %s", $pattern, $reason);
|
||||
|
||||
return self::withContext($message, [
|
||||
'pattern' => $pattern,
|
||||
'test_string' => $testString,
|
||||
'reason' => $reason,
|
||||
'category' => 'pattern_test',
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for JSON processing failure in commands.
|
||||
*
|
||||
* @param string $commandName The command that failed
|
||||
* @param string $jsonData The JSON data being processed
|
||||
* @param string $reason The reason for JSON processing failure
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function forJsonProcessing(
|
||||
string $commandName,
|
||||
string $jsonData,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf(
|
||||
"Command '%s' failed to process JSON data: %s",
|
||||
$commandName,
|
||||
$reason
|
||||
);
|
||||
|
||||
return self::withContext($message, [
|
||||
'command_name' => $commandName,
|
||||
'json_data' => $jsonData,
|
||||
'reason' => $reason,
|
||||
'category' => 'json_processing',
|
||||
], 0, $previous);
|
||||
}
|
||||
}
|
||||
63
src/Exceptions/GdprProcessorException.php
Normal file
63
src/Exceptions/GdprProcessorException.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Exceptions;
|
||||
|
||||
use Throwable;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Base exception class for all GDPR processor related errors.
|
||||
*
|
||||
* This serves as the parent class for all specific GDPR processing exceptions,
|
||||
* allowing consumers to catch all GDPR-related errors with a single catch block.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class GdprProcessorException extends Exception
|
||||
{
|
||||
/**
|
||||
* Create a new GDPR processor exception.
|
||||
*
|
||||
* @param string $message The exception message
|
||||
* @param int $code The exception code (default: 0)
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public function __construct(string $message = '', int $code = 0, ?Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception with additional context information.
|
||||
*
|
||||
* @param string $message The base exception message
|
||||
* @param array<string, mixed> $context Additional context data
|
||||
* @param int $code The exception code (default: 0)
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function withContext(
|
||||
string $message,
|
||||
array $context,
|
||||
int $code = 0,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$contextString = '';
|
||||
if ($context !== []) {
|
||||
$contextParts = [];
|
||||
foreach ($context as $key => $value) {
|
||||
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES);
|
||||
$contextParts[] = $key . ': ' . ($encoded === false ? '[unserializable]' : $encoded);
|
||||
}
|
||||
|
||||
$contextString = ' [Context: ' . implode(', ', $contextParts) . ']';
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-suppress UnsafeInstantiation
|
||||
* @phpstan-ignore new.static
|
||||
*/
|
||||
return new static($message . $contextString, $code, $previous);
|
||||
}
|
||||
}
|
||||
181
src/Exceptions/InvalidConfigurationException.php
Normal file
181
src/Exceptions/InvalidConfigurationException.php
Normal file
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Exceptions;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Exception thrown when GDPR processor configuration is invalid.
|
||||
*
|
||||
* This exception is thrown when:
|
||||
* - Invalid field paths are provided
|
||||
* - Invalid data type masks are specified
|
||||
* - Invalid conditional rules are configured
|
||||
* - Configuration values are out of acceptable ranges
|
||||
* - Configuration structure is malformed
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class InvalidConfigurationException extends GdprProcessorException
|
||||
{
|
||||
/**
|
||||
* Create an exception for an invalid field path.
|
||||
*
|
||||
* @param string $fieldPath The invalid field path
|
||||
* @param string $reason The reason why the field path is invalid
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function forFieldPath(
|
||||
string $fieldPath,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Invalid field path '%s': %s", $fieldPath, $reason);
|
||||
|
||||
return self::withContext($message, [
|
||||
'field_path' => $fieldPath,
|
||||
'reason' => $reason,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for an invalid data type mask.
|
||||
*
|
||||
* @param string $dataType The invalid data type
|
||||
* @param mixed $mask The invalid mask value
|
||||
* @param string $reason The reason why the mask is invalid
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function forDataTypeMask(
|
||||
string $dataType,
|
||||
mixed $mask,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Invalid data type mask for '%s': %s", $dataType, $reason);
|
||||
|
||||
return self::withContext($message, [
|
||||
'data_type' => $dataType,
|
||||
'mask' => $mask,
|
||||
'reason' => $reason,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for an invalid conditional rule.
|
||||
*
|
||||
* @param string $ruleName The invalid rule name
|
||||
* @param string $reason The reason why the rule is invalid
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function forConditionalRule(
|
||||
string $ruleName,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Invalid conditional rule '%s': %s", $ruleName, $reason);
|
||||
|
||||
return self::withContext($message, [
|
||||
'rule_name' => $ruleName,
|
||||
'reason' => $reason,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for an invalid configuration value.
|
||||
*
|
||||
* @param string $parameter The parameter name
|
||||
* @param mixed $value The invalid value
|
||||
* @param string $reason The reason why the value is invalid
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function forParameter(
|
||||
string $parameter,
|
||||
mixed $value,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Invalid configuration parameter '%s': %s", $parameter, $reason);
|
||||
|
||||
return self::withContext($message, [
|
||||
'parameter' => $parameter,
|
||||
'value' => $value,
|
||||
'reason' => $reason,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for an empty or null required value.
|
||||
*
|
||||
* @param string $parameter The parameter name that cannot be empty
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function emptyValue(
|
||||
string $parameter,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("%s cannot be empty", ucfirst($parameter));
|
||||
|
||||
return self::withContext($message, [
|
||||
'parameter' => $parameter,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for a value that exceeds maximum allowed length.
|
||||
*
|
||||
* @param string $parameter The parameter name
|
||||
* @param int $actualLength The actual length
|
||||
* @param int $maxLength The maximum allowed length
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function exceedsMaxLength(
|
||||
string $parameter,
|
||||
int $actualLength,
|
||||
int $maxLength,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf(
|
||||
"%s length (%d) exceeds maximum allowed length (%d)",
|
||||
ucfirst($parameter),
|
||||
$actualLength,
|
||||
$maxLength
|
||||
);
|
||||
|
||||
return self::withContext($message, [
|
||||
'parameter' => $parameter,
|
||||
'actual_length' => $actualLength,
|
||||
'max_length' => $maxLength,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for an invalid type.
|
||||
*
|
||||
* @param string $parameter The parameter name
|
||||
* @param string $expectedType The expected type
|
||||
* @param string $actualType The actual type
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function invalidType(
|
||||
string $parameter,
|
||||
string $expectedType,
|
||||
string $actualType,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf(
|
||||
"%s must be of type %s, got %s",
|
||||
ucfirst($parameter),
|
||||
$expectedType,
|
||||
$actualType
|
||||
);
|
||||
|
||||
return self::withContext($message, [
|
||||
'parameter' => $parameter,
|
||||
'expected_type' => $expectedType,
|
||||
'actual_type' => $actualType,
|
||||
], 0, $previous);
|
||||
}
|
||||
}
|
||||
203
src/Exceptions/InvalidRateLimitConfigurationException.php
Normal file
203
src/Exceptions/InvalidRateLimitConfigurationException.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Exceptions;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Exception thrown when rate limiter configuration is invalid.
|
||||
*
|
||||
* This exception is thrown when:
|
||||
* - Maximum requests value is invalid
|
||||
* - Time window value is invalid
|
||||
* - Cleanup interval value is invalid
|
||||
* - Rate limiting key is invalid or contains forbidden characters
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class InvalidRateLimitConfigurationException extends GdprProcessorException
|
||||
{
|
||||
/**
|
||||
* Create an exception for an invalid maximum requests value.
|
||||
*
|
||||
* @param int|float|string $value The invalid value
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function invalidMaxRequests(
|
||||
int|float|string $value,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf('Maximum requests must be a positive integer, got: %s', $value);
|
||||
|
||||
return self::withContext($message, [
|
||||
'parameter' => 'max_requests',
|
||||
'value' => $value,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for an invalid time window value.
|
||||
*
|
||||
* @param int|float|string $value The invalid value
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function invalidTimeWindow(
|
||||
int|float|string $value,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf(
|
||||
'Time window must be a positive integer representing seconds, got: %s',
|
||||
$value
|
||||
);
|
||||
|
||||
return self::withContext($message, [
|
||||
'parameter' => 'time_window',
|
||||
'value' => $value,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for an invalid cleanup interval.
|
||||
*
|
||||
* @param int|float|string $value The invalid value
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function invalidCleanupInterval(
|
||||
int|float|string $value,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf('Cleanup interval must be a positive integer, got: %s', $value);
|
||||
|
||||
return self::withContext($message, [
|
||||
'parameter' => 'cleanup_interval',
|
||||
'value' => $value,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for a time window that is too short.
|
||||
*
|
||||
* @param int $value The time window value
|
||||
* @param int $minimum The minimum allowed value
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function timeWindowTooShort(
|
||||
int $value,
|
||||
int $minimum,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf(
|
||||
'Time window (%d seconds) is too short, minimum is %d seconds',
|
||||
$value,
|
||||
$minimum
|
||||
);
|
||||
|
||||
return self::withContext($message, [
|
||||
'parameter' => 'time_window',
|
||||
'value' => $value,
|
||||
'minimum' => $minimum,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for a cleanup interval that is too short.
|
||||
*
|
||||
* @param int $value The cleanup interval value
|
||||
* @param int $minimum The minimum allowed value
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function cleanupIntervalTooShort(
|
||||
int $value,
|
||||
int $minimum,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf(
|
||||
'Cleanup interval (%d seconds) is too short, minimum is %d seconds',
|
||||
$value,
|
||||
$minimum
|
||||
);
|
||||
|
||||
return self::withContext($message, [
|
||||
'parameter' => 'cleanup_interval',
|
||||
'value' => $value,
|
||||
'minimum' => $minimum,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for an empty rate limiting key.
|
||||
*
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function emptyKey(?Throwable $previous = null): static
|
||||
{
|
||||
return self::withContext('Rate limiting key cannot be empty', [
|
||||
'parameter' => 'key',
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for a rate limiting key that is too long.
|
||||
*
|
||||
* @param string $key The key that is too long
|
||||
* @param int $maxLength The maximum allowed length
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function keyTooLong(
|
||||
string $key,
|
||||
int $maxLength,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf(
|
||||
'Rate limiting key length (%d) exceeds maximum (%d characters)',
|
||||
strlen($key),
|
||||
$maxLength
|
||||
);
|
||||
|
||||
return self::withContext($message, [
|
||||
'parameter' => 'key',
|
||||
'key_length' => strlen($key),
|
||||
'max_length' => $maxLength,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for a rate limiting key containing invalid characters.
|
||||
*
|
||||
* @param string $reason The reason why the key is invalid
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function invalidKeyFormat(
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
return self::withContext($reason, [
|
||||
'parameter' => 'key',
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for a generic parameter validation failure.
|
||||
*
|
||||
* @param string $parameter The parameter name
|
||||
* @param mixed $value The invalid value
|
||||
* @param string $reason The reason why the value is invalid
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function forParameter(
|
||||
string $parameter,
|
||||
mixed $value,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Invalid rate limit parameter '%s': %s", $parameter, $reason);
|
||||
|
||||
return self::withContext($message, [
|
||||
'parameter' => $parameter,
|
||||
'value' => $value,
|
||||
'reason' => $reason,
|
||||
], 0, $previous);
|
||||
}
|
||||
}
|
||||
104
src/Exceptions/InvalidRegexPatternException.php
Normal file
104
src/Exceptions/InvalidRegexPatternException.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Exceptions;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Exception thrown when a regex pattern is invalid or cannot be compiled.
|
||||
*
|
||||
* This exception is thrown when:
|
||||
* - A regex pattern has invalid syntax
|
||||
* - A regex pattern cannot be compiled by PHP's PCRE engine
|
||||
* - A regex pattern is detected as potentially vulnerable to ReDoS attacks
|
||||
* - A regex pattern compilation results in a PCRE error
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class InvalidRegexPatternException extends GdprProcessorException
|
||||
{
|
||||
/**
|
||||
* Create an exception for an invalid regex pattern.
|
||||
*
|
||||
* @param string $pattern The invalid regex pattern
|
||||
* @param string $reason The reason why the pattern is invalid
|
||||
* @param int $pcreError Optional PCRE error code
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function forPattern(
|
||||
string $pattern,
|
||||
string $reason,
|
||||
int $pcreError = 0,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Invalid regex pattern '%s': %s", $pattern, $reason);
|
||||
|
||||
if ($pcreError !== 0) {
|
||||
$pcreErrorMessage = self::getPcreErrorMessage($pcreError);
|
||||
$message .= sprintf(' (PCRE Error: %s)', $pcreErrorMessage);
|
||||
}
|
||||
|
||||
return self::withContext($message, [
|
||||
'pattern' => $pattern,
|
||||
'reason' => $reason,
|
||||
'pcre_error' => $pcreError,
|
||||
'pcre_error_message' => $pcreError !== 0 ? self::getPcreErrorMessage($pcreError) : null,
|
||||
], $pcreError, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for a pattern that failed compilation.
|
||||
*
|
||||
* @param string $pattern The pattern that failed to compile
|
||||
* @param int $pcreError The PCRE error code
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function compilationFailed(
|
||||
string $pattern,
|
||||
int $pcreError,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
return self::forPattern($pattern, 'Pattern compilation failed', $pcreError, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for a pattern detected as vulnerable to ReDoS.
|
||||
*
|
||||
* @param string $pattern The potentially vulnerable pattern
|
||||
* @param string $vulnerability Description of the vulnerability
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*
|
||||
* @return InvalidRegexPatternException&static
|
||||
*/
|
||||
public static function redosVulnerable(
|
||||
string $pattern,
|
||||
string $vulnerability,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
return self::forPattern($pattern, 'Potential ReDoS vulnerability: ' . $vulnerability, 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable error message for a PCRE error code.
|
||||
*
|
||||
* @param int $errorCode The PCRE error code
|
||||
*
|
||||
* @return string Human-readable error message
|
||||
* @psalm-return non-empty-string
|
||||
*/
|
||||
private static function getPcreErrorMessage(int $errorCode): string
|
||||
{
|
||||
return match ($errorCode) {
|
||||
PREG_NO_ERROR => 'No error',
|
||||
PREG_INTERNAL_ERROR => 'Internal PCRE error',
|
||||
PREG_BACKTRACK_LIMIT_ERROR => 'Backtrack limit exceeded',
|
||||
PREG_RECURSION_LIMIT_ERROR => 'Recursion limit exceeded',
|
||||
PREG_BAD_UTF8_ERROR => 'Invalid UTF-8 data',
|
||||
PREG_BAD_UTF8_OFFSET_ERROR => 'Invalid UTF-8 offset',
|
||||
PREG_JIT_STACKLIMIT_ERROR => 'JIT stack limit exceeded',
|
||||
default => sprintf('Unknown PCRE error (code: %s)', $errorCode),
|
||||
};
|
||||
}
|
||||
}
|
||||
177
src/Exceptions/MaskingOperationFailedException.php
Normal file
177
src/Exceptions/MaskingOperationFailedException.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Exceptions;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Exception thrown when a masking operation fails unexpectedly.
|
||||
*
|
||||
* This exception is thrown when:
|
||||
* - A regex replacement operation fails
|
||||
* - A field path masking operation encounters an error
|
||||
* - A custom callback masking function throws an exception
|
||||
* - Data type masking fails due to type conversion issues
|
||||
* - JSON masking fails due to malformed JSON structures
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class MaskingOperationFailedException extends GdprProcessorException
|
||||
{
|
||||
/**
|
||||
* Create an exception for a failed regex masking operation.
|
||||
*
|
||||
* @param string $pattern The regex pattern that failed
|
||||
* @param string $input The input string being processed
|
||||
* @param string $reason The reason for the failure
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function regexMaskingFailed(
|
||||
string $pattern,
|
||||
string $input,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Regex masking failed for pattern '%s': %s", $pattern, $reason);
|
||||
|
||||
return self::withContext($message, [
|
||||
'operation_type' => 'regex_masking',
|
||||
'pattern' => $pattern,
|
||||
'input_length' => strlen($input),
|
||||
'input_preview' => substr($input, 0, 100) . (strlen($input) > 100 ? '...' : ''),
|
||||
'reason' => $reason,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for a failed field path masking operation.
|
||||
*
|
||||
* @param string $fieldPath The field path that failed
|
||||
* @param mixed $value The value being masked
|
||||
* @param string $reason The reason for the failure
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function fieldPathMaskingFailed(
|
||||
string $fieldPath,
|
||||
mixed $value,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Field path masking failed for path '%s': %s", $fieldPath, $reason);
|
||||
|
||||
return self::withContext($message, [
|
||||
'operation_type' => 'field_path_masking',
|
||||
'field_path' => $fieldPath,
|
||||
'value_type' => gettype($value),
|
||||
'value_preview' => self::getValuePreview($value),
|
||||
'reason' => $reason,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for a failed custom callback masking operation.
|
||||
*
|
||||
* @param string $fieldPath The field path with the custom callback
|
||||
* @param mixed $value The value being processed
|
||||
* @param string $reason The reason for the failure
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function customCallbackFailed(
|
||||
string $fieldPath,
|
||||
mixed $value,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Custom callback masking failed for path '%s': %s", $fieldPath, $reason);
|
||||
|
||||
return self::withContext($message, [
|
||||
'operation_type' => 'custom_callback',
|
||||
'field_path' => $fieldPath,
|
||||
'value_type' => gettype($value),
|
||||
'value_preview' => self::getValuePreview($value),
|
||||
'reason' => $reason,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for a failed data type masking operation.
|
||||
*
|
||||
* @param string $dataType The data type being masked
|
||||
* @param mixed $value The value being masked
|
||||
* @param string $reason The reason for the failure
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function dataTypeMaskingFailed(
|
||||
string $dataType,
|
||||
mixed $value,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Data type masking failed for type '%s': %s", $dataType, $reason);
|
||||
|
||||
return self::withContext($message, [
|
||||
'operation_type' => 'data_type_masking',
|
||||
'expected_type' => $dataType,
|
||||
'actual_type' => gettype($value),
|
||||
'value_preview' => self::getValuePreview($value),
|
||||
'reason' => $reason,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for a failed JSON masking operation.
|
||||
*
|
||||
* @param string $jsonString The JSON string that failed to be processed
|
||||
* @param string $reason The reason for the failure
|
||||
* @param int $jsonError Optional JSON error code
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function jsonMaskingFailed(
|
||||
string $jsonString,
|
||||
string $reason,
|
||||
int $jsonError = 0,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = 'JSON masking failed: ' . $reason;
|
||||
|
||||
if ($jsonError !== 0) {
|
||||
$jsonErrorMessage = json_last_error_msg();
|
||||
$message .= sprintf(' (JSON Error: %s)', $jsonErrorMessage);
|
||||
}
|
||||
|
||||
return self::withContext($message, [
|
||||
'operation_type' => 'json_masking',
|
||||
'json_preview' => substr($jsonString, 0, 200) . (strlen($jsonString) > 200 ? '...' : ''),
|
||||
'json_length' => strlen($jsonString),
|
||||
'reason' => $reason,
|
||||
'json_error' => $jsonError,
|
||||
'json_error_message' => $jsonError !== 0 ? json_last_error_msg() : null,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a safe preview of a value for logging.
|
||||
*
|
||||
* @param mixed $value The value to preview
|
||||
* @return string Safe preview string
|
||||
*/
|
||||
private static function getValuePreview(mixed $value): string
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return substr($value, 0, 100) . (strlen($value) > 100 ? '...' : '');
|
||||
}
|
||||
|
||||
if (is_array($value) || is_object($value)) {
|
||||
$json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR);
|
||||
if ($json === false) {
|
||||
return '[Unable to serialize]';
|
||||
}
|
||||
|
||||
return substr($json, 0, 100) . (strlen($json) > 100 ? '...' : '');
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
}
|
||||
102
src/Exceptions/PatternValidationException.php
Normal file
102
src/Exceptions/PatternValidationException.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Exceptions;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Exception thrown when pattern validation fails.
|
||||
*
|
||||
* This exception is thrown when:
|
||||
* - Regex patterns are invalid or malformed
|
||||
* - Pattern security validation fails
|
||||
* - Pattern syntax is incorrect
|
||||
* - Pattern validation methods encounter errors
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class PatternValidationException extends GdprProcessorException
|
||||
{
|
||||
/**
|
||||
* Create an exception for a failed pattern validation.
|
||||
*
|
||||
* @param string $pattern The pattern that failed validation
|
||||
* @param string $reason The reason why validation failed
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function forPattern(
|
||||
string $pattern,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Pattern validation failed for '%s': %s", $pattern, $reason);
|
||||
|
||||
return self::withContext($message, [
|
||||
'pattern' => $pattern,
|
||||
'reason' => $reason,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for multiple pattern validation failures.
|
||||
*
|
||||
* @param array<string, string> $failedPatterns Array of pattern => error reason
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function forMultiplePatterns(
|
||||
array $failedPatterns,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$count = count($failedPatterns);
|
||||
$message = sprintf("Pattern validation failed for %d pattern(s)", $count);
|
||||
|
||||
return self::withContext($message, [
|
||||
'failed_patterns' => $failedPatterns,
|
||||
'failure_count' => $count,
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for pattern security validation failure.
|
||||
*
|
||||
* @param string $pattern The potentially unsafe pattern
|
||||
* @param string $securityReason The security concern
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function securityValidationFailed(
|
||||
string $pattern,
|
||||
string $securityReason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Pattern security validation failed for '%s': %s", $pattern, $securityReason);
|
||||
|
||||
return self::withContext($message, [
|
||||
'pattern' => $pattern,
|
||||
'security_reason' => $securityReason,
|
||||
'category' => 'security',
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for pattern syntax errors.
|
||||
*
|
||||
* @param string $pattern The pattern with syntax errors
|
||||
* @param string $syntaxError The syntax error details
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function syntaxError(
|
||||
string $pattern,
|
||||
string $syntaxError,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Pattern syntax error in '%s': %s", $pattern, $syntaxError);
|
||||
|
||||
return self::withContext($message, [
|
||||
'pattern' => $pattern,
|
||||
'syntax_error' => $syntaxError,
|
||||
'category' => 'syntax',
|
||||
], 0, $previous);
|
||||
}
|
||||
}
|
||||
169
src/Exceptions/RecursionDepthExceededException.php
Normal file
169
src/Exceptions/RecursionDepthExceededException.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Exceptions;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Exception thrown when the maximum recursion depth is exceeded during processing.
|
||||
*
|
||||
* This exception is thrown when:
|
||||
* - The recursion depth limit is exceeded while processing nested structures
|
||||
* - Circular references are detected in data structures
|
||||
* - Extremely deep nesting threatens stack overflow
|
||||
* - The configured maxDepth parameter is reached
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class RecursionDepthExceededException extends GdprProcessorException
|
||||
{
|
||||
/**
|
||||
* Create an exception for exceeded recursion depth.
|
||||
*
|
||||
* @param int $currentDepth The current recursion depth when the exception occurred
|
||||
* @param int $maxDepth The maximum allowed recursion depth
|
||||
* @param string $path The field path where the depth was exceeded
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function depthExceeded(
|
||||
int $currentDepth,
|
||||
int $maxDepth,
|
||||
string $path,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf(
|
||||
"Maximum recursion depth of %d exceeded (current: %d) at path '%s'",
|
||||
$maxDepth,
|
||||
$currentDepth,
|
||||
$path
|
||||
);
|
||||
|
||||
return self::withContext($message, [
|
||||
'error_type' => 'depth_exceeded',
|
||||
'current_depth' => $currentDepth,
|
||||
'max_depth' => $maxDepth,
|
||||
'field_path' => $path,
|
||||
'safety_measure' => 'Processing stopped to prevent stack overflow',
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for potential circular reference detection.
|
||||
*
|
||||
* @param string $path The field path where circular reference was detected
|
||||
* @param int $currentDepth The current recursion depth
|
||||
* @param int $maxDepth The maximum allowed recursion depth
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function circularReferenceDetected(
|
||||
string $path,
|
||||
int $currentDepth,
|
||||
int $maxDepth,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf(
|
||||
"Potential circular reference detected at path '%s' (depth: %d/%d)",
|
||||
$path,
|
||||
$currentDepth,
|
||||
$maxDepth
|
||||
);
|
||||
|
||||
return self::withContext($message, [
|
||||
'error_type' => 'circular_reference',
|
||||
'field_path' => $path,
|
||||
'current_depth' => $currentDepth,
|
||||
'max_depth' => $maxDepth,
|
||||
'safety_measure' => 'Processing stopped to prevent infinite recursion',
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for extremely deep nesting scenarios.
|
||||
*
|
||||
* @param string $dataType The type of data structure causing deep nesting
|
||||
* @param int $currentDepth The current recursion depth
|
||||
* @param int $maxDepth The maximum allowed recursion depth
|
||||
* @param string $path The field path with deep nesting
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function extremeNesting(
|
||||
string $dataType,
|
||||
int $currentDepth,
|
||||
int $maxDepth,
|
||||
string $path,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf(
|
||||
"Extremely deep nesting detected in %s at path '%s' (depth: %d/%d)",
|
||||
$dataType,
|
||||
$path,
|
||||
$currentDepth,
|
||||
$maxDepth
|
||||
);
|
||||
|
||||
return self::withContext($message, [
|
||||
'error_type' => 'extreme_nesting',
|
||||
'data_type' => $dataType,
|
||||
'field_path' => $path,
|
||||
'current_depth' => $currentDepth,
|
||||
'max_depth' => $maxDepth,
|
||||
'suggestion' => 'Consider flattening the data structure or increasing maxDepth parameter',
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for invalid depth configuration.
|
||||
*
|
||||
* @param int $invalidDepth The invalid depth value provided
|
||||
* @param string $reason The reason why the depth is invalid
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function invalidDepthConfiguration(
|
||||
int $invalidDepth,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf('Invalid recursion depth configuration: %d (%s)', $invalidDepth, $reason);
|
||||
|
||||
return self::withContext($message, [
|
||||
'error_type' => 'invalid_configuration',
|
||||
'invalid_depth' => $invalidDepth,
|
||||
'reason' => $reason,
|
||||
'valid_range' => 'Depth must be a positive integer between 1 and 1000',
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception with recommendations for handling deep structures.
|
||||
*
|
||||
* @param int $currentDepth The current recursion depth
|
||||
* @param int $maxDepth The maximum allowed recursion depth
|
||||
* @param string $path The field path where the issue occurred
|
||||
* @param array<string> $recommendations List of recommendations
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function withRecommendations(
|
||||
int $currentDepth,
|
||||
int $maxDepth,
|
||||
string $path,
|
||||
array $recommendations,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf(
|
||||
"Recursion depth limit reached at path '%s' (depth: %d/%d)",
|
||||
$path,
|
||||
$currentDepth,
|
||||
$maxDepth
|
||||
);
|
||||
|
||||
return self::withContext($message, [
|
||||
'error_type' => 'depth_with_recommendations',
|
||||
'current_depth' => $currentDepth,
|
||||
'max_depth' => $maxDepth,
|
||||
'field_path' => $path,
|
||||
'recommendations' => $recommendations,
|
||||
], 0, $previous);
|
||||
}
|
||||
}
|
||||
133
src/Exceptions/RuleExecutionException.php
Normal file
133
src/Exceptions/RuleExecutionException.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Exceptions;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Exception thrown when rule execution fails.
|
||||
*
|
||||
* This exception is thrown when:
|
||||
* - Conditional rules fail during execution
|
||||
* - Rule callbacks throw errors
|
||||
* - Rule evaluation encounters runtime errors
|
||||
* - Custom masking logic fails
|
||||
* - Rule processing exceeds limits
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class RuleExecutionException extends GdprProcessorException
|
||||
{
|
||||
/**
|
||||
* Create an exception for conditional rule execution failure.
|
||||
*
|
||||
* @param string $ruleName The rule that failed
|
||||
* @param string $reason The reason for failure
|
||||
* @param mixed $context Additional context about the failure
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function forConditionalRule(
|
||||
string $ruleName,
|
||||
string $reason,
|
||||
mixed $context = null,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Conditional rule '%s' execution failed: %s", $ruleName, $reason);
|
||||
|
||||
$contextData = [
|
||||
'rule_name' => $ruleName,
|
||||
'reason' => $reason,
|
||||
'category' => 'conditional_rule',
|
||||
];
|
||||
|
||||
if ($context !== null) {
|
||||
$contextData['context'] = $context;
|
||||
}
|
||||
|
||||
return self::withContext($message, $contextData, 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for callback execution failure.
|
||||
*
|
||||
* @param string $callbackName The callback that failed
|
||||
* @param string $fieldPath The field path being processed
|
||||
* @param string $reason The reason for failure
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function forCallback(
|
||||
string $callbackName,
|
||||
string $fieldPath,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf(
|
||||
"Callback '%s' failed for field path '%s': %s",
|
||||
$callbackName,
|
||||
$fieldPath,
|
||||
$reason
|
||||
);
|
||||
|
||||
return self::withContext($message, [
|
||||
'callback_name' => $callbackName,
|
||||
'field_path' => $fieldPath,
|
||||
'reason' => $reason,
|
||||
'category' => 'callback_execution',
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for rule timeout.
|
||||
*
|
||||
* @param string $ruleName The rule that timed out
|
||||
* @param float $timeoutSeconds The timeout threshold in seconds
|
||||
* @param float $actualTime The actual execution time
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function forTimeout(
|
||||
string $ruleName,
|
||||
float $timeoutSeconds,
|
||||
float $actualTime,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf(
|
||||
"Rule '%s' execution timed out after %.3f seconds (limit: %.3f seconds)",
|
||||
$ruleName,
|
||||
$actualTime,
|
||||
$timeoutSeconds
|
||||
);
|
||||
|
||||
return self::withContext($message, [
|
||||
'rule_name' => $ruleName,
|
||||
'timeout_seconds' => $timeoutSeconds,
|
||||
'actual_time' => $actualTime,
|
||||
'category' => 'timeout',
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for rule evaluation error.
|
||||
*
|
||||
* @param string $ruleName The rule that failed evaluation
|
||||
* @param mixed $inputData The input data being evaluated
|
||||
* @param string $reason The reason for evaluation failure
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function forEvaluation(
|
||||
string $ruleName,
|
||||
mixed $inputData,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Rule '%s' evaluation failed: %s", $ruleName, $reason);
|
||||
|
||||
return self::withContext($message, [
|
||||
'rule_name' => $ruleName,
|
||||
'input_data' => $inputData,
|
||||
'reason' => $reason,
|
||||
'category' => 'evaluation',
|
||||
], 0, $previous);
|
||||
}
|
||||
}
|
||||
106
src/Exceptions/ServiceRegistrationException.php
Normal file
106
src/Exceptions/ServiceRegistrationException.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Exceptions;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Exception thrown when Laravel service registration fails.
|
||||
*
|
||||
* This exception is thrown when:
|
||||
* - Service provider fails to register GDPR processor
|
||||
* - Configuration publishing fails
|
||||
* - Logging channel registration fails
|
||||
* - Artisan command registration fails
|
||||
* - Service binding or resolution fails
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class ServiceRegistrationException extends GdprProcessorException
|
||||
{
|
||||
/**
|
||||
* Create an exception for channel registration failure.
|
||||
*
|
||||
* @param string $channelName The channel that failed to register
|
||||
* @param string $reason The reason for failure
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function forChannel(
|
||||
string $channelName,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Failed to register GDPR processor with channel '%s': %s", $channelName, $reason);
|
||||
|
||||
return self::withContext($message, [
|
||||
'channel_name' => $channelName,
|
||||
'reason' => $reason,
|
||||
'category' => 'channel_registration',
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for service binding failure.
|
||||
*
|
||||
* @param string $serviceName The service that failed to bind
|
||||
* @param string $reason The reason for failure
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function forServiceBinding(
|
||||
string $serviceName,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Failed to bind service '%s': %s", $serviceName, $reason);
|
||||
|
||||
return self::withContext($message, [
|
||||
'service_name' => $serviceName,
|
||||
'reason' => $reason,
|
||||
'category' => 'service_binding',
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for configuration publishing failure.
|
||||
*
|
||||
* @param string $configPath The configuration path that failed
|
||||
* @param string $reason The reason for failure
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function forConfigPublishing(
|
||||
string $configPath,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Failed to publish configuration to '%s': %s", $configPath, $reason);
|
||||
|
||||
return self::withContext($message, [
|
||||
'config_path' => $configPath,
|
||||
'reason' => $reason,
|
||||
'category' => 'config_publishing',
|
||||
], 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for command registration failure.
|
||||
*
|
||||
* @param string $commandClass The command class that failed to register
|
||||
* @param string $reason The reason for failure
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function forCommandRegistration(
|
||||
string $commandClass,
|
||||
string $reason,
|
||||
?Throwable $previous = null
|
||||
): static {
|
||||
$message = sprintf("Failed to register command '%s': %s", $commandClass, $reason);
|
||||
|
||||
return self::withContext($message, [
|
||||
'command_class' => $commandClass,
|
||||
'reason' => $reason,
|
||||
'category' => 'command_registration',
|
||||
], 0, $previous);
|
||||
}
|
||||
}
|
||||
50
src/Exceptions/StreamingOperationFailedException.php
Normal file
50
src/Exceptions/StreamingOperationFailedException.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Exceptions;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Exception thrown when streaming operations fail.
|
||||
*
|
||||
* This exception is thrown when file operations related to streaming
|
||||
* log processing fail, such as inability to open input or output files.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class StreamingOperationFailedException extends GdprProcessorException
|
||||
{
|
||||
/**
|
||||
* Create an exception for when an input file cannot be opened.
|
||||
*
|
||||
* @param string $filePath Path to the file that could not be opened
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function cannotOpenInputFile(string $filePath, ?Throwable $previous = null): static
|
||||
{
|
||||
return self::withContext(
|
||||
"Cannot open input file for streaming: {$filePath}",
|
||||
['operation' => 'open_input_file', 'file' => $filePath],
|
||||
0,
|
||||
$previous
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for when an output file cannot be opened.
|
||||
*
|
||||
* @param string $filePath Path to the file that could not be opened
|
||||
* @param Throwable|null $previous Previous exception for chaining
|
||||
*/
|
||||
public static function cannotOpenOutputFile(string $filePath, ?Throwable $previous = null): static
|
||||
{
|
||||
return self::withContext(
|
||||
"Cannot open output file for streaming: {$filePath}",
|
||||
['operation' => 'open_output_file', 'file' => $filePath],
|
||||
0,
|
||||
$previous
|
||||
);
|
||||
}
|
||||
}
|
||||
126
src/Factory/AuditLoggerFactory.php
Normal file
126
src/Factory/AuditLoggerFactory.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Factory;
|
||||
|
||||
use Closure;
|
||||
use Ivuorinen\MonologGdprFilter\RateLimitedAuditLogger;
|
||||
|
||||
/**
|
||||
* Factory for creating audit logger instances.
|
||||
*
|
||||
* This class provides factory methods for creating various types of
|
||||
* audit loggers, including rate-limited and array-based loggers.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class AuditLoggerFactory
|
||||
{
|
||||
/**
|
||||
* Create a rate-limited audit logger wrapper.
|
||||
*
|
||||
* @param callable(string,mixed,mixed):void $auditLogger The underlying audit logger
|
||||
* @param string $profile Rate limiting profile: 'strict', 'default', 'relaxed', or 'testing'
|
||||
*/
|
||||
public function createRateLimited(
|
||||
callable $auditLogger,
|
||||
string $profile = 'default'
|
||||
): RateLimitedAuditLogger {
|
||||
return RateLimitedAuditLogger::create($auditLogger, $profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple audit logger that logs to an array (useful for testing).
|
||||
*
|
||||
* @param array<array-key, mixed> $logStorage Reference to array for storing logs
|
||||
* @psalm-param array<array{path: string, original: mixed, masked: mixed}> $logStorage
|
||||
* @psalm-param-out array<array{path: string, original: mixed, masked: mixed, timestamp: int<1, max>}> $logStorage
|
||||
* @phpstan-param-out array<array-key, mixed> $logStorage
|
||||
* @param bool $rateLimited Whether to apply rate limiting (default: false for testing)
|
||||
*
|
||||
* @psalm-return RateLimitedAuditLogger|Closure(string, mixed, mixed):void
|
||||
* @psalm-suppress ReferenceConstraintViolation
|
||||
*/
|
||||
public function createArrayLogger(
|
||||
array &$logStorage,
|
||||
bool $rateLimited = false
|
||||
): Closure|RateLimitedAuditLogger {
|
||||
$baseLogger = function (string $path, mixed $original, mixed $masked) use (&$logStorage): void {
|
||||
$logStorage[] = [
|
||||
'path' => $path,
|
||||
'original' => $original,
|
||||
'masked' => $masked,
|
||||
'timestamp' => time()
|
||||
];
|
||||
};
|
||||
|
||||
return $rateLimited
|
||||
? $this->createRateLimited($baseLogger, 'testing')
|
||||
: $baseLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a null audit logger that does nothing.
|
||||
*
|
||||
* @return Closure(string, mixed, mixed):void
|
||||
*/
|
||||
public function createNullLogger(): Closure
|
||||
{
|
||||
return function (string $path, mixed $original, mixed $masked): void {
|
||||
// Intentionally do nothing - null object pattern
|
||||
unset($path, $original, $masked);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a callback-based logger.
|
||||
*
|
||||
* @param callable(string, mixed, mixed):void $callback The callback to invoke
|
||||
* @return Closure(string, mixed, mixed):void
|
||||
*/
|
||||
public function createCallbackLogger(callable $callback): Closure
|
||||
{
|
||||
return function (string $path, mixed $original, mixed $masked) use ($callback): void {
|
||||
$callback($path, $original, $masked);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Static factory method for convenience.
|
||||
*/
|
||||
public static function create(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
|
||||
/**
|
||||
* Static method: Create a rate-limited audit logger wrapper.
|
||||
*
|
||||
* @param callable(string,mixed,mixed):void $auditLogger The underlying audit logger
|
||||
* @param string $profile Rate limiting profile
|
||||
* @deprecated Use instance method createRateLimited() instead
|
||||
*/
|
||||
public static function rateLimited(
|
||||
callable $auditLogger,
|
||||
string $profile = 'default'
|
||||
): RateLimitedAuditLogger {
|
||||
return (new self())->createRateLimited($auditLogger, $profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Static method: Create a simple audit logger that logs to an array.
|
||||
*
|
||||
* @param array<array-key, mixed> $logStorage Reference to array for storing logs
|
||||
* @param bool $rateLimited Whether to apply rate limiting
|
||||
* @deprecated Use instance method createArrayLogger() instead
|
||||
*
|
||||
* @psalm-return RateLimitedAuditLogger|Closure(string, mixed, mixed):void
|
||||
*/
|
||||
public static function arrayLogger(
|
||||
array &$logStorage,
|
||||
bool $rateLimited = false
|
||||
): Closure|RateLimitedAuditLogger {
|
||||
return (new self())->createArrayLogger($logStorage, $rateLimited);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidConfigurationException;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidRegexPatternException;
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants as Mask;
|
||||
|
||||
/**
|
||||
* FieldMaskConfig: config for masking/removal per field path
|
||||
* FieldMaskConfig: configuration for masking/removal per field path.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class FieldMaskConfig
|
||||
final readonly class FieldMaskConfig
|
||||
{
|
||||
public const MASK_REGEX = 'mask_regex';
|
||||
|
||||
@@ -13,7 +21,206 @@ final class FieldMaskConfig
|
||||
|
||||
public const REPLACE = 'replace';
|
||||
|
||||
public function __construct(public string $type, public ?string $replacement = null)
|
||||
public function __construct(
|
||||
public string $type,
|
||||
public ?string $replacement = null
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a configuration for field removal.
|
||||
*/
|
||||
public static function remove(): self
|
||||
{
|
||||
return new self(self::REMOVE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a configuration for static replacement.
|
||||
*
|
||||
* @param string $replacement The replacement value
|
||||
*/
|
||||
public static function replace(string $replacement): self
|
||||
{
|
||||
return new self(self::REPLACE, $replacement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a configuration that uses the processor's global regex patterns.
|
||||
* This is a shorthand for indicating "apply regex masking from the processor".
|
||||
*/
|
||||
public static function useProcessorPatterns(): self
|
||||
{
|
||||
return new self(self::MASK_REGEX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a configuration for regex-based masking.
|
||||
*
|
||||
* @param string $pattern The regex pattern
|
||||
* @param string $replacement The replacement string (default: '***MASKED***')
|
||||
*
|
||||
* @throws InvalidConfigurationException|InvalidRegexPatternException When pattern
|
||||
* is empty or invalid, or replacement is empty
|
||||
*/
|
||||
public static function regexMask(string $pattern, string $replacement = Mask::MASK_MASKED): self
|
||||
{
|
||||
// Validate pattern is not empty
|
||||
if (trim($pattern) === '') {
|
||||
throw InvalidConfigurationException::emptyValue('regex pattern');
|
||||
}
|
||||
|
||||
// Validate replacement is not empty
|
||||
if (trim($replacement) === '') {
|
||||
throw InvalidConfigurationException::emptyValue('replacement string');
|
||||
}
|
||||
|
||||
// Validate regex pattern syntax
|
||||
if (!self::isValidRegexPattern($pattern)) {
|
||||
throw InvalidRegexPatternException::forPattern($pattern, 'Invalid regex pattern syntax');
|
||||
}
|
||||
|
||||
return new self(self::MASK_REGEX, $pattern . '::' . $replacement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this configuration should remove the field.
|
||||
*/
|
||||
public function shouldRemove(): bool
|
||||
{
|
||||
return $this->type === self::REMOVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this configuration has a regex pattern.
|
||||
*/
|
||||
public function hasRegexPattern(): bool
|
||||
{
|
||||
return $this->type === self::MASK_REGEX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the regex pattern from a regex mask configuration.
|
||||
*
|
||||
* @return string|null The regex pattern or null if not a regex mask
|
||||
*/
|
||||
public function getRegexPattern(): ?string
|
||||
{
|
||||
if ($this->type !== self::MASK_REGEX || $this->replacement === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = explode('::', $this->replacement, 2);
|
||||
return $parts[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the replacement value.
|
||||
*
|
||||
* @return string|null The replacement value
|
||||
*/
|
||||
public function getReplacement(): ?string
|
||||
{
|
||||
if ($this->type === self::MASK_REGEX && $this->replacement !== null) {
|
||||
$parts = explode('::', $this->replacement, 2);
|
||||
return $parts[1] ?? Mask::MASK_MASKED;
|
||||
}
|
||||
|
||||
return $this->replacement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array representation.
|
||||
*
|
||||
* @return (null|string)[]
|
||||
*
|
||||
* @psalm-return array{type: string, replacement: null|string}
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->type,
|
||||
'replacement' => $this->replacement,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from array representation.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @throws InvalidConfigurationException|InvalidRegexPatternException When data contains invalid values
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$type = $data['type'] ?? self::REPLACE;
|
||||
$replacement = $data['replacement'] ?? null;
|
||||
|
||||
// Validate type
|
||||
$validTypes = [self::MASK_REGEX, self::REMOVE, self::REPLACE];
|
||||
if (!in_array($type, $validTypes, true)) {
|
||||
$validList = implode(', ', $validTypes);
|
||||
throw InvalidConfigurationException::forParameter(
|
||||
'type',
|
||||
$type,
|
||||
sprintf("Must be one of: %s", $validList)
|
||||
);
|
||||
}
|
||||
|
||||
// Validate replacement for REPLACE type - only when explicitly provided
|
||||
if (
|
||||
$type === self::REPLACE &&
|
||||
array_key_exists('replacement', $data) &&
|
||||
($replacement === null || trim($replacement) === '')
|
||||
) {
|
||||
throw InvalidConfigurationException::forParameter(
|
||||
'replacement',
|
||||
null,
|
||||
'Cannot be null or empty for REPLACE type'
|
||||
);
|
||||
}
|
||||
|
||||
return new self($type, $replacement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a regex pattern is syntactically correct.
|
||||
*
|
||||
* @param string $pattern The regex pattern to validate
|
||||
* @return bool True if valid, false otherwise
|
||||
*/
|
||||
private static function isValidRegexPattern(string $pattern): bool
|
||||
{
|
||||
// Suppress warnings for invalid patterns
|
||||
$previousErrorReporting = error_reporting(E_ERROR);
|
||||
|
||||
try {
|
||||
// Test the pattern by attempting to use it
|
||||
/** @psalm-suppress ArgumentTypeCoercion - Pattern validated by caller */
|
||||
$result = @preg_match($pattern, '');
|
||||
|
||||
// Check if preg_match succeeded (returns 0 or 1) or failed (returns false)
|
||||
$isValid = $result !== false;
|
||||
|
||||
// Additional check for PREG errors
|
||||
if ($isValid && preg_last_error() !== PREG_NO_ERROR) {
|
||||
$isValid = false;
|
||||
}
|
||||
|
||||
// Additional validation for effectively empty patterns
|
||||
// Check for patterns that are effectively empty (like '//' or '/\s*/')
|
||||
// Extract the pattern content between delimiters
|
||||
if ($isValid && preg_match('/^(.)(.*?)\1[gimuxXs]*$/', $pattern, $matches)) {
|
||||
$patternContent = $matches[2];
|
||||
// Reject patterns that are empty or only whitespace-based
|
||||
if ($patternContent === '' || trim($patternContent) === '' || $patternContent === '\s*') {
|
||||
$isValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
return $isValid;
|
||||
} finally {
|
||||
// Restore previous error reporting level
|
||||
error_reporting($previousErrorReporting);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter;
|
||||
|
||||
use Adbar\Dot;
|
||||
use Ivuorinen\MonologGdprFilter\ArrayAccessor\ArrayAccessorFactory;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\PatternValidationException;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidRegexPatternException;
|
||||
use Ivuorinen\MonologGdprFilter\Factory\AuditLoggerFactory;
|
||||
use Closure;
|
||||
use Throwable;
|
||||
use Monolog\LogRecord;
|
||||
use Monolog\Processor\ProcessorInterface;
|
||||
|
||||
@@ -10,86 +15,105 @@ use Monolog\Processor\ProcessorInterface;
|
||||
* GdprProcessor is a Monolog processor that masks sensitive information in log messages
|
||||
* according to specified regex patterns and field paths.
|
||||
*
|
||||
* This class serves as a Monolog adapter, delegating actual masking work to MaskingOrchestrator.
|
||||
*
|
||||
* @psalm-api
|
||||
*/
|
||||
class GdprProcessor implements ProcessorInterface
|
||||
{
|
||||
private readonly MaskingOrchestrator $orchestrator;
|
||||
|
||||
/**
|
||||
* @var callable(string,mixed,mixed):void|null
|
||||
*/
|
||||
private $auditLogger;
|
||||
|
||||
/**
|
||||
* @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:
|
||||
* @param array<string,FieldMaskConfig|string> $fieldPaths Dot-notation path => FieldMaskConfig
|
||||
* @param array<string,callable(mixed):string> $customCallbacks Dot-notation path => callback(value): string
|
||||
* @param callable(string,mixed,mixed):void|null $auditLogger Opt. audit logger callback:
|
||||
* fn(string $path, mixed $original, mixed $masked)
|
||||
* @param int $maxDepth Maximum recursion depth for nested structures (default: 100)
|
||||
* @param array<string,string> $dataTypeMasks Type-based masking: type => mask pattern
|
||||
* @param array<string,callable(LogRecord):bool> $conditionalRules Conditional masking rules:
|
||||
* rule_name => condition_callback
|
||||
* @param ArrayAccessorFactory|null $arrayAccessorFactory Factory for creating array accessors
|
||||
*
|
||||
* @throws \InvalidArgumentException When any parameter is invalid
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $patterns,
|
||||
private readonly array $fieldPaths = [],
|
||||
private readonly array $customCallbacks = [],
|
||||
private $auditLogger = null
|
||||
array $fieldPaths = [],
|
||||
array $customCallbacks = [],
|
||||
$auditLogger = null,
|
||||
int $maxDepth = 100,
|
||||
array $dataTypeMasks = [],
|
||||
private readonly array $conditionalRules = [],
|
||||
?ArrayAccessorFactory $arrayAccessorFactory = null
|
||||
) {
|
||||
$this->auditLogger = $auditLogger;
|
||||
|
||||
// Validate all constructor parameters using InputValidator
|
||||
InputValidator::validateAll(
|
||||
$patterns,
|
||||
$fieldPaths,
|
||||
$customCallbacks,
|
||||
$auditLogger,
|
||||
$maxDepth,
|
||||
$dataTypeMasks,
|
||||
$conditionalRules
|
||||
);
|
||||
|
||||
// Pre-validate and cache patterns for better performance
|
||||
/** @psalm-suppress DeprecatedMethod - Internal use of caching mechanism */
|
||||
PatternValidator::cachePatterns($patterns);
|
||||
|
||||
// Create orchestrator to handle actual masking work
|
||||
$this->orchestrator = new MaskingOrchestrator(
|
||||
$patterns,
|
||||
$fieldPaths,
|
||||
$customCallbacks,
|
||||
$auditLogger,
|
||||
$maxDepth,
|
||||
$dataTypeMasks,
|
||||
$arrayAccessorFactory
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Create a rate-limited audit logger wrapper.
|
||||
*
|
||||
* @return array<array-key, string>
|
||||
* @param callable(string,mixed,mixed):void $auditLogger The underlying audit logger
|
||||
* @param string $profile Rate limiting profile: 'strict', 'default', 'relaxed', or 'testing'
|
||||
*
|
||||
* @deprecated Use AuditLoggerFactory::create()->createRateLimited() instead
|
||||
*/
|
||||
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***',
|
||||
];
|
||||
public static function createRateLimitedAuditLogger(
|
||||
callable $auditLogger,
|
||||
string $profile = 'default'
|
||||
): RateLimitedAuditLogger {
|
||||
return AuditLoggerFactory::create()->createRateLimited($auditLogger, $profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple audit logger that logs to an array (useful for testing).
|
||||
*
|
||||
* @param array<array-key, mixed> $logStorage Reference to array for storing logs
|
||||
* @psalm-param array<array{path: string, original: mixed, masked: mixed}> $logStorage
|
||||
* @psalm-param-out array<array{path: string, original: mixed, masked: mixed, timestamp: int<1, max>}> $logStorage
|
||||
* @phpstan-param-out array<array-key, mixed> $logStorage
|
||||
* @param bool $rateLimited Whether to apply rate limiting (default: false for testing)
|
||||
*
|
||||
* @psalm-return RateLimitedAuditLogger|Closure(string, mixed, mixed):void
|
||||
*
|
||||
* @deprecated Use AuditLoggerFactory::create()->createArrayLogger() instead
|
||||
*/
|
||||
public static function createArrayAuditLogger(
|
||||
array &$logStorage,
|
||||
bool $rateLimited = false
|
||||
): Closure|RateLimitedAuditLogger {
|
||||
return AuditLoggerFactory::create()->createArrayLogger($logStorage, $rateLimited);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,149 +121,81 @@ class GdprProcessor implements ProcessorInterface
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
#[\Override]
|
||||
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);
|
||||
// Check conditional rules first - if any rule returns false, skip masking
|
||||
if (!$this->shouldApplyMasking($record)) {
|
||||
return $record;
|
||||
}
|
||||
|
||||
return $record->with(message: $message, context: $context);
|
||||
// Delegate to orchestrator
|
||||
$result = $this->orchestrator->process($record->message, $record->context);
|
||||
|
||||
return $record->with(message: $result['message'], context: $result['context']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask a string using all regex patterns sequentially.
|
||||
* Check if masking should be applied based on conditional rules.
|
||||
*/
|
||||
public function regExpMessage(string $message = ''): string
|
||||
private function shouldApplyMasking(LogRecord $record): bool
|
||||
{
|
||||
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);
|
||||
// If no conditional rules are defined, always apply masking
|
||||
if ($this->conditionalRules === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// All conditional rules must return true for masking to be applied
|
||||
foreach ($this->conditionalRules as $ruleName => $ruleCallback) {
|
||||
try {
|
||||
if (!$ruleCallback($record)) {
|
||||
// Log which rule prevented masking
|
||||
if ($this->auditLogger !== null) {
|
||||
($this->auditLogger)(
|
||||
'conditional_skip',
|
||||
$ruleName,
|
||||
'Masking skipped due to conditional rule'
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// If a rule throws an exception, log it and default to applying masking
|
||||
if ($this->auditLogger !== null) {
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($e->getMessage());
|
||||
$errorMsg = 'Rule error: ' . $sanitized;
|
||||
($this->auditLogger)('conditional_error', $ruleName, $errorMsg);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($result === '' || $result === '0') {
|
||||
// If the result is empty, we can skip further processing
|
||||
return $message;
|
||||
}
|
||||
|
||||
$message = $result;
|
||||
}
|
||||
|
||||
return $message;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask only specified paths in context (fieldPaths)
|
||||
* Mask a string using all regex patterns with optimized caching and batch processing.
|
||||
* Also handles JSON strings within the message.
|
||||
*/
|
||||
private function maskFieldPaths(Dot $accessor): void
|
||||
public function regExpMessage(string $message = ''): string
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
return $this->orchestrator->regExpMessage($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask a single value according to config or callback
|
||||
* Returns an array: ['masked' => value|null, 'remove' => bool]
|
||||
* Recursively mask all string values in an array using regex patterns with depth limiting
|
||||
* and memory-efficient processing for large nested structures.
|
||||
*
|
||||
* @psalm-return array{masked: string|null, remove: bool}
|
||||
* @param array<mixed>|string $data
|
||||
* @param int $currentDepth Current recursion depth
|
||||
* @return array<mixed>|string
|
||||
*/
|
||||
private function maskValue(string $path, mixed $value, null|FieldMaskConfig|string $config): array
|
||||
public function recursiveMask(array|string $data, int $currentDepth = 0): array|string
|
||||
{
|
||||
/** @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;
|
||||
return $this->orchestrator->recursiveMask($data, $currentDepth);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,26 +203,77 @@ class GdprProcessor implements ProcessorInterface
|
||||
*/
|
||||
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);
|
||||
|
||||
try {
|
||||
/** @psalm-suppress ArgumentTypeCoercion */
|
||||
$result = preg_replace($keys, $values, $value);
|
||||
if ($result === null) {
|
||||
$error = preg_last_error_msg();
|
||||
if ($this->auditLogger !== null) {
|
||||
($this->auditLogger)('preg_replace_batch_error', $value, 'Error: ' . $error);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $result;
|
||||
} catch (\Error $error) {
|
||||
if ($this->auditLogger !== null) {
|
||||
($this->auditLogger)('regex_batch_error', implode(', ', $keys), $error->getMessage());
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the audit logger callable.
|
||||
*
|
||||
* @param callable(string,mixed,mixed):void|null $auditLogger
|
||||
*/
|
||||
public function setAuditLogger(?callable $auditLogger): void
|
||||
{
|
||||
$this->auditLogger = $auditLogger;
|
||||
$this->orchestrator->setAuditLogger($auditLogger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying orchestrator for direct access.
|
||||
*/
|
||||
public function getOrchestrator(): MaskingOrchestrator
|
||||
{
|
||||
return $this->orchestrator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an array of patterns for security and syntax.
|
||||
*
|
||||
* @param array<string, string> $patterns Array of regex pattern => replacement
|
||||
*
|
||||
* @throws \Ivuorinen\MonologGdprFilter\Exceptions\PatternValidationException When patterns are invalid
|
||||
*/
|
||||
public static function validatePatternsArray(array $patterns): void
|
||||
{
|
||||
try {
|
||||
/** @psalm-suppress DeprecatedMethod - Wrapper for deprecated validation */
|
||||
PatternValidator::validateAll($patterns);
|
||||
} catch (InvalidRegexPatternException $e) {
|
||||
throw PatternValidationException::forMultiplePatterns(
|
||||
['validation_error' => $e->getMessage()],
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default GDPR regex patterns for common sensitive data types.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function getDefaultPatterns(): array
|
||||
{
|
||||
return DefaultPatterns::get();
|
||||
}
|
||||
}
|
||||
|
||||
300
src/InputValidator.php
Normal file
300
src/InputValidator.php
Normal file
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidConfigurationException;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidRegexPatternException;
|
||||
|
||||
/**
|
||||
* Validates constructor parameters for GdprProcessor.
|
||||
*
|
||||
* This class is responsible for validating all input parameters
|
||||
* to ensure they meet the requirements before processing.
|
||||
*/
|
||||
final class InputValidator
|
||||
{
|
||||
/**
|
||||
* Validate all constructor parameters for early error detection.
|
||||
*
|
||||
* @param array<string,string> $patterns
|
||||
* @param array<string,FieldMaskConfig|string> $fieldPaths
|
||||
* @param array<string,callable(mixed):string> $customCallbacks
|
||||
* @param callable(string,mixed,mixed):void|null $auditLogger
|
||||
* @param int $maxDepth
|
||||
* @param array<string,string> $dataTypeMasks
|
||||
* @param array<string,callable> $conditionalRules
|
||||
*
|
||||
* @throws InvalidConfigurationException When any parameter is invalid
|
||||
*/
|
||||
public static function validateAll(
|
||||
array $patterns,
|
||||
array $fieldPaths,
|
||||
array $customCallbacks,
|
||||
mixed $auditLogger,
|
||||
int $maxDepth,
|
||||
array $dataTypeMasks,
|
||||
array $conditionalRules
|
||||
): void {
|
||||
self::validatePatterns($patterns);
|
||||
self::validateFieldPaths($fieldPaths);
|
||||
self::validateCustomCallbacks($customCallbacks);
|
||||
self::validateAuditLogger($auditLogger);
|
||||
self::validateMaxDepth($maxDepth);
|
||||
self::validateDataTypeMasks($dataTypeMasks);
|
||||
self::validateConditionalRules($conditionalRules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate patterns array for proper structure and valid regex patterns.
|
||||
*
|
||||
* @param array<string,string> $patterns
|
||||
*
|
||||
* @throws InvalidConfigurationException When patterns are invalid
|
||||
*/
|
||||
public static function validatePatterns(array $patterns): void
|
||||
{
|
||||
foreach ($patterns as $pattern => $replacement) {
|
||||
// Validate pattern key
|
||||
/** @psalm-suppress DocblockTypeContradiction - Runtime validation for defensive programming */
|
||||
if (!is_string($pattern)) {
|
||||
throw InvalidConfigurationException::invalidType(
|
||||
'pattern',
|
||||
'string',
|
||||
gettype($pattern)
|
||||
);
|
||||
}
|
||||
|
||||
if (trim($pattern) === '') {
|
||||
throw InvalidConfigurationException::emptyValue('pattern');
|
||||
}
|
||||
|
||||
// Validate replacement value
|
||||
/** @psalm-suppress DocblockTypeContradiction - Runtime validation for defensive programming */
|
||||
if (!is_string($replacement)) {
|
||||
throw InvalidConfigurationException::invalidType(
|
||||
'pattern replacement',
|
||||
'string',
|
||||
gettype($replacement)
|
||||
);
|
||||
}
|
||||
|
||||
// Validate regex pattern syntax
|
||||
/** @psalm-suppress DeprecatedMethod - Internal validation use */
|
||||
if (!PatternValidator::isValid($pattern)) {
|
||||
throw InvalidRegexPatternException::forPattern(
|
||||
$pattern,
|
||||
'Invalid regex pattern syntax'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate field paths array for proper structure.
|
||||
*
|
||||
* @param array<string,FieldMaskConfig|string> $fieldPaths
|
||||
*
|
||||
* @throws InvalidConfigurationException When field paths are invalid
|
||||
*/
|
||||
public static function validateFieldPaths(array $fieldPaths): void
|
||||
{
|
||||
foreach ($fieldPaths as $path => $config) {
|
||||
// Validate path key
|
||||
/** @psalm-suppress DocblockTypeContradiction - Runtime validation for defensive programming */
|
||||
if (!is_string($path)) {
|
||||
throw InvalidConfigurationException::invalidType(
|
||||
'field path',
|
||||
'string',
|
||||
gettype($path)
|
||||
);
|
||||
}
|
||||
|
||||
if (trim($path) === '') {
|
||||
throw InvalidConfigurationException::emptyValue('field path');
|
||||
}
|
||||
|
||||
// Validate config value
|
||||
/** @psalm-suppress DocblockTypeContradiction - Runtime validation for defensive programming */
|
||||
if (!($config instanceof FieldMaskConfig) && !is_string($config)) {
|
||||
throw InvalidConfigurationException::invalidType(
|
||||
'field path value',
|
||||
'FieldMaskConfig or string',
|
||||
gettype($config)
|
||||
);
|
||||
}
|
||||
|
||||
if (is_string($config) && trim($config) === '') {
|
||||
throw InvalidConfigurationException::forFieldPath(
|
||||
$path,
|
||||
'Cannot have empty string value'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate custom callbacks array for proper structure.
|
||||
*
|
||||
* @param array<string,callable(mixed):string> $customCallbacks
|
||||
*
|
||||
* @throws InvalidConfigurationException When custom callbacks are invalid
|
||||
*/
|
||||
public static function validateCustomCallbacks(array $customCallbacks): void
|
||||
{
|
||||
foreach ($customCallbacks as $path => $callback) {
|
||||
// Validate path key
|
||||
/** @psalm-suppress DocblockTypeContradiction - Runtime validation for defensive programming */
|
||||
if (!is_string($path)) {
|
||||
throw InvalidConfigurationException::invalidType(
|
||||
'custom callback path',
|
||||
'string',
|
||||
gettype($path)
|
||||
);
|
||||
}
|
||||
|
||||
if (trim($path) === '') {
|
||||
throw InvalidConfigurationException::emptyValue('custom callback path');
|
||||
}
|
||||
|
||||
// Validate callback value
|
||||
if (!is_callable($callback)) {
|
||||
throw InvalidConfigurationException::forParameter(
|
||||
'custom callback for ' . $path,
|
||||
$callback,
|
||||
'Must be callable'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate audit logger parameter.
|
||||
*
|
||||
* @param callable(string,mixed,mixed):void|null $auditLogger
|
||||
*
|
||||
* @throws InvalidConfigurationException When audit logger is invalid
|
||||
*/
|
||||
public static function validateAuditLogger(mixed $auditLogger): void
|
||||
{
|
||||
if ($auditLogger !== null && !is_callable($auditLogger)) {
|
||||
$type = gettype($auditLogger);
|
||||
throw InvalidConfigurationException::invalidType(
|
||||
'audit logger',
|
||||
'callable or null',
|
||||
$type
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate max depth parameter for reasonable bounds.
|
||||
*
|
||||
* @throws InvalidConfigurationException When max depth is invalid
|
||||
*/
|
||||
public static function validateMaxDepth(int $maxDepth): void
|
||||
{
|
||||
if ($maxDepth <= 0) {
|
||||
throw InvalidConfigurationException::forParameter(
|
||||
'max_depth',
|
||||
$maxDepth,
|
||||
'Must be a positive integer'
|
||||
);
|
||||
}
|
||||
|
||||
if ($maxDepth > 1000) {
|
||||
throw InvalidConfigurationException::forParameter(
|
||||
'max_depth',
|
||||
$maxDepth,
|
||||
'Cannot exceed 1,000 for stack safety'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate data type masks array for proper structure.
|
||||
*
|
||||
* @param array<string,string> $dataTypeMasks
|
||||
*
|
||||
* @throws InvalidConfigurationException When data type masks are invalid
|
||||
*/
|
||||
public static function validateDataTypeMasks(array $dataTypeMasks): void
|
||||
{
|
||||
$validTypes = ['integer', 'double', 'string', 'boolean', 'NULL', 'array', 'object', 'resource'];
|
||||
|
||||
foreach ($dataTypeMasks as $type => $mask) {
|
||||
// Validate type key
|
||||
/** @psalm-suppress DocblockTypeContradiction - Runtime validation for defensive programming */
|
||||
if (!is_string($type)) {
|
||||
$typeGot = gettype($type);
|
||||
throw InvalidConfigurationException::invalidType(
|
||||
'data type mask key',
|
||||
'string',
|
||||
$typeGot
|
||||
);
|
||||
}
|
||||
|
||||
if (!in_array($type, $validTypes, true)) {
|
||||
$validList = implode(', ', $validTypes);
|
||||
throw InvalidConfigurationException::forDataTypeMask(
|
||||
$type,
|
||||
null,
|
||||
"Must be one of: $validList"
|
||||
);
|
||||
}
|
||||
|
||||
// Validate mask value
|
||||
/** @psalm-suppress DocblockTypeContradiction - Runtime validation for defensive programming */
|
||||
if (!is_string($mask)) {
|
||||
throw InvalidConfigurationException::invalidType(
|
||||
'data type mask value',
|
||||
'string',
|
||||
gettype($mask)
|
||||
);
|
||||
}
|
||||
|
||||
if (trim($mask) === '') {
|
||||
throw InvalidConfigurationException::forDataTypeMask(
|
||||
$type,
|
||||
'',
|
||||
'Cannot be empty'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate conditional rules array for proper structure.
|
||||
*
|
||||
* @param array<string,callable> $conditionalRules
|
||||
*
|
||||
* @throws InvalidConfigurationException When conditional rules are invalid
|
||||
*/
|
||||
public static function validateConditionalRules(array $conditionalRules): void
|
||||
{
|
||||
foreach ($conditionalRules as $ruleName => $callback) {
|
||||
// Validate rule name key
|
||||
/** @psalm-suppress DocblockTypeContradiction - Runtime validation for defensive programming */
|
||||
if (!is_string($ruleName)) {
|
||||
throw InvalidConfigurationException::invalidType(
|
||||
'conditional rule name',
|
||||
'string',
|
||||
gettype($ruleName)
|
||||
);
|
||||
}
|
||||
|
||||
if (trim($ruleName) === '') {
|
||||
throw InvalidConfigurationException::emptyValue('conditional rule name');
|
||||
}
|
||||
|
||||
// Validate callback value
|
||||
if (!is_callable($callback)) {
|
||||
throw InvalidConfigurationException::forConditionalRule(
|
||||
$ruleName,
|
||||
'Must have a callable callback'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
227
src/JsonMasker.php
Normal file
227
src/JsonMasker.php
Normal file
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter;
|
||||
|
||||
use JsonException;
|
||||
|
||||
/**
|
||||
* Handles JSON structure detection and masking within log messages.
|
||||
*
|
||||
* This class provides methods to find JSON structures in strings,
|
||||
* parse them, apply masking, and re-encode them.
|
||||
*/
|
||||
final class JsonMasker
|
||||
{
|
||||
/**
|
||||
* @param callable(array<mixed>|string, int=):array<mixed>|string $recursiveMaskCallback
|
||||
* @param callable(string, mixed, mixed):void|null $auditLogger
|
||||
*/
|
||||
public function __construct(
|
||||
private $recursiveMaskCallback,
|
||||
private $auditLogger = null
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Find and process JSON structures in the message.
|
||||
*/
|
||||
public function processMessage(string $message): string
|
||||
{
|
||||
$result = '';
|
||||
$length = strlen($message);
|
||||
$i = 0;
|
||||
|
||||
while ($i < $length) {
|
||||
$char = $message[$i];
|
||||
|
||||
if ($char === '{' || $char === '[') {
|
||||
// Found potential JSON start, try to extract balanced structure
|
||||
$jsonCandidate = $this->extractBalancedStructure($message, $i);
|
||||
|
||||
if ($jsonCandidate !== null) {
|
||||
// Process the candidate
|
||||
$processed = $this->processCandidate($jsonCandidate);
|
||||
$result .= $processed;
|
||||
$i += strlen($jsonCandidate);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$result .= $char;
|
||||
$i++;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a balanced JSON structure starting from the given position.
|
||||
*/
|
||||
public function extractBalancedStructure(string $message, int $startPos): ?string
|
||||
{
|
||||
$length = strlen($message);
|
||||
$startChar = $message[$startPos];
|
||||
$endChar = $startChar === '{' ? '}' : ']';
|
||||
$level = 0;
|
||||
$inString = false;
|
||||
$escaped = false;
|
||||
|
||||
for ($i = $startPos; $i < $length; $i++) {
|
||||
$char = $message[$i];
|
||||
|
||||
if ($this->isEscapedCharacter($escaped)) {
|
||||
$escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->isEscapeStart($char, $inString)) {
|
||||
$escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === '"') {
|
||||
$inString = !$inString;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($inString) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$balancedEnd = $this->processStructureChar($char, $startChar, $endChar, $level, $message, $startPos, $i);
|
||||
if ($balancedEnd !== null) {
|
||||
return $balancedEnd;
|
||||
}
|
||||
}
|
||||
|
||||
// No balanced structure found
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current character is escaped.
|
||||
*/
|
||||
private function isEscapedCharacter(bool $escaped): bool
|
||||
{
|
||||
return $escaped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current character starts an escape sequence.
|
||||
*/
|
||||
private function isEscapeStart(string $char, bool $inString): bool
|
||||
{
|
||||
return $char === '\\' && $inString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a structure character (bracket or brace) and update nesting level.
|
||||
*
|
||||
* @return string|null Returns the extracted structure if complete, null otherwise
|
||||
*/
|
||||
private function processStructureChar(
|
||||
string $char,
|
||||
string $startChar,
|
||||
string $endChar,
|
||||
int &$level,
|
||||
string $message,
|
||||
int $startPos,
|
||||
int $currentPos
|
||||
): ?string {
|
||||
if ($char === $startChar) {
|
||||
$level++;
|
||||
} elseif ($char === $endChar) {
|
||||
$level--;
|
||||
|
||||
if ($level === 0) {
|
||||
// Found complete balanced structure
|
||||
return substr($message, $startPos, $currentPos - $startPos + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a potential JSON candidate string.
|
||||
*/
|
||||
public function processCandidate(string $potentialJson): string
|
||||
{
|
||||
try {
|
||||
// Try to parse as JSON
|
||||
$decoded = json_decode($potentialJson, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
// If successfully decoded, apply masking and re-encode
|
||||
if ($decoded !== null) {
|
||||
$masked = ($this->recursiveMaskCallback)($decoded, 0);
|
||||
$reEncoded = $this->encodePreservingEmptyObjects($masked, $potentialJson);
|
||||
|
||||
if ($reEncoded !== false) {
|
||||
// Log the operation if audit logger is available
|
||||
if ($this->auditLogger !== null && $reEncoded !== $potentialJson) {
|
||||
($this->auditLogger)('json_masked', $potentialJson, $reEncoded);
|
||||
}
|
||||
|
||||
return $reEncoded;
|
||||
}
|
||||
}
|
||||
} catch (JsonException) {
|
||||
// Not valid JSON, leave as-is to be processed by regular patterns
|
||||
}
|
||||
|
||||
return $potentialJson;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode JSON while preserving empty object structures from the original.
|
||||
*
|
||||
* @param array<mixed>|string $data The data to encode.
|
||||
* @param string $originalJson The original JSON string.
|
||||
*
|
||||
* @return false|string The encoded JSON string or false on failure.
|
||||
*/
|
||||
public function encodePreservingEmptyObjects(array|string $data, string $originalJson): string|false
|
||||
{
|
||||
// Handle simple empty cases first
|
||||
if (in_array($data, ['', '0', []], true)) {
|
||||
if ($originalJson === '{}') {
|
||||
return '{}';
|
||||
}
|
||||
|
||||
if ($originalJson === '[]') {
|
||||
return '[]';
|
||||
}
|
||||
}
|
||||
|
||||
// Encode the processed data
|
||||
$encoded = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
if ($encoded === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fix empty arrays that should be empty objects by comparing with original
|
||||
return $this->fixEmptyObjects($encoded, $originalJson);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix empty arrays that should be empty objects in the encoded JSON.
|
||||
*/
|
||||
public function fixEmptyObjects(string $encoded, string $original): string
|
||||
{
|
||||
// Count empty objects in original and empty arrays in encoded
|
||||
$originalEmptyObjects = substr_count($original, '{}');
|
||||
$encodedEmptyArrays = substr_count($encoded, '[]');
|
||||
|
||||
// If we lost empty objects (they became arrays), fix them
|
||||
if ($originalEmptyObjects > 0 && $encodedEmptyArrays >= $originalEmptyObjects) {
|
||||
// Replace empty arrays with empty objects, up to the number we had originally
|
||||
for ($i = 0; $i < $originalEmptyObjects; $i++) {
|
||||
$encoded = preg_replace('/\[\]/', '{}', $encoded, 1) ?? $encoded;
|
||||
}
|
||||
}
|
||||
|
||||
return $encoded;
|
||||
}
|
||||
}
|
||||
216
src/Laravel/Commands/GdprDebugCommand.php
Normal file
216
src/Laravel/Commands/GdprDebugCommand.php
Normal file
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Laravel\Commands;
|
||||
|
||||
use Monolog\LogRecord;
|
||||
use DateTimeImmutable;
|
||||
use Monolog\Level;
|
||||
use JsonException;
|
||||
use Illuminate\Console\Command;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\CommandExecutionException;
|
||||
|
||||
/**
|
||||
* Artisan command for debugging GDPR configuration and testing.
|
||||
*
|
||||
* This command provides information about the current GDPR configuration
|
||||
* and allows testing with sample log data.
|
||||
*
|
||||
* @api
|
||||
* @psalm-suppress PropertyNotSetInConstructor
|
||||
*/
|
||||
class GdprDebugCommand extends Command
|
||||
{
|
||||
private const COMMAND_NAME = 'gdpr:debug';
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'gdpr:debug
|
||||
{--test-data= : JSON string of sample data to test}
|
||||
{--show-patterns : Show all configured patterns}
|
||||
{--show-config : Show current configuration}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Debug GDPR configuration and test with sample data';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('GDPR Filter Debug Information');
|
||||
$this->line('=============================');
|
||||
|
||||
// Show configuration if requested
|
||||
if ((bool)$this->option('show-config')) {
|
||||
$this->showConfiguration();
|
||||
}
|
||||
|
||||
// Show patterns if requested
|
||||
if ((bool)$this->option('show-patterns')) {
|
||||
$this->showPatterns();
|
||||
}
|
||||
|
||||
// Test with sample data if provided
|
||||
$testData = (string)$this->option('test-data');
|
||||
if ($testData !== '' && $testData !== '0') {
|
||||
$this->testWithSampleData($testData);
|
||||
}
|
||||
|
||||
if (!$this->option('show-config') && !$this->option('show-patterns') && !$testData) {
|
||||
$this->showSummary();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show current GDPR configuration.
|
||||
*/
|
||||
protected function showConfiguration(): void
|
||||
{
|
||||
$this->line('');
|
||||
$this->info('Current Configuration:');
|
||||
$this->line('----------------------');
|
||||
|
||||
$config = \config('gdpr', []);
|
||||
|
||||
$this->line('Auto Register: ' . ($config['auto_register'] ?? true ? 'Yes' : 'No'));
|
||||
$this->line('Max Depth: ' . ($config['max_depth'] ?? 100));
|
||||
$this->line('Audit Logging: ' . (($config['audit_logging']['enabled'] ?? false) ? 'Enabled' : 'Disabled'));
|
||||
|
||||
$channels = $config['channels'] ?? [];
|
||||
$this->line('Channels: ' . (empty($channels) ? 'None' : implode(', ', $channels)));
|
||||
|
||||
$fieldPaths = $config['field_paths'] ?? [];
|
||||
$this->line('Field Paths: ' . count($fieldPaths) . ' configured');
|
||||
|
||||
$customCallbacks = $config['custom_callbacks'] ?? [];
|
||||
$this->line('Custom Callbacks: ' . count($customCallbacks) . ' configured');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show all configured patterns.
|
||||
*/
|
||||
protected function showPatterns(): void
|
||||
{
|
||||
$this->line('');
|
||||
$this->info('Configured Patterns:');
|
||||
$this->line('--------------------');
|
||||
|
||||
$config = \config('gdpr', []);
|
||||
/**
|
||||
* @var array<string, mixed>|null $patterns
|
||||
*/
|
||||
$patterns = $config['patterns'] ?? null;
|
||||
|
||||
if (count($patterns) === 0 && empty($patterns)) {
|
||||
$this->line('No patterns configured - using defaults');
|
||||
$patterns = GdprProcessor::getDefaultPatterns();
|
||||
}
|
||||
|
||||
foreach ($patterns as $pattern => $replacement) {
|
||||
$this->line(sprintf('%s => %s', $pattern, $replacement));
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
$this->line('Total patterns: ' . count($patterns));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GDPR processing with sample data.
|
||||
*/
|
||||
protected function testWithSampleData(string $testData): void
|
||||
{
|
||||
$this->line('');
|
||||
$this->info('Testing with sample data:');
|
||||
$this->line('-------------------------');
|
||||
|
||||
try {
|
||||
$data = json_decode($testData, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
$processor = \app('gdpr.processor');
|
||||
|
||||
// Test with a sample log record
|
||||
$logRecord = new LogRecord(
|
||||
datetime: new DateTimeImmutable(),
|
||||
channel: 'test',
|
||||
level: Level::Info,
|
||||
message: $data['message'] ?? 'Test message',
|
||||
context: $data['context'] ?? []
|
||||
);
|
||||
|
||||
$result = $processor($logRecord);
|
||||
|
||||
$this->line('Original Message: ' . $logRecord->message);
|
||||
$this->line('Processed Message: ' . $result->message);
|
||||
|
||||
if ($logRecord->context !== []) {
|
||||
$this->line('');
|
||||
$this->line('Original Context:');
|
||||
$this->line((string)json_encode($logRecord->context, JSON_PRETTY_PRINT));
|
||||
|
||||
$this->line('Processed Context:');
|
||||
$this->line((string)json_encode($result->context, JSON_PRETTY_PRINT));
|
||||
}
|
||||
} catch (JsonException $e) {
|
||||
throw CommandExecutionException::forJsonProcessing(
|
||||
self::COMMAND_NAME,
|
||||
$testData,
|
||||
$e->getMessage(),
|
||||
$e
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
throw CommandExecutionException::forOperation(
|
||||
self::COMMAND_NAME,
|
||||
'data processing',
|
||||
$e->getMessage(),
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show summary information.
|
||||
*/
|
||||
protected function showSummary(): void
|
||||
{
|
||||
$this->line('');
|
||||
$this->info('Quick Summary:');
|
||||
$this->line('--------------');
|
||||
|
||||
try {
|
||||
\app('gdpr.processor');
|
||||
$this->line('<info>✓</info> GDPR processor is registered and ready');
|
||||
|
||||
$config = \config('gdpr', []);
|
||||
$patterns = $config['patterns'] ?? GdprProcessor::getDefaultPatterns();
|
||||
$this->line('Patterns configured: ' . count($patterns));
|
||||
} catch (\Throwable $exception) {
|
||||
throw CommandExecutionException::forOperation(
|
||||
self::COMMAND_NAME,
|
||||
'configuration check',
|
||||
'GDPR processor is not properly configured: ' . $exception->getMessage(),
|
||||
$exception
|
||||
);
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
$this->info('Available options:');
|
||||
$this->line(' --show-config Show current configuration');
|
||||
$this->line(' --show-patterns Show all regex patterns');
|
||||
$this->line(' --test-data Test with JSON sample data');
|
||||
|
||||
$this->line('');
|
||||
$this->info('Example usage:');
|
||||
$this->line(' php artisan gdpr:debug --show-config');
|
||||
$this->line(' php artisan gdpr:debug --test-data=\'{"message":"Email: test@example.com"}\'');
|
||||
}
|
||||
}
|
||||
191
src/Laravel/Commands/GdprTestPatternCommand.php
Normal file
191
src/Laravel/Commands/GdprTestPatternCommand.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Laravel\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\PatternValidationException;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\CommandExecutionException;
|
||||
|
||||
/**
|
||||
* Artisan command for testing GDPR regex patterns.
|
||||
*
|
||||
* This command allows developers to test regex patterns against sample data
|
||||
* to ensure they work correctly before deploying to production.
|
||||
*
|
||||
* @api
|
||||
* @psalm-suppress PropertyNotSetInConstructor
|
||||
*/
|
||||
class GdprTestPatternCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'gdpr:test-pattern
|
||||
{pattern : The regex pattern to test}
|
||||
{replacement : The replacement text}
|
||||
{test-string : The string to test against}
|
||||
{--validate : Validate the pattern for security}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Test GDPR regex patterns against sample data';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @psalm-return 0|1
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$args = $this->extractAndNormalizeArguments();
|
||||
$pattern = $args[0];
|
||||
$replacement = $args[1];
|
||||
$testString = $args[2];
|
||||
$validate = $args[3];
|
||||
|
||||
$this->displayTestHeader($pattern, $replacement, $testString);
|
||||
|
||||
if ($validate && !$this->validatePattern($pattern, $replacement)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return $this->executePatternTest($pattern, $replacement, $testString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and normalize command arguments.
|
||||
*
|
||||
* @return array{string, string, string, bool}
|
||||
*/
|
||||
private function extractAndNormalizeArguments(): array
|
||||
{
|
||||
$pattern = $this->argument('pattern');
|
||||
$replacement = $this->argument('replacement');
|
||||
$testString = $this->argument('test-string');
|
||||
$validate = $this->option('validate');
|
||||
|
||||
$pattern = is_array($pattern) ? $pattern[0] : $pattern;
|
||||
$replacement = is_array($replacement) ? $replacement[0] : $replacement;
|
||||
$testString = is_array($testString) ? $testString[0] : $testString;
|
||||
$validate = is_bool($validate) ? $validate : (bool) $validate;
|
||||
|
||||
return [
|
||||
(string) ($pattern ?? ''),
|
||||
(string) ($replacement ?? ''),
|
||||
(string) ($testString ?? ''),
|
||||
$validate,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the test header with pattern information.
|
||||
*/
|
||||
private function displayTestHeader(string $pattern, string $replacement, string $testString): void
|
||||
{
|
||||
$this->info('Testing GDPR Pattern');
|
||||
$this->line('====================');
|
||||
$this->line('Pattern: ' . $pattern);
|
||||
$this->line('Replacement: ' . $replacement);
|
||||
$this->line('Test String: ' . $testString);
|
||||
$this->line('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the pattern if requested.
|
||||
*/
|
||||
private function validatePattern(string $pattern, string $replacement): bool
|
||||
{
|
||||
$this->info('Validating pattern...');
|
||||
try {
|
||||
GdprProcessor::validatePatternsArray([$pattern => $replacement]);
|
||||
$this->line('<info>✓</info> Pattern is valid and secure');
|
||||
} catch (PatternValidationException $e) {
|
||||
$this->error('✗ Pattern validation failed: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the pattern test.
|
||||
*/
|
||||
private function executePatternTest(string $pattern, string $replacement, string $testString): int
|
||||
{
|
||||
$this->info('Testing pattern match...');
|
||||
|
||||
try {
|
||||
$this->validateInputs($pattern, $testString);
|
||||
|
||||
$processor = new GdprProcessor([$pattern => $replacement]);
|
||||
$result = $processor->regExpMessage($testString);
|
||||
|
||||
$this->displayTestResult($result, $testString);
|
||||
$this->showMatchDetails($pattern, $testString);
|
||||
} catch (CommandExecutionException $exception) {
|
||||
$this->error('✗ Pattern test failed: ' . $exception->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate inputs are not empty.
|
||||
*/
|
||||
private function validateInputs(string $pattern, string $testString): void
|
||||
{
|
||||
if ($pattern === '' || $pattern === '0') {
|
||||
throw CommandExecutionException::forInvalidInput(
|
||||
'gdpr:test-pattern',
|
||||
'pattern',
|
||||
$pattern,
|
||||
'Pattern cannot be empty'
|
||||
);
|
||||
}
|
||||
|
||||
if ($testString === '' || $testString === '0') {
|
||||
throw CommandExecutionException::forInvalidInput(
|
||||
'gdpr:test-pattern',
|
||||
'test-string',
|
||||
$testString,
|
||||
'Test string cannot be empty'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the test result.
|
||||
*/
|
||||
private function displayTestResult(string $result, string $testString): void
|
||||
{
|
||||
if ($result === $testString) {
|
||||
$this->line('<comment>-</comment> No match found - string unchanged');
|
||||
} else {
|
||||
$this->line('<info>✓</info> Pattern matched!');
|
||||
$this->line('Result: ' . $result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show detailed matching information.
|
||||
*/
|
||||
private function showMatchDetails(string $pattern, string $testString): void
|
||||
{
|
||||
$matches = [];
|
||||
if (preg_match($pattern, $testString, $matches)) {
|
||||
$this->line('');
|
||||
$this->info('Match details:');
|
||||
foreach ($matches as $index => $match) {
|
||||
$this->line(sprintf(' [%s]: %s', $index, $match));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/Laravel/Facades/Gdpr.php
Normal file
36
src/Laravel/Facades/Gdpr.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Laravel\Facades;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
|
||||
use Monolog\LogRecord;
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
|
||||
/**
|
||||
* Laravel Facade for GDPR Processor.
|
||||
*
|
||||
* @method static string regExpMessage(string $message = '')
|
||||
* @method static array<string, string> getDefaultPatterns()
|
||||
* @method static FieldMaskConfig maskWithRegex()
|
||||
* @method static FieldMaskConfig removeField()
|
||||
* @method static FieldMaskConfig replaceWith(string $replacement)
|
||||
* @method static void validatePatterns(array<string, string> $patterns)
|
||||
* @method static void clearPatternCache()
|
||||
* @method static LogRecord __invoke(LogRecord $record)
|
||||
*
|
||||
* @see \Ivuorinen\MonologGdprFilter\GdprProcessor
|
||||
* @api
|
||||
*/
|
||||
class Gdpr extends Facade
|
||||
{
|
||||
/**
|
||||
* Get the registered name of the component.
|
||||
*
|
||||
*
|
||||
* @psalm-return 'gdpr.processor'
|
||||
*/
|
||||
protected static function getFacadeAccessor(): string
|
||||
{
|
||||
return 'gdpr.processor';
|
||||
}
|
||||
}
|
||||
115
src/Laravel/GdprServiceProvider.php
Normal file
115
src/Laravel/GdprServiceProvider.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Laravel;
|
||||
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\Laravel\Commands\GdprTestPatternCommand;
|
||||
use Ivuorinen\MonologGdprFilter\Laravel\Commands\GdprDebugCommand;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\ServiceRegistrationException;
|
||||
|
||||
/**
|
||||
* Laravel Service Provider for Monolog GDPR Filter.
|
||||
*
|
||||
* This service provider automatically registers the GDPR processor with Laravel's logging system
|
||||
* and provides configuration management and artisan commands.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class GdprServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->mergeConfigFrom(__DIR__ . '/../../config/gdpr.php', 'gdpr');
|
||||
|
||||
$this->app->singleton('gdpr.processor', function (Application $app): GdprProcessor {
|
||||
$config = $app->make('config')->get('gdpr', []);
|
||||
|
||||
$patterns = $config['patterns'] ?? GdprProcessor::getDefaultPatterns();
|
||||
$fieldPaths = $config['field_paths'] ?? [];
|
||||
$customCallbacks = $config['custom_callbacks'] ?? [];
|
||||
$maxDepth = $config['max_depth'] ?? 100;
|
||||
|
||||
$auditLogger = null;
|
||||
if ($config['audit_logging']['enabled'] ?? false) {
|
||||
$auditLogger = function (string $path, mixed $original, mixed $masked): void {
|
||||
Log::channel('gdpr-audit')->info('GDPR Processing', [
|
||||
'path' => $path,
|
||||
'original_type' => gettype($original),
|
||||
'was_masked' => $original !== $masked,
|
||||
'timestamp' => Carbon::now()->toISOString(),
|
||||
]);
|
||||
};
|
||||
}
|
||||
|
||||
return new GdprProcessor(
|
||||
$patterns,
|
||||
$fieldPaths,
|
||||
$customCallbacks,
|
||||
$auditLogger,
|
||||
$maxDepth
|
||||
);
|
||||
});
|
||||
|
||||
$this->app->alias('gdpr.processor', GdprProcessor::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
// Publish configuration file
|
||||
$this->publishes([
|
||||
__DIR__ . '/../../config/gdpr.php' => $this->app->configPath('gdpr.php'),
|
||||
], 'gdpr-config');
|
||||
|
||||
// Register artisan commands
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([
|
||||
GdprTestPatternCommand::class,
|
||||
GdprDebugCommand::class,
|
||||
]);
|
||||
}
|
||||
|
||||
// Auto-register with Laravel's logging system if enabled
|
||||
if (\config('gdpr.auto_register', true)) {
|
||||
$this->registerWithLogging();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically register GDPR processor with Laravel's logging channels.
|
||||
*/
|
||||
protected function registerWithLogging(): void
|
||||
{
|
||||
$logger = $this->app->make('log');
|
||||
$processor = $this->app->make('gdpr.processor');
|
||||
|
||||
// Get channels to apply GDPR processing to
|
||||
$channels = \config('gdpr.channels', ['single', 'daily', 'stack']);
|
||||
|
||||
foreach ($channels as $channelName) {
|
||||
try {
|
||||
$channelLogger = $logger->channel($channelName);
|
||||
if (method_exists($channelLogger, 'pushProcessor')) {
|
||||
$channelLogger->pushProcessor($processor);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Log proper service registration failure but continue with other channels
|
||||
$exception = ServiceRegistrationException::forChannel(
|
||||
$channelName,
|
||||
$e->getMessage(),
|
||||
$e
|
||||
);
|
||||
Log::debug('GDPR service registration warning: ' . $exception->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
207
src/Laravel/Middleware/GdprLogMiddleware.php
Normal file
207
src/Laravel/Middleware/GdprLogMiddleware.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Laravel Middleware for GDPR-compliant logging using MonologGdprFilter.
|
||||
* This middleware logs HTTP requests and responses while filtering out sensitive data
|
||||
* according to GDPR guidelines.
|
||||
*/
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Laravel\Middleware;
|
||||
|
||||
use JsonException;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants;
|
||||
|
||||
/**
|
||||
* Middleware for GDPR-compliant request/response logging.
|
||||
*
|
||||
* This middleware automatically logs HTTP requests and responses
|
||||
* with GDPR filtering applied to sensitive data.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class GdprLogMiddleware
|
||||
{
|
||||
private const LOG_MESSAGE_HTTP_RESPONSE = 'HTTP Response';
|
||||
|
||||
protected GdprProcessor $processor;
|
||||
|
||||
public function __construct(GdprProcessor $processor)
|
||||
{
|
||||
$this->processor = $processor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
|
||||
// Log the incoming request
|
||||
$this->logRequest($request);
|
||||
|
||||
// Process the request
|
||||
$response = $next($request);
|
||||
|
||||
// Log the response
|
||||
$this->logResponse($request, $response, $startTime);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the incoming request with GDPR filtering.
|
||||
*/
|
||||
protected function logRequest(Request $request): void
|
||||
{
|
||||
$requestData = [
|
||||
'method' => $request->method(),
|
||||
'url' => $request->fullUrl(),
|
||||
'ip' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'headers' => $this->filterHeaders($request->headers->all()),
|
||||
'query' => $request->query(),
|
||||
'body' => $this->getRequestBody($request),
|
||||
];
|
||||
|
||||
// Apply GDPR filtering to the entire request data
|
||||
$filteredData = $this->processor->recursiveMask($requestData);
|
||||
|
||||
Log::info('HTTP Request', $filteredData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the response with GDPR filtering.
|
||||
*
|
||||
* @param \Illuminate\Http\Response|\Symfony\Component\HttpFoundation\Response $response
|
||||
*/
|
||||
protected function logResponse(Request $request, mixed $response, float $startTime): void
|
||||
{
|
||||
$duration = round((microtime(true) - $startTime) * 1000, 2);
|
||||
|
||||
$responseData = [
|
||||
'status' => $response->getStatusCode(),
|
||||
'duration_ms' => $duration,
|
||||
'memory_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2),
|
||||
'content_length' => $response->headers->get('Content-Length'),
|
||||
'response_headers' => $this->filterHeaders($response->headers->all()),
|
||||
];
|
||||
|
||||
// Only log response body for errors or if specifically configured
|
||||
if ($response->getStatusCode() >= 400 && config('gdpr.log_error_responses', false)) {
|
||||
$responseData['body'] = $this->getResponseBody($response);
|
||||
}
|
||||
|
||||
// Apply GDPR filtering
|
||||
$filteredData = $this->processor->recursiveMask($responseData);
|
||||
|
||||
$level = $response->getStatusCode() >= 500 ? 'error' : ($response->getStatusCode() >= 400 ? 'warning' : 'info');
|
||||
|
||||
match ($level) {
|
||||
'error' => Log::error(self::LOG_MESSAGE_HTTP_RESPONSE, array_merge(
|
||||
['method' => $request->method(), 'url' => $request->fullUrl()],
|
||||
$filteredData
|
||||
)),
|
||||
'warning' => Log::warning(self::LOG_MESSAGE_HTTP_RESPONSE, array_merge(
|
||||
['method' => $request->method(), 'url' => $request->fullUrl()],
|
||||
$filteredData
|
||||
)),
|
||||
default => Log::info(self::LOG_MESSAGE_HTTP_RESPONSE, array_merge(
|
||||
['method' => $request->method(), 'url' => $request->fullUrl()],
|
||||
$filteredData
|
||||
))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get request body safely.
|
||||
*/
|
||||
protected function getRequestBody(Request $request): mixed
|
||||
{
|
||||
// Only log body for specific content types and methods
|
||||
if (!in_array($request->method(), ['POST', 'PUT', 'PATCH', 'DELETE'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$contentType = $request->header('Content-Type', '');
|
||||
|
||||
if (str_contains($contentType, 'application/json')) {
|
||||
return $request->json()->all();
|
||||
}
|
||||
|
||||
if (str_contains($contentType, 'application/x-www-form-urlencoded')) {
|
||||
return $request->all();
|
||||
}
|
||||
|
||||
if (str_contains($contentType, 'multipart/form-data')) {
|
||||
// Don't log file uploads, just the form fields
|
||||
return $request->except(['_token']) + ['files' => array_keys($request->allFiles())];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get response body safely.
|
||||
*
|
||||
* @param \Illuminate\Http\Response|\Symfony\Component\HttpFoundation\Response $response
|
||||
*/
|
||||
protected function getResponseBody(mixed $response): mixed
|
||||
{
|
||||
if (!method_exists($response, 'getContent')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = $response->getContent();
|
||||
|
||||
// Try to decode JSON responses
|
||||
if (
|
||||
is_object($response) && property_exists($response, 'headers') &&
|
||||
$response->headers->get('Content-Type') &&
|
||||
str_contains((string) $response->headers->get('Content-Type'), 'application/json')
|
||||
) {
|
||||
try {
|
||||
return json_decode((string) $content, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
return ['error' => 'Invalid JSON response'];
|
||||
}
|
||||
}
|
||||
|
||||
// For other content types, limit length to prevent massive logs
|
||||
return strlen((string) $content) > 1000 ? substr((string) $content, 0, 1000) . '...' : $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter sensitive headers.
|
||||
*
|
||||
* @param array<string, mixed> $headers
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function filterHeaders(array $headers): array
|
||||
{
|
||||
$sensitiveHeaders = [
|
||||
'authorization',
|
||||
'x-api-key',
|
||||
'x-auth-token',
|
||||
'cookie',
|
||||
'set-cookie',
|
||||
'php-auth-user',
|
||||
'php-auth-pw',
|
||||
];
|
||||
|
||||
$filtered = [];
|
||||
foreach ($headers as $name => $value) {
|
||||
$filtered[$name] = in_array(strtolower($name), $sensitiveHeaders) ? [MaskConstants::MASK_FILTERED] : $value;
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
}
|
||||
91
src/MaskConstants.php
Normal file
91
src/MaskConstants.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter;
|
||||
|
||||
/**
|
||||
* Constants for mask replacement values.
|
||||
*
|
||||
* This class provides standardized mask values to avoid duplication
|
||||
* and ensure consistency across the codebase.
|
||||
*/
|
||||
final class MaskConstants
|
||||
{
|
||||
// Data type masks
|
||||
public const MASK_INT = '***INT***';
|
||||
public const MASK_FLOAT = '***FLOAT***';
|
||||
public const MASK_STRING = '***STRING***';
|
||||
public const MASK_BOOL = '***BOOL***';
|
||||
public const MASK_NULL = '***NULL***';
|
||||
public const MASK_ARRAY = '***ARRAY***';
|
||||
public const MASK_OBJECT = '***OBJECT***';
|
||||
public const MASK_RESOURCE = '***RESOURCE***';
|
||||
|
||||
// Generic masks
|
||||
public const MASK_GENERIC = '***'; // Simple generic mask
|
||||
public const MASK_MASKED = '***MASKED***';
|
||||
public const MASK_REDACTED = '***REDACTED***';
|
||||
public const MASK_FILTERED = '***FILTERED***';
|
||||
public const MASK_BRACKETS = '[MASKED]';
|
||||
public const MASK_REDACTED_BRACKETS = '[REDACTED]';
|
||||
|
||||
// Personal identifiers
|
||||
public const MASK_HETU = '***HETU***'; // Finnish SSN
|
||||
public const MASK_SSN = '***SSN***'; // Generic SSN
|
||||
public const MASK_USSSN = '***USSSN***'; // US SSN
|
||||
public const MASK_UKNI = '***UKNI***'; // UK National Insurance
|
||||
public const MASK_CASIN = '***CASIN***'; // Canadian SIN
|
||||
public const MASK_PASSPORT = '***PASSPORT***';
|
||||
|
||||
// Financial information
|
||||
public const MASK_IBAN = '***IBAN***';
|
||||
public const MASK_CC = '***CC***'; // Credit Card
|
||||
public const MASK_CARD = '***CARD***'; // Credit Card (alternative)
|
||||
public const MASK_UKBANK = '***UKBANK***';
|
||||
public const MASK_CABANK = '***CABANK***';
|
||||
|
||||
// Contact information
|
||||
public const MASK_EMAIL = '***EMAIL***';
|
||||
public const MASK_PHONE = '***PHONE***';
|
||||
public const MASK_IP = '***IP***';
|
||||
|
||||
// Security tokens and keys
|
||||
public const MASK_TOKEN = '***TOKEN***';
|
||||
public const MASK_APIKEY = '***APIKEY***';
|
||||
public const MASK_SECRET = '***SECRET***';
|
||||
|
||||
// Personal data
|
||||
public const MASK_DOB = '***DOB***'; // Date of Birth
|
||||
public const MASK_MAC = '***MAC***'; // MAC Address
|
||||
|
||||
// Vehicle and identification
|
||||
public const MASK_VEHICLE = '***VEHICLE***';
|
||||
|
||||
// Healthcare
|
||||
public const MASK_MEDICARE = '***MEDICARE***';
|
||||
public const MASK_EHIC = '***EHIC***'; // European Health Insurance Card
|
||||
|
||||
// Custom/Internal
|
||||
public const MASK_INTERNAL = '***INTERNAL***';
|
||||
public const MASK_CUSTOMER = '***CUSTOMER***';
|
||||
public const MASK_NUMBER = '***NUMBER***';
|
||||
public const MASK_ITEM = '***ITEM***';
|
||||
|
||||
// Custom mask patterns for partial masking
|
||||
public const MASK_SSN_PATTERN = '***-**-****'; // SSN with format preserved
|
||||
public const MASK_EMAIL_PATTERN = '***@***.***'; // Email with format preserved
|
||||
|
||||
// Error states
|
||||
public const MASK_INVALID = '***INVALID***';
|
||||
public const MASK_TOOLONG = '***TOOLONG***';
|
||||
public const MASK_ERROR = '***ERROR***';
|
||||
|
||||
/**
|
||||
* Prevent instantiation.
|
||||
*
|
||||
* @psalm-suppress UnusedConstructor
|
||||
*/
|
||||
private function __construct()
|
||||
{}
|
||||
}
|
||||
291
src/MaskingOrchestrator.php
Normal file
291
src/MaskingOrchestrator.php
Normal file
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter;
|
||||
|
||||
use Error;
|
||||
use Ivuorinen\MonologGdprFilter\ArrayAccessor\ArrayAccessorFactory;
|
||||
|
||||
/**
|
||||
* Coordinates masking operations across different processors.
|
||||
*
|
||||
* This class orchestrates the masking workflow:
|
||||
* 1. Applies regex patterns to messages
|
||||
* 2. Processes field paths in context data
|
||||
* 3. Executes custom callbacks
|
||||
* 4. Applies data type masking
|
||||
*
|
||||
* Separated from GdprProcessor to enable use outside Monolog context.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class MaskingOrchestrator
|
||||
{
|
||||
private readonly DataTypeMasker $dataTypeMasker;
|
||||
private readonly JsonMasker $jsonMasker;
|
||||
private readonly ContextProcessor $contextProcessor;
|
||||
private readonly RecursiveProcessor $recursiveProcessor;
|
||||
private readonly ArrayAccessorFactory $arrayAccessorFactory;
|
||||
|
||||
/**
|
||||
* @var callable(string,mixed,mixed):void|null
|
||||
*/
|
||||
private $auditLogger;
|
||||
|
||||
/**
|
||||
* @param array<string,string> $patterns Regex pattern => replacement
|
||||
* @param array<string,FieldMaskConfig|string> $fieldPaths Dot-notation path => FieldMaskConfig
|
||||
* @param array<string,callable(mixed):string> $customCallbacks Dot-notation path => callback(value): string
|
||||
* @param callable(string,mixed,mixed):void|null $auditLogger Optional audit logger callback
|
||||
* @param int $maxDepth Maximum recursion depth for nested structures
|
||||
* @param array<string,string> $dataTypeMasks Type-based masking: type => mask pattern
|
||||
* @param ArrayAccessorFactory|null $arrayAccessorFactory Factory for creating array accessors
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $patterns,
|
||||
private readonly array $fieldPaths = [],
|
||||
private readonly array $customCallbacks = [],
|
||||
?callable $auditLogger = null,
|
||||
int $maxDepth = 100,
|
||||
array $dataTypeMasks = [],
|
||||
?ArrayAccessorFactory $arrayAccessorFactory = null
|
||||
) {
|
||||
$this->auditLogger = $auditLogger;
|
||||
$this->arrayAccessorFactory = $arrayAccessorFactory ?? ArrayAccessorFactory::default();
|
||||
|
||||
// Initialize data type masker
|
||||
$this->dataTypeMasker = new DataTypeMasker($dataTypeMasks, $auditLogger);
|
||||
|
||||
// Initialize recursive processor for data structure processing
|
||||
$this->recursiveProcessor = new RecursiveProcessor(
|
||||
$this->regExpMessage(...),
|
||||
$this->dataTypeMasker,
|
||||
$auditLogger,
|
||||
$maxDepth
|
||||
);
|
||||
|
||||
// Initialize JSON masker with recursive mask callback
|
||||
/** @psalm-suppress InvalidArgument - recursiveMask is intentionally impure due to audit logging */
|
||||
$this->jsonMasker = new JsonMasker(
|
||||
$this->recursiveProcessor->recursiveMask(...),
|
||||
$auditLogger
|
||||
);
|
||||
|
||||
// Initialize context processor for field-level operations
|
||||
$this->contextProcessor = new ContextProcessor(
|
||||
$fieldPaths,
|
||||
$customCallbacks,
|
||||
$auditLogger,
|
||||
$this->regExpMessage(...)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an orchestrator with validated parameters.
|
||||
*
|
||||
* @param array<string,string> $patterns Regex pattern => replacement
|
||||
* @param array<string,FieldMaskConfig|string> $fieldPaths Dot-notation path => FieldMaskConfig
|
||||
* @param array<string,callable(mixed):string> $customCallbacks Dot-notation path => callback
|
||||
* @param callable(string,mixed,mixed):void|null $auditLogger Optional audit logger callback
|
||||
* @param int $maxDepth Maximum recursion depth for nested structures
|
||||
* @param array<string,string> $dataTypeMasks Type-based masking
|
||||
* @param ArrayAccessorFactory|null $arrayAccessorFactory Factory for creating array accessors
|
||||
*
|
||||
* @throws \InvalidArgumentException When any parameter is invalid
|
||||
*/
|
||||
public static function create(
|
||||
array $patterns,
|
||||
array $fieldPaths = [],
|
||||
array $customCallbacks = [],
|
||||
?callable $auditLogger = null,
|
||||
int $maxDepth = 100,
|
||||
array $dataTypeMasks = [],
|
||||
?ArrayAccessorFactory $arrayAccessorFactory = null
|
||||
): self {
|
||||
// Validate all parameters
|
||||
InputValidator::validateAll(
|
||||
$patterns,
|
||||
$fieldPaths,
|
||||
$customCallbacks,
|
||||
$auditLogger,
|
||||
$maxDepth,
|
||||
$dataTypeMasks,
|
||||
[]
|
||||
);
|
||||
|
||||
// Pre-validate and cache patterns for better performance
|
||||
/** @psalm-suppress DeprecatedMethod - Internal use of caching mechanism */
|
||||
PatternValidator::cachePatterns($patterns);
|
||||
|
||||
return new self(
|
||||
$patterns,
|
||||
$fieldPaths,
|
||||
$customCallbacks,
|
||||
$auditLogger,
|
||||
$maxDepth,
|
||||
$dataTypeMasks,
|
||||
$arrayAccessorFactory
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process data by masking sensitive information.
|
||||
*
|
||||
* @param string $message The message to mask
|
||||
* @param array<string,mixed> $context The context data to mask
|
||||
* @return array{message: string, context: array<string,mixed>}
|
||||
*/
|
||||
public function process(string $message, array $context): array
|
||||
{
|
||||
$maskedMessage = $this->regExpMessage($message);
|
||||
$maskedContext = $this->processContext($context);
|
||||
|
||||
return [
|
||||
'message' => $maskedMessage,
|
||||
'context' => $maskedContext,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process context data by masking sensitive information.
|
||||
*
|
||||
* @param array<string,mixed> $context The context data to mask
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function processContext(array $context): array
|
||||
{
|
||||
$accessor = $this->arrayAccessorFactory->create($context);
|
||||
$processedFields = [];
|
||||
|
||||
if ($this->fieldPaths !== []) {
|
||||
$processedFields = array_merge($processedFields, $this->contextProcessor->maskFieldPaths($accessor));
|
||||
}
|
||||
|
||||
if ($this->customCallbacks !== []) {
|
||||
$processedFields = array_merge(
|
||||
$processedFields,
|
||||
$this->contextProcessor->processCustomCallbacks($accessor)
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->fieldPaths !== [] || $this->customCallbacks !== []) {
|
||||
$context = $accessor->all();
|
||||
// Apply data type masking to the entire context after field/callback processing
|
||||
return $this->dataTypeMasker->applyToContext(
|
||||
$context,
|
||||
$processedFields,
|
||||
'',
|
||||
$this->recursiveProcessor->recursiveMask(...)
|
||||
);
|
||||
}
|
||||
|
||||
return $this->recursiveProcessor->recursiveMask($context, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask a string using all regex patterns with JSON support.
|
||||
*/
|
||||
public function regExpMessage(string $message = ''): string
|
||||
{
|
||||
// Early return for empty messages
|
||||
if ($message === '') {
|
||||
return $message;
|
||||
}
|
||||
|
||||
// Track original message for empty result protection
|
||||
$originalMessage = $message;
|
||||
|
||||
// Handle JSON strings and regular patterns in a coordinated way
|
||||
$message = $this->maskMessageWithJsonSupport($message);
|
||||
|
||||
return $message === '' || $message === '0' ? $originalMessage : $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask message content, handling both JSON structures and regular patterns.
|
||||
*/
|
||||
private function maskMessageWithJsonSupport(string $message): string
|
||||
{
|
||||
// Use JsonMasker to process JSON structures
|
||||
$result = $this->jsonMasker->processMessage($message);
|
||||
|
||||
// Now apply regular patterns to the entire result
|
||||
foreach ($this->patterns as $regex => $replacement) {
|
||||
try {
|
||||
/** @psalm-suppress ArgumentTypeCoercion */
|
||||
$newResult = preg_replace($regex, $replacement, $result, -1, $count);
|
||||
|
||||
if ($newResult === null) {
|
||||
$error = preg_last_error_msg();
|
||||
|
||||
if ($this->auditLogger !== null) {
|
||||
($this->auditLogger)('preg_replace_error', $result, 'Error: ' . $error);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($count > 0) {
|
||||
$result = $newResult;
|
||||
}
|
||||
} catch (Error $e) {
|
||||
if ($this->auditLogger !== null) {
|
||||
($this->auditLogger)('regex_error', $regex, $e->getMessage());
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively mask all string values in an array using regex patterns.
|
||||
*
|
||||
* @param array<mixed>|string $data
|
||||
* @param int $currentDepth Current recursion depth
|
||||
* @return array<mixed>|string
|
||||
*/
|
||||
public function recursiveMask(array|string $data, int $currentDepth = 0): array|string
|
||||
{
|
||||
return $this->recursiveProcessor->recursiveMask($data, $currentDepth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the context processor for direct access.
|
||||
*/
|
||||
public function getContextProcessor(): ContextProcessor
|
||||
{
|
||||
return $this->contextProcessor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the recursive processor for direct access.
|
||||
*/
|
||||
public function getRecursiveProcessor(): RecursiveProcessor
|
||||
{
|
||||
return $this->recursiveProcessor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array accessor factory.
|
||||
*/
|
||||
public function getArrayAccessorFactory(): ArrayAccessorFactory
|
||||
{
|
||||
return $this->arrayAccessorFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the audit logger callable.
|
||||
*
|
||||
* @param callable(string,mixed,mixed):void|null $auditLogger
|
||||
*/
|
||||
public function setAuditLogger(?callable $auditLogger): void
|
||||
{
|
||||
$this->auditLogger = $auditLogger;
|
||||
$this->contextProcessor->setAuditLogger($auditLogger);
|
||||
$this->recursiveProcessor->setAuditLogger($auditLogger);
|
||||
}
|
||||
}
|
||||
307
src/PatternValidator.php
Normal file
307
src/PatternValidator.php
Normal file
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter;
|
||||
|
||||
use Error;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidRegexPatternException;
|
||||
|
||||
/**
|
||||
* Validates regex patterns for safety and correctness.
|
||||
*
|
||||
* This class provides pattern validation with ReDoS (Regular Expression Denial of Service)
|
||||
* protection and caching for improved performance.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class PatternValidator
|
||||
{
|
||||
/**
|
||||
* Instance cache for compiled regex patterns.
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
private array $instanceCache = [];
|
||||
|
||||
/**
|
||||
* Static cache for compiled regex patterns (for backward compatibility).
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
private static array $validPatternCache = [];
|
||||
|
||||
/**
|
||||
* Dangerous pattern checks.
|
||||
* @var list<non-empty-string>
|
||||
*/
|
||||
private static array $dangerousPatterns = [
|
||||
// Nested quantifiers (classic ReDoS patterns)
|
||||
'/\([^)]*\+[^)]*\)\+/', // (a+)+ pattern
|
||||
'/\([^)]*\*[^)]*\)\*/', // (a*)* pattern
|
||||
'/\([^)]*\+[^)]*\)\*/', // (a+)* pattern
|
||||
'/\([^)]*\*[^)]*\)\+/', // (a*)+ pattern
|
||||
|
||||
// Alternation with overlapping patterns
|
||||
'/\([^|)]*\|[^|)]*\)\*/', // (a|a)* pattern
|
||||
'/\([^|)]*\|[^|)]*\)\+/', // (a|a)+ pattern
|
||||
|
||||
// Complex nested structures
|
||||
'/\(\([^)]*\+[^)]*\)[^)]*\)\+/', // ((a+)...)+ pattern
|
||||
|
||||
// Character classes with nested quantifiers
|
||||
'/\[[^\]]*\]\*\*/', // [a-z]** pattern
|
||||
'/\[[^\]]*\]\+\+/', // [a-z]++ pattern
|
||||
'/\([^)]*\[[^\]]*\][^)]*\)\*/', // ([a-z])* pattern
|
||||
'/\([^)]*\[[^\]]*\][^)]*\)\+/', // ([a-z])+ pattern
|
||||
|
||||
// Lookahead/lookbehind with quantifiers
|
||||
'/\(\?\=[^)]*\)\([^)]*\)\+/', // (?=...)(...)+
|
||||
'/\(\?\<[^)]*\)\([^)]*\)\+/', // (?<...)(...)+
|
||||
|
||||
// Word boundaries with dangerous quantifiers
|
||||
'/\\\\w\+\*/', // \w+* pattern
|
||||
'/\\\\w\*\+/', // \w*+ pattern
|
||||
|
||||
// Dot with dangerous quantifiers
|
||||
'/\.\*\*/', // .** pattern
|
||||
'/\.\+\+/', // .++ pattern
|
||||
'/\(\.\*\)\+/', // (.*)+ pattern
|
||||
'/\(\.\+\)\*/', // (.+)* pattern
|
||||
|
||||
// Legacy dangerous patterns (keeping for backward compatibility)
|
||||
'/\(\?.*\*.*\+/', // (?:...*...)+
|
||||
'/\(.*\*.*\).*\*/', // (...*...).*
|
||||
|
||||
// Overlapping alternation patterns - catastrophic backtracking
|
||||
'/\(\.\*\s*\|\s*\.\*\)/', // (.*|.*) pattern - identical alternations
|
||||
'/\(\.\+\s*\|\s*\.\+\)/', // (.+|.+) pattern - identical alternations
|
||||
|
||||
// Multiple alternations with overlapping/expanding strings causing exponential backtracking
|
||||
// Matches patterns like (a|ab|abc|abcd)* where alternatives overlap/extend each other
|
||||
'/\([a-zA-Z0-9]+(\s*\|\s*[a-zA-Z0-9]+){2,}\)\*/',
|
||||
'/\([a-zA-Z0-9]+(\s*\|\s*[a-zA-Z0-9]+){2,}\)\+/',
|
||||
];
|
||||
|
||||
/**
|
||||
* Create a new PatternValidator instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
// Instance cache starts empty
|
||||
}
|
||||
|
||||
/**
|
||||
* Static factory method.
|
||||
*/
|
||||
public static function create(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the instance cache.
|
||||
*/
|
||||
public function clearInstanceCache(): void
|
||||
{
|
||||
$this->instanceCache = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a regex pattern is safe and well-formed.
|
||||
*/
|
||||
public function validate(string $pattern): bool
|
||||
{
|
||||
// Check instance cache first
|
||||
if (isset($this->instanceCache[$pattern])) {
|
||||
return $this->instanceCache[$pattern];
|
||||
}
|
||||
|
||||
$isValid = $this->performValidation($pattern);
|
||||
$this->instanceCache[$pattern] = $isValid;
|
||||
|
||||
return $isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-validate patterns for better runtime performance.
|
||||
*
|
||||
* @param array<string, string> $patterns
|
||||
*/
|
||||
public function cacheAllPatterns(array $patterns): void
|
||||
{
|
||||
foreach (array_keys($patterns) as $pattern) {
|
||||
if (!isset($this->instanceCache[$pattern])) {
|
||||
$this->instanceCache[$pattern] = $this->validate($pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all patterns for security before use.
|
||||
*
|
||||
* @param array<string, string> $patterns
|
||||
* @throws InvalidRegexPatternException If any pattern is invalid or unsafe
|
||||
*/
|
||||
public function validateAllPatterns(array $patterns): void
|
||||
{
|
||||
foreach (array_keys($patterns) as $pattern) {
|
||||
if (!$this->validate($pattern)) {
|
||||
throw InvalidRegexPatternException::forPattern(
|
||||
$pattern,
|
||||
'Pattern failed validation or is potentially unsafe'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the instance cache.
|
||||
*
|
||||
* @return array<string, bool>
|
||||
*/
|
||||
public function getInstanceCache(): array
|
||||
{
|
||||
return $this->instanceCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the actual validation logic.
|
||||
*/
|
||||
private function performValidation(string $pattern): bool
|
||||
{
|
||||
// Check for basic regex structure
|
||||
$firstChar = $pattern[0];
|
||||
$lastDelimPos = strrpos($pattern, $firstChar);
|
||||
|
||||
// Consolidated validation checks - return false if any basic check fails
|
||||
if (
|
||||
strlen($pattern) < 3
|
||||
|| $lastDelimPos === false
|
||||
|| $lastDelimPos === 0
|
||||
|| $this->checkDangerousPattern($pattern)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Test if the pattern is valid by trying to compile it
|
||||
return $this->testPatternCompilation($pattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a pattern contains dangerous constructs that could cause ReDoS.
|
||||
*/
|
||||
private function checkDangerousPattern(string $pattern): bool
|
||||
{
|
||||
foreach (self::$dangerousPatterns as $dangerousPattern) {
|
||||
if (preg_match($dangerousPattern, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if the pattern compiles successfully.
|
||||
*/
|
||||
private function testPatternCompilation(string $pattern): bool
|
||||
{
|
||||
set_error_handler(
|
||||
/**
|
||||
* @return true
|
||||
*/
|
||||
static fn(): bool => true
|
||||
);
|
||||
|
||||
try {
|
||||
/** @psalm-suppress ArgumentTypeCoercion */
|
||||
$result = preg_match($pattern, '');
|
||||
return $result !== false;
|
||||
} catch (Error) {
|
||||
return false;
|
||||
} finally {
|
||||
restore_error_handler();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DEPRECATED STATIC METHODS - Use instance methods instead
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Clear the pattern validation cache (useful for testing).
|
||||
*
|
||||
* @deprecated Use instance method clearInstanceCache() instead
|
||||
*/
|
||||
public static function clearCache(): void
|
||||
{
|
||||
self::$validPatternCache = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a regex pattern is safe and well-formed.
|
||||
* This helps prevent regex injection and ReDoS attacks.
|
||||
*
|
||||
* @deprecated Use instance method validate() instead
|
||||
*/
|
||||
public static function isValid(string $pattern): bool
|
||||
{
|
||||
// Check cache first
|
||||
if (isset(self::$validPatternCache[$pattern])) {
|
||||
return self::$validPatternCache[$pattern];
|
||||
}
|
||||
|
||||
$validator = new self();
|
||||
$isValid = $validator->performValidation($pattern);
|
||||
|
||||
self::$validPatternCache[$pattern] = $isValid;
|
||||
return $isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-validate patterns during construction for better runtime performance.
|
||||
*
|
||||
* @param array<string, string> $patterns
|
||||
* @deprecated Use instance method cacheAllPatterns() instead
|
||||
*/
|
||||
public static function cachePatterns(array $patterns): void
|
||||
{
|
||||
foreach (array_keys($patterns) as $pattern) {
|
||||
if (!isset(self::$validPatternCache[$pattern])) {
|
||||
/** @psalm-suppress DeprecatedMethod - Internal self-call within deprecated method */
|
||||
self::$validPatternCache[$pattern] = self::isValid($pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all patterns for security before use.
|
||||
* This method can be called to validate patterns before creating a processor.
|
||||
*
|
||||
* @param array<string, string> $patterns
|
||||
* @throws InvalidRegexPatternException If any pattern is invalid or unsafe
|
||||
* @deprecated Use instance method validateAllPatterns() instead
|
||||
*/
|
||||
public static function validateAll(array $patterns): void
|
||||
{
|
||||
foreach (array_keys($patterns) as $pattern) {
|
||||
/** @psalm-suppress DeprecatedMethod - Internal self-call within deprecated method */
|
||||
if (!self::isValid($pattern)) {
|
||||
throw InvalidRegexPatternException::forPattern(
|
||||
$pattern,
|
||||
'Pattern failed validation or is potentially unsafe'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current pattern cache.
|
||||
*
|
||||
* @return array<string, bool>
|
||||
* @deprecated Use instance method getInstanceCache() instead
|
||||
*/
|
||||
public static function getCache(): array
|
||||
{
|
||||
return self::$validPatternCache;
|
||||
}
|
||||
}
|
||||
82
src/Plugins/AbstractMaskingPlugin.php
Normal file
82
src/Plugins/AbstractMaskingPlugin.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Plugins;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Contracts\MaskingPluginInterface;
|
||||
|
||||
/**
|
||||
* Abstract base class for masking plugins.
|
||||
*
|
||||
* Provides default no-op implementations for all plugin methods,
|
||||
* allowing plugins to override only the methods they need.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
abstract class AbstractMaskingPlugin implements MaskingPluginInterface
|
||||
{
|
||||
/**
|
||||
* @param int $priority Plugin priority (lower = earlier execution, default: 100)
|
||||
*/
|
||||
public function __construct(
|
||||
protected readonly int $priority = 100
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function preProcessContext(array $context): array
|
||||
{
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function postProcessContext(array $context): array
|
||||
{
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function preProcessMessage(string $message): string
|
||||
{
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function postProcessMessage(string $message): string
|
||||
{
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getPatterns(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getFieldPaths(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getPriority(): int
|
||||
{
|
||||
return $this->priority;
|
||||
}
|
||||
}
|
||||
203
src/RateLimitedAuditLogger.php
Normal file
203
src/RateLimitedAuditLogger.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter;
|
||||
|
||||
/**
|
||||
* Rate-limited wrapper for audit logging to prevent log flooding.
|
||||
*
|
||||
* This class wraps any audit logger callable and applies rate limiting
|
||||
* to prevent overwhelming the audit system with too many log entries.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class RateLimitedAuditLogger
|
||||
{
|
||||
private readonly RateLimiter $rateLimiter;
|
||||
|
||||
/**
|
||||
* @param callable(string,mixed,mixed):void $auditLogger The underlying audit logger
|
||||
* @param int $maxRequestsPerMinute Maximum audit log entries per minute (default: 100)
|
||||
* @param int $windowSeconds Time window for rate limiting in seconds (default: 60)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly mixed $auditLogger,
|
||||
int $maxRequestsPerMinute = 100,
|
||||
int $windowSeconds = 60
|
||||
) {
|
||||
$this->rateLimiter = new RateLimiter($maxRequestsPerMinute, $windowSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an audit entry if rate limiting allows it.
|
||||
*
|
||||
* @param string $path The path or operation being audited
|
||||
* @param mixed $original The original value
|
||||
* @param mixed $masked The masked value
|
||||
*/
|
||||
public function __invoke(string $path, mixed $original, mixed $masked): void
|
||||
{
|
||||
// Use a combination of path and operation type as the rate limiting key
|
||||
$key = $this->generateRateLimitKey($path);
|
||||
|
||||
if ($this->rateLimiter->isAllowed($key)) {
|
||||
// Rate limit allows this log entry
|
||||
/** @psalm-suppress RedundantConditionGivenDocblockType - Runtime validation for defensive programming */
|
||||
if (is_callable($this->auditLogger)) {
|
||||
($this->auditLogger)($path, $original, $masked);
|
||||
}
|
||||
} else {
|
||||
// Rate limit exceeded - optionally log a rate limit warning
|
||||
$this->logRateLimitExceeded($path, $key);
|
||||
}
|
||||
}
|
||||
|
||||
public function isOperationAllowed(string $path): bool
|
||||
{
|
||||
// Use a combination of path and operation type as the rate limiting key
|
||||
$key = $this->generateRateLimitKey($path);
|
||||
|
||||
return $this->rateLimiter->isAllowed($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate limiting statistics for all active operation types.
|
||||
*
|
||||
* @return int[][]
|
||||
*
|
||||
* @psalm-return array{
|
||||
* 'audit:general_operations'?: array{
|
||||
* current_requests: int<1, max>,
|
||||
* remaining_requests: int<0, max>,
|
||||
* time_until_reset: int<0, max>
|
||||
* },
|
||||
* 'audit:error_operations'?: array{
|
||||
* current_requests: int<1, max>,
|
||||
* remaining_requests: int<0, max>,
|
||||
* time_until_reset: int<0, max>
|
||||
* },
|
||||
* 'audit:regex_operations'?: array{
|
||||
* current_requests: int<1, max>,
|
||||
* remaining_requests: int<0, max>,
|
||||
* time_until_reset: int<0, max>
|
||||
* },
|
||||
* 'audit:conditional_operations'?: array{
|
||||
* current_requests: int<1, max>,
|
||||
* remaining_requests: int<0, max>,
|
||||
* time_until_reset: int<0, max>
|
||||
* },
|
||||
* 'audit:json_operations'?: array{
|
||||
* current_requests: int<1, max>,
|
||||
* remaining_requests: int<0, max>,
|
||||
* time_until_reset: int<0, max>
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public function getRateLimitStats(): array
|
||||
{
|
||||
// Get all possible operation types based on the classification logic
|
||||
$operationTypes = [
|
||||
'audit:json_operations',
|
||||
'audit:conditional_operations',
|
||||
'audit:regex_operations',
|
||||
'audit:error_operations',
|
||||
'audit:general_operations'
|
||||
];
|
||||
|
||||
$stats = [];
|
||||
foreach ($operationTypes as $type) {
|
||||
$typeStats = $this->rateLimiter->getStats($type);
|
||||
// Only include operation types that have been used
|
||||
if ($typeStats['current_requests'] > 0) {
|
||||
$stats[$type] = $typeStats;
|
||||
}
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all rate limiting data.
|
||||
*/
|
||||
public function clearRateLimitData(): void
|
||||
{
|
||||
RateLimiter::clearAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a rate limiting key based on the audit operation.
|
||||
*
|
||||
* This allows different types of operations to have separate rate limits.
|
||||
*/
|
||||
private function generateRateLimitKey(string $path): string
|
||||
{
|
||||
// Group similar operations together to prevent flooding of specific operation types
|
||||
$operationType = $this->getOperationType($path);
|
||||
|
||||
// Use operation type as the primary key for rate limiting
|
||||
return 'audit:' . $operationType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the operation type from the path.
|
||||
*/
|
||||
private function getOperationType(string $path): string
|
||||
{
|
||||
// Group different operations into categories for rate limiting
|
||||
return match (true) {
|
||||
str_contains($path, 'json_') => 'json_operations',
|
||||
str_contains($path, 'conditional_') => 'conditional_operations',
|
||||
str_contains($path, 'regex_') => 'regex_operations',
|
||||
str_contains($path, 'preg_replace_') => 'regex_operations',
|
||||
str_contains($path, 'error') => 'error_operations',
|
||||
default => 'general_operations'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log when rate limiting is exceeded (with its own rate limiting to prevent spam).
|
||||
*/
|
||||
private function logRateLimitExceeded(string $path, string $key): void
|
||||
{
|
||||
// Create a separate rate limiter for warnings to avoid interfering with main rate limiting
|
||||
static $warningRateLimiter = null;
|
||||
if ($warningRateLimiter === null) {
|
||||
$warningRateLimiter = new RateLimiter(1, 60); // 1 warning per minute per operation type
|
||||
}
|
||||
|
||||
$warningKey = 'warning:' . $key;
|
||||
|
||||
// Only log rate limit warnings once per minute per operation type to prevent warning spam
|
||||
/** @psalm-suppress RedundantConditionGivenDocblockType - Runtime validation for defensive programming */
|
||||
if ($warningRateLimiter->isAllowed($warningKey) === true && is_callable($this->auditLogger)) {
|
||||
$statsJson = json_encode($this->rateLimiter->getStats($key));
|
||||
($this->auditLogger)(
|
||||
'rate_limit_exceeded',
|
||||
$path,
|
||||
sprintf(
|
||||
'Audit logging rate limit exceeded for operation type: %s. Stats: %s',
|
||||
$key,
|
||||
$statsJson !== false ? $statsJson : 'N/A'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a factory method for common configurations.
|
||||
*
|
||||
* @psalm-param callable(string, mixed, mixed):void $auditLogger
|
||||
*/
|
||||
public static function create(
|
||||
callable $auditLogger,
|
||||
string $profile = 'default'
|
||||
): self {
|
||||
return match ($profile) {
|
||||
'strict' => new self($auditLogger, 50, 60), // 50 per minute
|
||||
'relaxed' => new self($auditLogger, 200, 60), // 200 per minute
|
||||
'testing' => new self($auditLogger, 1000, 60), // 1000 per minute for testing
|
||||
default => new self($auditLogger, 100, 60), // 100 per minute (default)
|
||||
};
|
||||
}
|
||||
}
|
||||
314
src/RateLimiter.php
Normal file
314
src/RateLimiter.php
Normal file
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidRateLimitConfigurationException;
|
||||
|
||||
/**
|
||||
* Simple rate limiter to prevent audit log flooding.
|
||||
*
|
||||
* Uses a sliding window approach with memory-based storage.
|
||||
* For production use, consider implementing persistent storage.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class RateLimiter
|
||||
{
|
||||
/**
|
||||
* Storage for request timestamps per key.
|
||||
* @var array<string, array<int>>
|
||||
*/
|
||||
private static array $requests = [];
|
||||
|
||||
/**
|
||||
* Last time global cleanup was performed.
|
||||
*/
|
||||
private static int $lastCleanup = 0;
|
||||
|
||||
/**
|
||||
* How often to perform global cleanup (in seconds).
|
||||
*/
|
||||
private static int $cleanupInterval = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
* @param int $maxRequests Maximum number of requests allowed
|
||||
* @param int $windowSeconds Time window in seconds
|
||||
*
|
||||
* @throws InvalidRateLimitConfigurationException When parameters are invalid
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly int $maxRequests,
|
||||
private readonly int $windowSeconds
|
||||
) {
|
||||
// Validate maxRequests
|
||||
if ($this->maxRequests <= 0) {
|
||||
throw InvalidRateLimitConfigurationException::invalidMaxRequests($this->maxRequests);
|
||||
}
|
||||
|
||||
if ($this->maxRequests > 1000000) {
|
||||
throw InvalidRateLimitConfigurationException::forParameter(
|
||||
'max_requests',
|
||||
$this->maxRequests,
|
||||
'Cannot exceed 1,000,000 for memory safety'
|
||||
);
|
||||
}
|
||||
|
||||
// Validate windowSeconds
|
||||
if ($this->windowSeconds <= 0) {
|
||||
throw InvalidRateLimitConfigurationException::invalidTimeWindow($this->windowSeconds);
|
||||
}
|
||||
|
||||
if ($this->windowSeconds > 86400) { // 24 hours max
|
||||
throw InvalidRateLimitConfigurationException::forParameter(
|
||||
'window_seconds',
|
||||
$this->windowSeconds,
|
||||
'Cannot exceed 86,400 (24 hours) for practical reasons'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request is allowed for the given key.
|
||||
*
|
||||
* @throws InvalidRateLimitConfigurationException When key is invalid
|
||||
*/
|
||||
public function isAllowed(string $key): bool
|
||||
{
|
||||
$this->validateKey($key);
|
||||
$now = time();
|
||||
$windowStart = $now - $this->windowSeconds;
|
||||
|
||||
// Initialize key if not exists
|
||||
if (!isset(self::$requests[$key])) {
|
||||
self::$requests[$key] = [];
|
||||
}
|
||||
|
||||
// Remove old requests outside the window
|
||||
self::$requests[$key] = array_filter(
|
||||
self::$requests[$key],
|
||||
fn(int $timestamp): bool => $timestamp > $windowStart
|
||||
);
|
||||
|
||||
// Perform global cleanup periodically to prevent memory leaks
|
||||
$this->performGlobalCleanupIfNeeded($now);
|
||||
|
||||
// Check if we're under the limit
|
||||
if (count(self::$requests[$key] ?? []) < $this->maxRequests) {
|
||||
// Add current request
|
||||
self::$requests[$key][] = $now;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time until next request is allowed (in seconds).
|
||||
*
|
||||
* @psalm-return int<0, max>
|
||||
* @throws InvalidRateLimitConfigurationException When key is invalid
|
||||
*/
|
||||
public function getTimeUntilReset(string $key): int
|
||||
{
|
||||
$this->validateKey($key);
|
||||
if (!isset(self::$requests[$key]) || empty(self::$requests[$key])) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$oldestRequest = min(self::$requests[$key]);
|
||||
$resetTime = $oldestRequest + $this->windowSeconds;
|
||||
|
||||
return max(0, $resetTime - $now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics for a specific key.
|
||||
*
|
||||
* @return int[]
|
||||
*
|
||||
* @psalm-return array{
|
||||
* current_requests: int<0, max>,
|
||||
* remaining_requests: int<0, max>,
|
||||
* time_until_reset: int<0, max>
|
||||
* }
|
||||
* @throws InvalidRateLimitConfigurationException When key is invalid
|
||||
*/
|
||||
public function getStats(string $key): array
|
||||
{
|
||||
$this->validateKey($key);
|
||||
$now = time();
|
||||
$windowStart = $now - $this->windowSeconds;
|
||||
|
||||
$currentRequests = 0;
|
||||
if (isset(self::$requests[$key])) {
|
||||
$currentRequests = count(array_filter(
|
||||
self::$requests[$key],
|
||||
fn(int $timestamp): bool => $timestamp > $windowStart
|
||||
));
|
||||
}
|
||||
|
||||
return [
|
||||
'current_requests' => $currentRequests,
|
||||
'remaining_requests' => max(0, $this->maxRequests - $currentRequests),
|
||||
'time_until_reset' => $this->getTimeUntilReset($key),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining requests for a specific key.
|
||||
*
|
||||
* @param string $key The rate limiting key
|
||||
* @return int The number of remaining requests
|
||||
*
|
||||
* @psalm-return int<0, max>
|
||||
* @throws InvalidRateLimitConfigurationException When key is invalid
|
||||
*/
|
||||
public function getRemainingRequests(string $key): int
|
||||
{
|
||||
$this->validateKey($key);
|
||||
return $this->getStats($key)['remaining_requests'] ?? 0;
|
||||
}
|
||||
|
||||
public static function clearAll(): void
|
||||
{
|
||||
self::$requests = [];
|
||||
}
|
||||
|
||||
public static function clearKey(string $key): void
|
||||
{
|
||||
self::validateKeyStatic($key);
|
||||
if (isset(self::$requests[$key])) {
|
||||
unset(self::$requests[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform global cleanup if enough time has passed.
|
||||
* This prevents memory leaks from accumulating unused keys.
|
||||
*/
|
||||
private function performGlobalCleanupIfNeeded(int $now): void
|
||||
{
|
||||
if ($now - self::$lastCleanup >= self::$cleanupInterval) {
|
||||
$this->performGlobalCleanup($now);
|
||||
self::$lastCleanup = $now;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all expired entries across all keys.
|
||||
* This prevents memory leaks from accumulating old unused keys.
|
||||
*/
|
||||
private function performGlobalCleanup(int $now): void
|
||||
{
|
||||
$windowStart = $now - $this->windowSeconds;
|
||||
|
||||
foreach (self::$requests as $key => $timestamps) {
|
||||
// Filter out old timestamps
|
||||
$validTimestamps = array_filter(
|
||||
$timestamps,
|
||||
fn(int $timestamp): bool => $timestamp > $windowStart
|
||||
);
|
||||
|
||||
if ($validTimestamps === []) {
|
||||
// Remove keys with no valid timestamps
|
||||
unset(self::$requests[$key]);
|
||||
} else {
|
||||
// Update with filtered timestamps
|
||||
self::$requests[$key] = array_values($validTimestamps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get memory usage statistics for debugging.
|
||||
*
|
||||
* @return int[]
|
||||
*
|
||||
* @psalm-return array{
|
||||
* total_keys: int<0, max>,
|
||||
* total_timestamps: int,
|
||||
* estimated_memory_bytes: int<min, max>,
|
||||
* last_cleanup: int,
|
||||
* cleanup_interval: int
|
||||
* }
|
||||
*/
|
||||
public static function getMemoryStats(): array
|
||||
{
|
||||
$totalKeys = count(self::$requests);
|
||||
$totalTimestamps = array_sum(array_map(count(...), self::$requests));
|
||||
$estimatedMemory = $totalKeys * 50 + $totalTimestamps * 8; // Rough estimate
|
||||
|
||||
return [
|
||||
'total_keys' => $totalKeys,
|
||||
'total_timestamps' => $totalTimestamps,
|
||||
'estimated_memory_bytes' => $estimatedMemory,
|
||||
'last_cleanup' => self::$lastCleanup,
|
||||
'cleanup_interval' => self::$cleanupInterval,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the global cleanup interval.
|
||||
*
|
||||
* @param int $seconds Cleanup interval in seconds (minimum 60)
|
||||
* @throws InvalidRateLimitConfigurationException When seconds is invalid
|
||||
*/
|
||||
public static function setCleanupInterval(int $seconds): void
|
||||
{
|
||||
if ($seconds <= 0) {
|
||||
throw InvalidRateLimitConfigurationException::invalidCleanupInterval($seconds);
|
||||
}
|
||||
|
||||
if ($seconds < 60) {
|
||||
throw InvalidRateLimitConfigurationException::cleanupIntervalTooShort($seconds, 60);
|
||||
}
|
||||
|
||||
if ($seconds > 604800) { // 1 week max
|
||||
throw InvalidRateLimitConfigurationException::forParameter(
|
||||
'cleanup_interval',
|
||||
$seconds,
|
||||
'Cannot exceed 604,800 seconds (1 week) for practical reasons'
|
||||
);
|
||||
}
|
||||
|
||||
self::$cleanupInterval = $seconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a rate limiting key.
|
||||
*
|
||||
* @param string $key The key to validate
|
||||
* @throws InvalidRateLimitConfigurationException When key is invalid
|
||||
*/
|
||||
private function validateKey(string $key): void
|
||||
{
|
||||
self::validateKeyStatic($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Static version of key validation for use in static methods.
|
||||
*
|
||||
* @param string $key The key to validate
|
||||
* @throws InvalidRateLimitConfigurationException When key is invalid
|
||||
*/
|
||||
private static function validateKeyStatic(string $key): void
|
||||
{
|
||||
if (trim($key) === '') {
|
||||
throw InvalidRateLimitConfigurationException::emptyKey();
|
||||
}
|
||||
|
||||
if (strlen($key) > 250) {
|
||||
throw InvalidRateLimitConfigurationException::keyTooLong($key, 250);
|
||||
}
|
||||
|
||||
// Check for potential problematic characters that could cause issues
|
||||
if (preg_match('/[\x00-\x1F\x7F]/', $key)) {
|
||||
throw InvalidRateLimitConfigurationException::invalidKeyFormat(
|
||||
'Rate limiting key cannot contain control characters'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/Recovery/FailureMode.php
Normal file
57
src/Recovery/FailureMode.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Recovery;
|
||||
|
||||
/**
|
||||
* Defines how the processor should behave when masking operations fail.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
enum FailureMode: string
|
||||
{
|
||||
/**
|
||||
* Fail open: On failure, return the original value unmasked.
|
||||
*
|
||||
* Use this when availability is more important than privacy,
|
||||
* but be aware this may expose sensitive data in error scenarios.
|
||||
*/
|
||||
case FAIL_OPEN = 'fail_open';
|
||||
|
||||
/**
|
||||
* Fail closed: On failure, return a completely masked/redacted value.
|
||||
*
|
||||
* Use this when privacy is critical and you'd rather lose data
|
||||
* than risk exposing sensitive information.
|
||||
*/
|
||||
case FAIL_CLOSED = 'fail_closed';
|
||||
|
||||
/**
|
||||
* Fail safe: On failure, apply a conservative fallback mask.
|
||||
*
|
||||
* This is the recommended default. It attempts to provide useful
|
||||
* information while still protecting potentially sensitive data.
|
||||
*/
|
||||
case FAIL_SAFE = 'fail_safe';
|
||||
|
||||
/**
|
||||
* Get a human-readable description of this failure mode.
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::FAIL_OPEN => 'Return original value on failure (risky)',
|
||||
self::FAIL_CLOSED => 'Return fully redacted value on failure (strict)',
|
||||
self::FAIL_SAFE => 'Apply conservative fallback mask on failure (balanced)',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the recommended failure mode for production environments.
|
||||
*/
|
||||
public static function recommended(): self
|
||||
{
|
||||
return self::FAIL_SAFE;
|
||||
}
|
||||
}
|
||||
178
src/Recovery/FallbackMaskStrategy.php
Normal file
178
src/Recovery/FallbackMaskStrategy.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Recovery;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants;
|
||||
|
||||
/**
|
||||
* Provides fallback mask values for different data types and scenarios.
|
||||
*
|
||||
* Used by recovery strategies to determine appropriate masked values
|
||||
* when masking operations fail.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class FallbackMaskStrategy
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $customFallbacks Custom fallback values by type
|
||||
* @param string $defaultFallback Default fallback for unknown types
|
||||
* @param bool $preserveType Whether to try preserving the original type
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $customFallbacks = [],
|
||||
private readonly string $defaultFallback = MaskConstants::MASK_MASKED,
|
||||
private readonly bool $preserveType = true,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a strategy with default fallback values.
|
||||
*/
|
||||
public static function default(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a strict strategy that always uses the same mask.
|
||||
*/
|
||||
public static function strict(string $mask = MaskConstants::MASK_REDACTED): self
|
||||
{
|
||||
return new self(
|
||||
defaultFallback: $mask,
|
||||
preserveType: false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a strategy with custom type mappings.
|
||||
*
|
||||
* @param array<string, string> $typeMappings Type name => fallback value
|
||||
*/
|
||||
public static function withMappings(array $typeMappings): self
|
||||
{
|
||||
return new self(customFallbacks: $typeMappings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate fallback value for a given original value.
|
||||
*
|
||||
* @param mixed $originalValue The original value that couldn't be masked
|
||||
* @param FailureMode $mode The failure mode to apply
|
||||
*/
|
||||
public function getFallback(
|
||||
mixed $originalValue,
|
||||
FailureMode $mode = FailureMode::FAIL_SAFE
|
||||
): mixed {
|
||||
return match ($mode) {
|
||||
FailureMode::FAIL_OPEN => $originalValue,
|
||||
FailureMode::FAIL_CLOSED => $this->getClosedFallback(),
|
||||
FailureMode::FAIL_SAFE => $this->getSafeFallback($originalValue),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fallback for FAIL_CLOSED mode.
|
||||
*/
|
||||
private function getClosedFallback(): string
|
||||
{
|
||||
return $this->customFallbacks['closed'] ?? MaskConstants::MASK_REDACTED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fallback for FAIL_SAFE mode (type-aware).
|
||||
*/
|
||||
private function getSafeFallback(mixed $originalValue): mixed
|
||||
{
|
||||
$type = gettype($originalValue);
|
||||
|
||||
// Check for custom fallback first
|
||||
if (isset($this->customFallbacks[$type])) {
|
||||
return $this->customFallbacks[$type];
|
||||
}
|
||||
|
||||
// If not preserving type, return default
|
||||
if (!$this->preserveType) {
|
||||
return $this->defaultFallback;
|
||||
}
|
||||
|
||||
// Return type-appropriate fallback
|
||||
return match ($type) {
|
||||
'string' => $this->getStringFallback($originalValue),
|
||||
'integer' => MaskConstants::MASK_INT,
|
||||
'double' => MaskConstants::MASK_FLOAT,
|
||||
'boolean' => MaskConstants::MASK_BOOL,
|
||||
'array' => $this->getArrayFallback($originalValue),
|
||||
'object' => $this->getObjectFallback($originalValue),
|
||||
'NULL' => MaskConstants::MASK_NULL,
|
||||
'resource', 'resource (closed)' => MaskConstants::MASK_RESOURCE,
|
||||
default => $this->defaultFallback,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fallback for string values.
|
||||
*
|
||||
* @param string $originalValue
|
||||
*/
|
||||
private function getStringFallback(string $originalValue): string
|
||||
{
|
||||
// Try to preserve length indication
|
||||
$length = strlen($originalValue);
|
||||
|
||||
if ($length <= 10) {
|
||||
return MaskConstants::MASK_STRING;
|
||||
}
|
||||
|
||||
return sprintf('%s (%d chars)', MaskConstants::MASK_STRING, $length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fallback for array values.
|
||||
*
|
||||
* @param array<mixed> $originalValue
|
||||
*/
|
||||
private function getArrayFallback(array $originalValue): string
|
||||
{
|
||||
$count = count($originalValue);
|
||||
|
||||
if ($count === 0) {
|
||||
return MaskConstants::MASK_ARRAY;
|
||||
}
|
||||
|
||||
return sprintf('%s (%d items)', MaskConstants::MASK_ARRAY, $count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fallback for object values.
|
||||
*/
|
||||
private function getObjectFallback(object $originalValue): string
|
||||
{
|
||||
$class = $originalValue::class;
|
||||
|
||||
// Extract just the class name without namespace
|
||||
$lastBackslash = strrpos($class, '\\');
|
||||
$shortClass = $lastBackslash !== false
|
||||
? substr($class, $lastBackslash + 1)
|
||||
: $class;
|
||||
|
||||
return sprintf('%s (%s)', MaskConstants::MASK_OBJECT, $shortClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a description of this strategy's configuration.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'custom_fallbacks' => $this->customFallbacks,
|
||||
'default_fallback' => $this->defaultFallback,
|
||||
'preserve_type' => $this->preserveType,
|
||||
];
|
||||
}
|
||||
}
|
||||
202
src/Recovery/RecoveryResult.php
Normal file
202
src/Recovery/RecoveryResult.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Recovery;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Audit\AuditContext;
|
||||
use Ivuorinen\MonologGdprFilter\Audit\ErrorContext;
|
||||
|
||||
/**
|
||||
* Result of a recovery operation.
|
||||
*
|
||||
* Encapsulates the outcome of attempting an operation with recovery,
|
||||
* including whether it succeeded, failed, or used a fallback.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final readonly class RecoveryResult
|
||||
{
|
||||
public const OUTCOME_SUCCESS = 'success';
|
||||
public const OUTCOME_RECOVERED = 'recovered';
|
||||
public const OUTCOME_FALLBACK = 'fallback';
|
||||
public const OUTCOME_FAILED = 'failed';
|
||||
|
||||
/**
|
||||
* @param mixed $value The resulting value (masked or fallback)
|
||||
* @param string $outcome The outcome type
|
||||
* @param int $attempts Number of attempts made
|
||||
* @param float $totalDurationMs Total time spent including retries
|
||||
* @param ErrorContext|null $lastError The last error if any occurred
|
||||
*/
|
||||
public function __construct(
|
||||
public mixed $value,
|
||||
public string $outcome,
|
||||
public int $attempts = 1,
|
||||
public float $totalDurationMs = 0.0,
|
||||
public ?ErrorContext $lastError = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a success result (first attempt succeeded).
|
||||
*
|
||||
* @param mixed $value The masked value
|
||||
* @param float $durationMs Operation duration
|
||||
*/
|
||||
public static function success(mixed $value, float $durationMs = 0.0): self
|
||||
{
|
||||
return new self(
|
||||
value: $value,
|
||||
outcome: self::OUTCOME_SUCCESS,
|
||||
attempts: 1,
|
||||
totalDurationMs: $durationMs,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a recovered result (succeeded after retry).
|
||||
*
|
||||
* @param mixed $value The masked value
|
||||
* @param int $attempts Number of attempts needed
|
||||
* @param float $totalDurationMs Total duration including retries
|
||||
*/
|
||||
public static function recovered(
|
||||
mixed $value,
|
||||
int $attempts,
|
||||
float $totalDurationMs = 0.0
|
||||
): self {
|
||||
return new self(
|
||||
value: $value,
|
||||
outcome: self::OUTCOME_RECOVERED,
|
||||
attempts: $attempts,
|
||||
totalDurationMs: $totalDurationMs,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fallback result (used fallback value after failures).
|
||||
*
|
||||
* @param mixed $fallbackValue The fallback value used
|
||||
* @param int $attempts Number of attempts made before fallback
|
||||
* @param ErrorContext $lastError The error that triggered fallback
|
||||
* @param float $totalDurationMs Total duration including retries
|
||||
*/
|
||||
public static function fallback(
|
||||
mixed $fallbackValue,
|
||||
int $attempts,
|
||||
ErrorContext $lastError,
|
||||
float $totalDurationMs = 0.0
|
||||
): self {
|
||||
return new self(
|
||||
value: $fallbackValue,
|
||||
outcome: self::OUTCOME_FALLBACK,
|
||||
attempts: $attempts,
|
||||
totalDurationMs: $totalDurationMs,
|
||||
lastError: $lastError,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a failed result (all recovery attempts exhausted).
|
||||
*
|
||||
* @param mixed $originalValue The original value (returned as-is)
|
||||
* @param int $attempts Number of attempts made
|
||||
* @param ErrorContext $error The final error
|
||||
* @param float $totalDurationMs Total duration including retries
|
||||
*/
|
||||
public static function failed(
|
||||
mixed $originalValue,
|
||||
int $attempts,
|
||||
ErrorContext $error,
|
||||
float $totalDurationMs = 0.0
|
||||
): self {
|
||||
return new self(
|
||||
value: $originalValue,
|
||||
outcome: self::OUTCOME_FAILED,
|
||||
attempts: $attempts,
|
||||
totalDurationMs: $totalDurationMs,
|
||||
lastError: $error,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the operation was successful (including recovery).
|
||||
*/
|
||||
public function isSuccess(): bool
|
||||
{
|
||||
return $this->outcome === self::OUTCOME_SUCCESS
|
||||
|| $this->outcome === self::OUTCOME_RECOVERED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a fallback was used.
|
||||
*/
|
||||
public function usedFallback(): bool
|
||||
{
|
||||
return $this->outcome === self::OUTCOME_FALLBACK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the operation completely failed.
|
||||
*/
|
||||
public function isFailed(): bool
|
||||
{
|
||||
return $this->outcome === self::OUTCOME_FAILED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if retry was needed.
|
||||
*/
|
||||
public function neededRetry(): bool
|
||||
{
|
||||
return $this->attempts > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an AuditContext from this result.
|
||||
*
|
||||
* @param string $operationType The type of operation performed
|
||||
*/
|
||||
public function toAuditContext(string $operationType): AuditContext
|
||||
{
|
||||
return match ($this->outcome) {
|
||||
self::OUTCOME_SUCCESS => AuditContext::success(
|
||||
$operationType,
|
||||
$this->totalDurationMs
|
||||
),
|
||||
self::OUTCOME_RECOVERED => AuditContext::recovered(
|
||||
$operationType,
|
||||
$this->attempts,
|
||||
$this->totalDurationMs
|
||||
),
|
||||
default => AuditContext::failed(
|
||||
$operationType,
|
||||
$this->lastError ?? ErrorContext::create('unknown', 'Unknown error'),
|
||||
$this->attempts,
|
||||
$this->totalDurationMs,
|
||||
['outcome' => $this->outcome]
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for logging/debugging.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$data = [
|
||||
'outcome' => $this->outcome,
|
||||
'attempts' => $this->attempts,
|
||||
'duration_ms' => round($this->totalDurationMs, 3),
|
||||
];
|
||||
|
||||
if ($this->lastError instanceof ErrorContext) {
|
||||
$data['error'] = $this->lastError->toArray();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user