mirror of
https://github.com/ivuorinen/monolog-gdpr-filter.git
synced 2026-01-26 03:34:00 +00:00
feat: add advanced architecture, documentation, and coverage improvements (#65)
* fix(style): resolve PHPCS line-length warnings in source files * fix(style): resolve PHPCS line-length warnings in test files * feat(audit): add structured audit logging with ErrorContext and AuditContext - ErrorContext: standardized error information with sensitive data sanitization - AuditContext: structured context for audit entries with operation types - StructuredAuditLogger: enhanced audit logger wrapper with timing support * feat(recovery): add recovery mechanism for failed masking operations - FailureMode enum: FAIL_OPEN, FAIL_CLOSED, FAIL_SAFE modes - RecoveryStrategy interface and RecoveryResult value object - RetryStrategy: exponential backoff with configurable attempts - FallbackMaskStrategy: type-aware fallback values * feat(strategies): add CallbackMaskingStrategy for custom masking logic - Wraps custom callbacks as MaskingStrategy implementations - Factory methods: constant(), hash(), partial() for common use cases - Supports exact match and prefix match for field paths * docs: add framework integration guides and examples - symfony-integration.md: Symfony service configuration and Monolog setup - psr3-decorator.md: PSR-3 logger decorator pattern implementation - framework-examples.md: CakePHP, CodeIgniter 4, Laminas, Yii2, PSR-15 - docker-development.md: Docker development environment guide * chore(docker): add Docker development environment - Dockerfile: PHP 8.2-cli-alpine with Xdebug for coverage - docker-compose.yml: development services with volume mounts * feat(demo): add interactive GDPR pattern tester playground - PatternTester.php: pattern testing utility with strategy support - index.php: web API endpoint with JSON response handling - playground.html: interactive web interface for testing patterns * docs(todo): update with completed medium priority items - Mark all PHPCS warnings as fixed (81 → 0) - Document new Audit and Recovery features - Update test count to 1,068 tests with 2,953 assertions - Move remaining items to low priority * feat: add advanced architecture, documentation, and coverage improvements - Add architecture improvements: - ArrayAccessorInterface and DotArrayAccessor for decoupled array access - MaskingOrchestrator for single-responsibility masking coordination - GdprProcessorBuilder for fluent configuration - MaskingPluginInterface and AbstractMaskingPlugin for plugin architecture - PluginAwareProcessor for plugin hook execution - AuditLoggerFactory for instance-based audit logger creation - Add advanced features: - SerializedDataProcessor for handling print_r/var_export/serialize output - KAnonymizer with GeneralizationStrategy for GDPR k-anonymity - RetentionPolicy for configurable data retention periods - StreamingProcessor for memory-efficient large log processing - Add comprehensive documentation: - docs/performance-tuning.md - benchmarking, optimization, caching - docs/troubleshooting.md - common issues and solutions - docs/logging-integrations.md - ELK, Graylog, Datadog, etc. - docs/plugin-development.md - complete plugin development guide - Improve test coverage (84.41% → 85.07%): - ConditionalRuleFactoryInstanceTest (100% coverage) - GdprProcessorBuilderEdgeCasesTest (100% coverage) - StrategyEdgeCasesTest for ReDoS detection and type parsing - 78 new tests, 119 new assertions - Update TODO.md with current statistics: - 141 PHP files, 1,346 tests, 85.07% line coverage * chore: tests, update actions, sonarcloud issues * chore: rector * fix: more sonarcloud fixes * chore: more fixes * refactor: copilot review fix * chore: rector
This commit is contained in:
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -32,15 +32,15 @@ jobs:
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8
|
||||
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8
|
||||
uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8
|
||||
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
5
.github/workflows/pr-lint.yml
vendored
5
.github/workflows/pr-lint.yml
vendored
@@ -12,7 +12,8 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: read-all
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
Linter:
|
||||
@@ -30,4 +31,4 @@ jobs:
|
||||
steps:
|
||||
- name: Run PR Lint
|
||||
# https://github.com/ivuorinen/actions
|
||||
uses: ivuorinen/actions/pr-lint@44a11e9773be8ae72c469d2461478413156de797 # v2025.12.07
|
||||
uses: ivuorinen/actions/pr-lint@db86bb2f0df059edc619ecca735320ed571f51e0 # v2025.12.21
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -23,4 +23,4 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: ivuorinen/actions/stale@44a11e9773be8ae72c469d2461478413156de797 # v2025.12.07
|
||||
- uses: ivuorinen/actions/stale@db86bb2f0df059edc619ecca735320ed571f51e0 # v2025.12.21
|
||||
|
||||
5
.github/workflows/sync-labels.yml
vendored
5
.github/workflows/sync-labels.yml
vendored
@@ -20,7 +20,8 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: read-all
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
labels:
|
||||
@@ -38,4 +39,4 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: ⤵️ Sync Latest Labels Definitions
|
||||
uses: ivuorinen/actions/sync-labels@44a11e9773be8ae72c469d2461478413156de797 # v2025.12.07
|
||||
uses: ivuorinen/actions/sync-labels@db86bb2f0df059edc619ecca735320ed571f51e0 # v2025.12.21
|
||||
|
||||
@@ -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/
|
||||
168
TODO.md
168
TODO.md
@@ -2,110 +2,120 @@
|
||||
|
||||
This file tracks remaining issues, improvements, and feature requests for the monolog-gdpr-filter library.
|
||||
|
||||
## 📊 Current Status - PRODUCTION READY ✅
|
||||
## Current Status - PRODUCTION READY
|
||||
|
||||
**Project Statistics:**
|
||||
- **32 PHP files** (9 source files, 18 test files, 5 Laravel integration files)
|
||||
- **329 tests** with **100% success rate** (1,416 assertions)
|
||||
**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.2+** with modern language features and strict type safety
|
||||
- **Zero Critical Issues**: All functionality-blocking bugs resolved
|
||||
- **Static Analysis**: All tools configured and working harmoniously
|
||||
- **Static Analysis**: All tools pass cleanly (Psalm, PHPStan, Rector, PHPCS)
|
||||
|
||||
## 🔧 Pending Items
|
||||
## Static Analysis Status
|
||||
|
||||
### Medium Priority - Developer Experience
|
||||
All static analysis tools now pass:
|
||||
|
||||
- [ ] **Add recovery mechanism** for failed masking operations
|
||||
- [ ] **Improve error context** in audit logging with detailed context
|
||||
- [ ] **Create interactive demo/playground** for pattern testing
|
||||
- **Psalm Level 5**: 0 errors
|
||||
- **PHPStan Level 6**: 0 errors
|
||||
- **Rector**: No changes needed
|
||||
- **PHPCS**: 0 errors, 0 warnings
|
||||
|
||||
### Medium Priority - Code Quality & Linting Improvements
|
||||
## Completed Items (2025-12-01)
|
||||
|
||||
- [ ] **Apply Rector Safe Changes** (15 files identified):
|
||||
- Add missing return types to arrow functions and closures
|
||||
- Add explicit string casting for safety (`preg_replace`, `str_contains`)
|
||||
- Simplify regex patterns (`[0-9]` → `\d` optimizations)
|
||||
- **Impact**: Improved type safety, better code readability
|
||||
### Developer Experience
|
||||
|
||||
- [ ] **Address PHPCS Coding Standards** (1 error, 69 warnings):
|
||||
- Fix the 1 error in `tests/Strategies/MaskingStrategiesTest.php`
|
||||
- Add missing PHPDoc documentation blocks
|
||||
- Fix line length and spacing formatting issues
|
||||
- Ensure full PSR-12 compliance
|
||||
- **Impact**: Better code documentation, consistent formatting
|
||||
- [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
|
||||
|
||||
- [ ] **Consider PHPStan Suggestions** (~200 items, Level 6):
|
||||
- Add missing type annotations where beneficial
|
||||
- Make array access patterns more explicit
|
||||
- Review PHPUnit attribute usage patterns
|
||||
- **Impact**: Enhanced type safety, reduced ambiguity
|
||||
### Code Quality
|
||||
|
||||
- [ ] **Review Psalm Test Patterns** (51 errors, acceptable but reviewable):
|
||||
- Consider improving test array access patterns
|
||||
- Review intentional validation failure patterns for clarity
|
||||
- **Impact**: Cleaner test code, better maintainability
|
||||
- [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
|
||||
|
||||
### Medium Priority - Framework Integration
|
||||
### Framework Integration
|
||||
|
||||
- [ ] **Create Symfony integration guide** with step-by-step setup
|
||||
- [ ] **Add PSR-3 logger decorator pattern example**
|
||||
- [ ] **Create Docker development environment** with PHP 8.2+
|
||||
- [ ] **Add examples for other popular frameworks** (CakePHP, CodeIgniter)
|
||||
- [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
|
||||
|
||||
### Medium Priority - Architecture Improvements
|
||||
### Architecture
|
||||
|
||||
- [ ] **Address Strategies Pattern Issues**:
|
||||
- Only 20% of strategy classes covered by tests
|
||||
- Many strategy methods have low coverage (36-62%)
|
||||
- Strategy pattern appears incomplete/unused in main processor
|
||||
- **Impact**: Dead code, untested functionality, reliability issues
|
||||
- [x] **Extended Strategy Pattern support**:
|
||||
- `src/Strategies/CallbackMaskingStrategy.php` - Wraps custom callbacks as strategies
|
||||
- Factory methods: `constant()`, `hash()`, `partial()` for common use cases
|
||||
|
||||
## 🟢 Future Enhancements (Low Priority)
|
||||
### Advanced Features (Completed 2025-12-01)
|
||||
|
||||
### Advanced Data Processing Features
|
||||
- [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
|
||||
|
||||
- [ ] Support masking arrays/objects in message strings
|
||||
- [ ] Add data anonymization (not just masking) with k-anonymity
|
||||
- [ ] Add retention policy support with automatic cleanup
|
||||
- [ ] Add data portability features (export masked logs)
|
||||
- [ ] Implement streaming processing for very large logs
|
||||
### Architecture Improvements (Completed 2025-12-01)
|
||||
|
||||
### Advanced Architecture Improvements
|
||||
- [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
|
||||
|
||||
- [ ] Refactor to follow Single Responsibility Principle more strictly
|
||||
- [ ] Reduce coupling with `Adbar\Dot` library (create abstraction)
|
||||
- [ ] Add dependency injection container support
|
||||
- [ ] Replace remaining static methods for better testability
|
||||
- [ ] Implement plugin architecture for custom processors
|
||||
### Documentation (Completed 2025-12-01)
|
||||
|
||||
### Documentation & Examples
|
||||
- [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)
|
||||
|
||||
- [ ] Add comprehensive usage examples for all masking types
|
||||
- [ ] Create performance tuning guide
|
||||
- [ ] Add troubleshooting guide with common issues
|
||||
- [ ] Create video tutorials for complex scenarios
|
||||
- [ ] Add integration examples with popular logging solutions
|
||||
## Development Notes
|
||||
|
||||
## 📊 Static Analysis Tool Status
|
||||
|
||||
**Current Findings (All Acceptable):**
|
||||
- **Psalm Level 5**: 51 errors (mostly test-related patterns)
|
||||
- **PHPStan Level 6**: ~200 suggestions (code quality improvements)
|
||||
- **Rector**: 15 files with safe changes identified
|
||||
- **PHPCS**: 1 error, 69 warnings (coding standards)
|
||||
|
||||
All static analysis tools are properly configured and working harmoniously. Issues are primarily code quality improvements rather than bugs.
|
||||
|
||||
## 📝 Development Notes
|
||||
|
||||
- **All critical and high-priority functionality is complete**
|
||||
- **Project is production-ready** with comprehensive test coverage
|
||||
- **Remaining items focus on code quality and developer experience**
|
||||
- **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-01-04
|
||||
**Production Status**: ✅ Ready
|
||||
**Next Focus**: Code quality improvements and developer experience enhancements
|
||||
**Last Updated**: 2025-12-01
|
||||
**Production Status**: Ready
|
||||
**All Items**: Complete
|
||||
|
||||
@@ -10,12 +10,14 @@
|
||||
"@lint:tool:ec",
|
||||
"@lint:tool:psalm",
|
||||
"@lint:tool:phpstan",
|
||||
"@lint:tool:phpcs"
|
||||
"@lint:tool:phpcs",
|
||||
"@lint:tool:md"
|
||||
],
|
||||
"lint:fix": [
|
||||
"@lint:tool:rector",
|
||||
"@lint:tool:psalm:fix",
|
||||
"@lint:tool:phpcbf",
|
||||
"@lint:tool:md:fix",
|
||||
"@lint:tool:ec:fix"
|
||||
],
|
||||
"test": "XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-text",
|
||||
@@ -24,11 +26,13 @@
|
||||
"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",
|
||||
"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=MissingReturnType,MissingParamType,MissingClosureReturnType",
|
||||
"lint:tool:rector": "./vendor/bin/rector"
|
||||
"lint:tool:rector": "./vendor/bin/rector",
|
||||
"lint:tool:md:fix": "markdownlint -f '**/*.md'",
|
||||
"lint:tool:md": "markdownlint '**/*.md'"
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
|
||||
355
composer.lock
generated
355
composer.lock
generated
@@ -527,16 +527,16 @@
|
||||
},
|
||||
{
|
||||
"name": "amphp/parallel",
|
||||
"version": "v2.3.2",
|
||||
"version": "v2.3.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/amphp/parallel.git",
|
||||
"reference": "321b45ae771d9c33a068186b24117e3cd1c48dce"
|
||||
"reference": "296b521137a54d3a02425b464e5aee4c93db2c60"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/amphp/parallel/zipball/321b45ae771d9c33a068186b24117e3cd1c48dce",
|
||||
"reference": "321b45ae771d9c33a068186b24117e3cd1c48dce",
|
||||
"url": "https://api.github.com/repos/amphp/parallel/zipball/296b521137a54d3a02425b464e5aee4c93db2c60",
|
||||
"reference": "296b521137a54d3a02425b464e5aee4c93db2c60",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -599,7 +599,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/amphp/parallel/issues",
|
||||
"source": "https://github.com/amphp/parallel/tree/v2.3.2"
|
||||
"source": "https://github.com/amphp/parallel/tree/v2.3.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -607,7 +607,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-08-27T21:55:40+00:00"
|
||||
"time": "2025-11-15T06:23:42+00:00"
|
||||
},
|
||||
{
|
||||
"name": "amphp/parser",
|
||||
@@ -2226,31 +2226,31 @@
|
||||
},
|
||||
{
|
||||
"name": "fruitcake/php-cors",
|
||||
"version": "v1.3.0",
|
||||
"version": "v1.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/fruitcake/php-cors.git",
|
||||
"reference": "3d158f36e7875e2f040f37bc0573956240a5a38b"
|
||||
"reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/fruitcake/php-cors/zipball/3d158f36e7875e2f040f37bc0573956240a5a38b",
|
||||
"reference": "3d158f36e7875e2f040f37bc0573956240a5a38b",
|
||||
"url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379",
|
||||
"reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4|^8.0",
|
||||
"symfony/http-foundation": "^4.4|^5.4|^6|^7"
|
||||
"php": "^8.1",
|
||||
"symfony/http-foundation": "^5.4|^6.4|^7.3|^8"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^1.4",
|
||||
"phpstan/phpstan": "^2",
|
||||
"phpunit/phpunit": "^9",
|
||||
"squizlabs/php_codesniffer": "^3.5"
|
||||
"squizlabs/php_codesniffer": "^4"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.2-dev"
|
||||
"dev-master": "1.3-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
@@ -2281,7 +2281,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/fruitcake/php-cors/issues",
|
||||
"source": "https://github.com/fruitcake/php-cors/tree/v1.3.0"
|
||||
"source": "https://github.com/fruitcake/php-cors/tree/v1.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -2293,7 +2293,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2023-10-12T05:21:21+00:00"
|
||||
"time": "2025-12-03T09:33:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "guuzen/psalm-enum-plugin",
|
||||
@@ -2806,16 +2806,16 @@
|
||||
},
|
||||
{
|
||||
"name": "illuminate/bus",
|
||||
"version": "v12.40.2",
|
||||
"version": "v12.43.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/bus.git",
|
||||
"reference": "7845b735651ffb734b8b064e7d0349490adf4564"
|
||||
"reference": "5db3f0f0b3b5a8fefee4598bfce99910f719d68d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/illuminate/bus/zipball/7845b735651ffb734b8b064e7d0349490adf4564",
|
||||
"reference": "7845b735651ffb734b8b064e7d0349490adf4564",
|
||||
"url": "https://api.github.com/repos/illuminate/bus/zipball/5db3f0f0b3b5a8fefee4598bfce99910f719d68d",
|
||||
"reference": "5db3f0f0b3b5a8fefee4598bfce99910f719d68d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2855,20 +2855,20 @@
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2025-11-04T15:31:54+00:00"
|
||||
"time": "2025-12-10T15:25:42+00:00"
|
||||
},
|
||||
{
|
||||
"name": "illuminate/collections",
|
||||
"version": "v12.40.2",
|
||||
"version": "v12.43.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/collections.git",
|
||||
"reference": "3a794986bad4caf369d17ae19d4ef20a38dd8b0c"
|
||||
"reference": "16657effa6a5a4e728f9aeb3e38fb4fa9ba70e7d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/illuminate/collections/zipball/3a794986bad4caf369d17ae19d4ef20a38dd8b0c",
|
||||
"reference": "3a794986bad4caf369d17ae19d4ef20a38dd8b0c",
|
||||
"url": "https://api.github.com/repos/illuminate/collections/zipball/16657effa6a5a4e728f9aeb3e38fb4fa9ba70e7d",
|
||||
"reference": "16657effa6a5a4e728f9aeb3e38fb4fa9ba70e7d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2914,11 +2914,11 @@
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2025-11-24T14:13:52+00:00"
|
||||
"time": "2025-12-06T18:08:25+00:00"
|
||||
},
|
||||
{
|
||||
"name": "illuminate/conditionable",
|
||||
"version": "v12.40.2",
|
||||
"version": "v12.43.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/conditionable.git",
|
||||
@@ -2964,16 +2964,16 @@
|
||||
},
|
||||
{
|
||||
"name": "illuminate/console",
|
||||
"version": "v12.40.2",
|
||||
"version": "v12.43.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/console.git",
|
||||
"reference": "8cf759395964d5a6323887345d590e46f3aa4fbe"
|
||||
"reference": "f7746a0b2e47d886238ac6527a39b254e5bd2f9c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/illuminate/console/zipball/8cf759395964d5a6323887345d590e46f3aa4fbe",
|
||||
"reference": "8cf759395964d5a6323887345d590e46f3aa4fbe",
|
||||
"url": "https://api.github.com/repos/illuminate/console/zipball/f7746a0b2e47d886238ac6527a39b254e5bd2f9c",
|
||||
"reference": "f7746a0b2e47d886238ac6527a39b254e5bd2f9c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3026,24 +3026,25 @@
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2025-11-26T14:30:36+00:00"
|
||||
"time": "2025-11-27T22:28:23+00:00"
|
||||
},
|
||||
{
|
||||
"name": "illuminate/container",
|
||||
"version": "v12.40.2",
|
||||
"version": "v12.43.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/container.git",
|
||||
"reference": "17ec6c2f741b11564420acc737dea9334d69988c"
|
||||
"reference": "326667a4c813e3ad5a645969a7e3f5c10d159de2"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/illuminate/container/zipball/17ec6c2f741b11564420acc737dea9334d69988c",
|
||||
"reference": "17ec6c2f741b11564420acc737dea9334d69988c",
|
||||
"url": "https://api.github.com/repos/illuminate/container/zipball/326667a4c813e3ad5a645969a7e3f5c10d159de2",
|
||||
"reference": "326667a4c813e3ad5a645969a7e3f5c10d159de2",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/contracts": "^12.0",
|
||||
"illuminate/reflection": "^12.0",
|
||||
"php": "^8.2",
|
||||
"psr/container": "^1.1.1|^2.0.1",
|
||||
"symfony/polyfill-php84": "^1.33",
|
||||
@@ -3087,20 +3088,20 @@
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2025-11-14T15:29:05+00:00"
|
||||
"time": "2025-12-08T22:34:25+00:00"
|
||||
},
|
||||
{
|
||||
"name": "illuminate/contracts",
|
||||
"version": "v12.40.2",
|
||||
"version": "v12.43.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/contracts.git",
|
||||
"reference": "b97a94df448f196f23d646e21999bfd5d86ae23b"
|
||||
"reference": "19e8938edb73047017cfbd443b96844b86da4a59"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/illuminate/contracts/zipball/b97a94df448f196f23d646e21999bfd5d86ae23b",
|
||||
"reference": "b97a94df448f196f23d646e21999bfd5d86ae23b",
|
||||
"url": "https://api.github.com/repos/illuminate/contracts/zipball/19e8938edb73047017cfbd443b96844b86da4a59",
|
||||
"reference": "19e8938edb73047017cfbd443b96844b86da4a59",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3135,20 +3136,20 @@
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2025-11-26T16:51:20+00:00"
|
||||
"time": "2025-11-26T21:36:01+00:00"
|
||||
},
|
||||
{
|
||||
"name": "illuminate/events",
|
||||
"version": "v12.40.2",
|
||||
"version": "v12.43.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/events.git",
|
||||
"reference": "e0de667c68040d59a6ffc09e914536a1186870f0"
|
||||
"reference": "5fbf9a127cb649699071c2fe98ac1d39e4991da3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/illuminate/events/zipball/e0de667c68040d59a6ffc09e914536a1186870f0",
|
||||
"reference": "e0de667c68040d59a6ffc09e914536a1186870f0",
|
||||
"url": "https://api.github.com/repos/illuminate/events/zipball/5fbf9a127cb649699071c2fe98ac1d39e4991da3",
|
||||
"reference": "5fbf9a127cb649699071c2fe98ac1d39e4991da3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3190,20 +3191,20 @@
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2025-10-21T15:10:34+00:00"
|
||||
"time": "2025-12-01T15:02:02+00:00"
|
||||
},
|
||||
{
|
||||
"name": "illuminate/filesystem",
|
||||
"version": "v12.40.2",
|
||||
"version": "v12.43.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/filesystem.git",
|
||||
"reference": "b1fbb20010e868f838feac86aeac8ba439fca10d"
|
||||
"reference": "29e1b5f572306c91742514adea3aff93e85f3d3b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/illuminate/filesystem/zipball/b1fbb20010e868f838feac86aeac8ba439fca10d",
|
||||
"reference": "b1fbb20010e868f838feac86aeac8ba439fca10d",
|
||||
"url": "https://api.github.com/repos/illuminate/filesystem/zipball/29e1b5f572306c91742514adea3aff93e85f3d3b",
|
||||
"reference": "29e1b5f572306c91742514adea3aff93e85f3d3b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3257,20 +3258,20 @@
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2025-10-29T15:59:33+00:00"
|
||||
"time": "2025-12-10T20:11:11+00:00"
|
||||
},
|
||||
{
|
||||
"name": "illuminate/http",
|
||||
"version": "v12.40.2",
|
||||
"version": "v12.43.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/http.git",
|
||||
"reference": "b88643505c605f31f53a2bb3fdc8c0398b185d68"
|
||||
"reference": "6aee9663acb6f3471364e518363ff0ed0ad59e90"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/illuminate/http/zipball/b88643505c605f31f53a2bb3fdc8c0398b185d68",
|
||||
"reference": "b88643505c605f31f53a2bb3fdc8c0398b185d68",
|
||||
"url": "https://api.github.com/repos/illuminate/http/zipball/6aee9663acb6f3471364e518363ff0ed0ad59e90",
|
||||
"reference": "6aee9663acb6f3471364e518363ff0ed0ad59e90",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3319,11 +3320,11 @@
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2025-11-24T21:57:52+00:00"
|
||||
"time": "2025-12-14T15:45:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "illuminate/macroable",
|
||||
"version": "v12.40.2",
|
||||
"version": "v12.43.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/macroable.git",
|
||||
@@ -3369,7 +3370,7 @@
|
||||
},
|
||||
{
|
||||
"name": "illuminate/pipeline",
|
||||
"version": "v12.40.2",
|
||||
"version": "v12.43.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/pipeline.git",
|
||||
@@ -3419,9 +3420,60 @@
|
||||
},
|
||||
"time": "2025-08-20T13:36:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "illuminate/reflection",
|
||||
"version": "v12.43.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/reflection.git",
|
||||
"reference": "7b86bc570d5b75e4a3ad79f8cca1491ba24b7c75"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/illuminate/reflection/zipball/7b86bc570d5b75e4a3ad79f8cca1491ba24b7c75",
|
||||
"reference": "7b86bc570d5b75e4a3ad79f8cca1491ba24b7c75",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/collections": "^12.0",
|
||||
"illuminate/contracts": "^12.0",
|
||||
"php": "^8.2"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "12.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"helpers.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Illuminate\\Support\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
}
|
||||
],
|
||||
"description": "The Illuminate Reflection package.",
|
||||
"homepage": "https://laravel.com",
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2025-12-09T15:11:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "illuminate/session",
|
||||
"version": "v12.40.2",
|
||||
"version": "v12.43.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/session.git",
|
||||
@@ -3478,16 +3530,16 @@
|
||||
},
|
||||
{
|
||||
"name": "illuminate/support",
|
||||
"version": "v12.40.2",
|
||||
"version": "v12.43.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/support.git",
|
||||
"reference": "20a64e34d9ee8bb7b28b242155e9c31f86e5804b"
|
||||
"reference": "20014564c32e2e8c6a57e03d065bcf8706a458e7"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/illuminate/support/zipball/20a64e34d9ee8bb7b28b242155e9c31f86e5804b",
|
||||
"reference": "20a64e34d9ee8bb7b28b242155e9c31f86e5804b",
|
||||
"url": "https://api.github.com/repos/illuminate/support/zipball/20014564c32e2e8c6a57e03d065bcf8706a458e7",
|
||||
"reference": "20014564c32e2e8c6a57e03d065bcf8706a458e7",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3499,6 +3551,7 @@
|
||||
"illuminate/conditionable": "^12.0",
|
||||
"illuminate/contracts": "^12.0",
|
||||
"illuminate/macroable": "^12.0",
|
||||
"illuminate/reflection": "^12.0",
|
||||
"nesbot/carbon": "^3.8.4",
|
||||
"php": "^8.2",
|
||||
"symfony/polyfill-php83": "^1.33",
|
||||
@@ -3553,20 +3606,20 @@
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2025-11-26T14:47:26+00:00"
|
||||
"time": "2025-12-14T15:58:12+00:00"
|
||||
},
|
||||
{
|
||||
"name": "illuminate/view",
|
||||
"version": "v12.40.2",
|
||||
"version": "v12.43.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/view.git",
|
||||
"reference": "f065c5fc1ad29aaf5734c5f99f69fc4cde9a255f"
|
||||
"reference": "4d6a34f009d9478f14d39b7788a18c7f0e6b4320"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/illuminate/view/zipball/f065c5fc1ad29aaf5734c5f99f69fc4cde9a255f",
|
||||
"reference": "f065c5fc1ad29aaf5734c5f99f69fc4cde9a255f",
|
||||
"url": "https://api.github.com/repos/illuminate/view/zipball/4d6a34f009d9478f14d39b7788a18c7f0e6b4320",
|
||||
"reference": "4d6a34f009d9478f14d39b7788a18c7f0e6b4320",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3607,25 +3660,25 @@
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2025-11-16T14:42:54+00:00"
|
||||
"time": "2025-12-15T14:59:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "justinrainbow/json-schema",
|
||||
"version": "6.6.2",
|
||||
"version": "6.6.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/jsonrainbow/json-schema.git",
|
||||
"reference": "3c25fe750c1599716ef26aa997f7c026cee8c4b7"
|
||||
"reference": "134e98916fa2f663afa623970af345cd788e8967"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/3c25fe750c1599716ef26aa997f7c026cee8c4b7",
|
||||
"reference": "3c25fe750c1599716ef26aa997f7c026cee8c4b7",
|
||||
"url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/134e98916fa2f663afa623970af345cd788e8967",
|
||||
"reference": "134e98916fa2f663afa623970af345cd788e8967",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"marc-mabe/php-enum": "^4.0",
|
||||
"marc-mabe/php-enum": "^4.4",
|
||||
"php": "^7.2 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
@@ -3680,9 +3733,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/jsonrainbow/json-schema/issues",
|
||||
"source": "https://github.com/jsonrainbow/json-schema/tree/6.6.2"
|
||||
"source": "https://github.com/jsonrainbow/json-schema/tree/6.6.3"
|
||||
},
|
||||
"time": "2025-11-28T15:24:03+00:00"
|
||||
"time": "2025-12-02T10:21:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "kelunik/certificate",
|
||||
@@ -3803,20 +3856,20 @@
|
||||
},
|
||||
{
|
||||
"name": "league/uri",
|
||||
"version": "7.6.0",
|
||||
"version": "7.7.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/uri.git",
|
||||
"reference": "f625804987a0a9112d954f9209d91fec52182344"
|
||||
"reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/uri/zipball/f625804987a0a9112d954f9209d91fec52182344",
|
||||
"reference": "f625804987a0a9112d954f9209d91fec52182344",
|
||||
"url": "https://api.github.com/repos/thephpleague/uri/zipball/8d587cddee53490f9b82bf203d3a9aa7ea4f9807",
|
||||
"reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"league/uri-interfaces": "^7.6",
|
||||
"league/uri-interfaces": "^7.7",
|
||||
"php": "^8.1",
|
||||
"psr/http-factory": "^1"
|
||||
},
|
||||
@@ -3889,7 +3942,7 @@
|
||||
"docs": "https://uri.thephpleague.com",
|
||||
"forum": "https://thephpleague.slack.com",
|
||||
"issues": "https://github.com/thephpleague/uri-src/issues",
|
||||
"source": "https://github.com/thephpleague/uri/tree/7.6.0"
|
||||
"source": "https://github.com/thephpleague/uri/tree/7.7.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -3897,20 +3950,20 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-18T12:17:23+00:00"
|
||||
"time": "2025-12-07T16:02:06+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/uri-interfaces",
|
||||
"version": "7.6.0",
|
||||
"version": "7.7.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/uri-interfaces.git",
|
||||
"reference": "ccbfb51c0445298e7e0b7f4481b942f589665368"
|
||||
"reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/ccbfb51c0445298e7e0b7f4481b942f589665368",
|
||||
"reference": "ccbfb51c0445298e7e0b7f4481b942f589665368",
|
||||
"url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/62ccc1a0435e1c54e10ee6022df28d6c04c2946c",
|
||||
"reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3973,7 +4026,7 @@
|
||||
"docs": "https://uri.thephpleague.com",
|
||||
"forum": "https://thephpleague.slack.com",
|
||||
"issues": "https://github.com/thephpleague/uri-src/issues",
|
||||
"source": "https://github.com/thephpleague/uri-interfaces/tree/7.6.0"
|
||||
"source": "https://github.com/thephpleague/uri-interfaces/tree/7.7.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -3981,7 +4034,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-18T12:17:23+00:00"
|
||||
"time": "2025-12-07T16:03:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "localheinz/diff",
|
||||
@@ -4173,16 +4226,16 @@
|
||||
},
|
||||
{
|
||||
"name": "nesbot/carbon",
|
||||
"version": "3.10.3",
|
||||
"version": "3.11.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/CarbonPHP/carbon.git",
|
||||
"reference": "8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f"
|
||||
"reference": "bdb375400dcd162624531666db4799b36b64e4a1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f",
|
||||
"reference": "8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f",
|
||||
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/bdb375400dcd162624531666db4799b36b64e4a1",
|
||||
"reference": "bdb375400dcd162624531666db4799b36b64e4a1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4190,9 +4243,9 @@
|
||||
"ext-json": "*",
|
||||
"php": "^8.1",
|
||||
"psr/clock": "^1.0",
|
||||
"symfony/clock": "^6.3.12 || ^7.0",
|
||||
"symfony/clock": "^6.3.12 || ^7.0 || ^8.0",
|
||||
"symfony/polyfill-mbstring": "^1.0",
|
||||
"symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0"
|
||||
"symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0"
|
||||
},
|
||||
"provide": {
|
||||
"psr/clock-implementation": "1.0"
|
||||
@@ -4274,7 +4327,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-09-06T13:39:36+00:00"
|
||||
"time": "2025-12-02T21:04:28+00:00"
|
||||
},
|
||||
{
|
||||
"name": "netresearch/jsonmapper",
|
||||
@@ -4329,16 +4382,16 @@
|
||||
},
|
||||
{
|
||||
"name": "nikic/php-parser",
|
||||
"version": "v5.6.2",
|
||||
"version": "v5.7.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nikic/PHP-Parser.git",
|
||||
"reference": "3a454ca033b9e06b63282ce19562e892747449bb"
|
||||
"reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb",
|
||||
"reference": "3a454ca033b9e06b63282ce19562e892747449bb",
|
||||
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
|
||||
"reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4381,9 +4434,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/nikic/PHP-Parser/issues",
|
||||
"source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2"
|
||||
"source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
|
||||
},
|
||||
"time": "2025-10-21T19:32:17+00:00"
|
||||
"time": "2025-12-06T11:56:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nunomaduro/termwind",
|
||||
@@ -4869,11 +4922,11 @@
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan",
|
||||
"version": "2.1.32",
|
||||
"version": "2.1.33",
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/e126cad1e30a99b137b8ed75a85a676450ebb227",
|
||||
"reference": "e126cad1e30a99b137b8ed75a85a676450ebb227",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/9e800e6bee7d5bd02784d4c6069b48032d16224f",
|
||||
"reference": "9e800e6bee7d5bd02784d4c6069b48032d16224f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4918,7 +4971,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-11T15:18:17+00:00"
|
||||
"time": "2025-12-05T10:24:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpunit/php-code-coverage",
|
||||
@@ -5257,16 +5310,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpunit/phpunit",
|
||||
"version": "11.5.45",
|
||||
"version": "11.5.46",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||
"reference": "faf5fff4fb9beb290affa53f812b05380819c51a"
|
||||
"reference": "75dfe79a2aa30085b7132bb84377c24062193f33"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/faf5fff4fb9beb290affa53f812b05380819c51a",
|
||||
"reference": "faf5fff4fb9beb290affa53f812b05380819c51a",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/75dfe79a2aa30085b7132bb84377c24062193f33",
|
||||
"reference": "75dfe79a2aa30085b7132bb84377c24062193f33",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -5338,7 +5391,7 @@
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.45"
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.46"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -5362,7 +5415,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-01T07:38:43+00:00"
|
||||
"time": "2025-12-06T08:01:15+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psalm/plugin-phpunit",
|
||||
@@ -5830,21 +5883,21 @@
|
||||
},
|
||||
{
|
||||
"name": "rector/rector",
|
||||
"version": "2.2.9",
|
||||
"version": "2.2.14",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/rectorphp/rector.git",
|
||||
"reference": "0b8e49ec234877b83244d2ecd0df7a4c16471f05"
|
||||
"reference": "6d56bb0e94d4df4f57a78610550ac76ab403657d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/rectorphp/rector/zipball/0b8e49ec234877b83244d2ecd0df7a4c16471f05",
|
||||
"reference": "0b8e49ec234877b83244d2ecd0df7a4c16471f05",
|
||||
"url": "https://api.github.com/repos/rectorphp/rector/zipball/6d56bb0e94d4df4f57a78610550ac76ab403657d",
|
||||
"reference": "6d56bb0e94d4df4f57a78610550ac76ab403657d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4|^8.0",
|
||||
"phpstan/phpstan": "^2.1.32"
|
||||
"phpstan/phpstan": "^2.1.33"
|
||||
},
|
||||
"conflict": {
|
||||
"rector/rector-doctrine": "*",
|
||||
@@ -5878,7 +5931,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/rectorphp/rector/issues",
|
||||
"source": "https://github.com/rectorphp/rector/tree/2.2.9"
|
||||
"source": "https://github.com/rectorphp/rector/tree/2.2.14"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -5886,7 +5939,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-28T14:21:22+00:00"
|
||||
"time": "2025-12-09T10:57:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "revolt/event-loop",
|
||||
@@ -6948,16 +7001,16 @@
|
||||
},
|
||||
{
|
||||
"name": "spatie/array-to-xml",
|
||||
"version": "3.4.3",
|
||||
"version": "3.4.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/array-to-xml.git",
|
||||
"reference": "7b9202dccfe18d4e3a13303156d6bbcc1c61dabf"
|
||||
"reference": "88b2f3852a922dd73177a68938f8eb2ec70c7224"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/array-to-xml/zipball/7b9202dccfe18d4e3a13303156d6bbcc1c61dabf",
|
||||
"reference": "7b9202dccfe18d4e3a13303156d6bbcc1c61dabf",
|
||||
"url": "https://api.github.com/repos/spatie/array-to-xml/zipball/88b2f3852a922dd73177a68938f8eb2ec70c7224",
|
||||
"reference": "88b2f3852a922dd73177a68938f8eb2ec70c7224",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -7000,7 +7053,7 @@
|
||||
"xml"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/spatie/array-to-xml/tree/3.4.3"
|
||||
"source": "https://github.com/spatie/array-to-xml/tree/3.4.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -7012,7 +7065,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-27T09:08:26+00:00"
|
||||
"time": "2025-12-15T09:00:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "squizlabs/php_codesniffer",
|
||||
@@ -7225,16 +7278,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/console",
|
||||
"version": "v7.4.0",
|
||||
"version": "v7.4.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/console.git",
|
||||
"reference": "0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8"
|
||||
"reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/console/zipball/0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8",
|
||||
"reference": "0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8",
|
||||
"url": "https://api.github.com/repos/symfony/console/zipball/6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e",
|
||||
"reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -7299,7 +7352,7 @@
|
||||
"terminal"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/console/tree/v7.4.0"
|
||||
"source": "https://github.com/symfony/console/tree/v7.4.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -7319,7 +7372,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-27T13:27:24+00:00"
|
||||
"time": "2025-12-05T15:23:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/deprecation-contracts",
|
||||
@@ -7771,16 +7824,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-foundation",
|
||||
"version": "v7.4.0",
|
||||
"version": "v7.4.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-foundation.git",
|
||||
"reference": "769c1720b68e964b13b58529c17d4a385c62167b"
|
||||
"reference": "bd1af1e425811d6f077db240c3a588bdb405cd27"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/769c1720b68e964b13b58529c17d4a385c62167b",
|
||||
"reference": "769c1720b68e964b13b58529c17d4a385c62167b",
|
||||
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/bd1af1e425811d6f077db240c3a588bdb405cd27",
|
||||
"reference": "bd1af1e425811d6f077db240c3a588bdb405cd27",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -7829,7 +7882,7 @@
|
||||
"description": "Defines an object-oriented layer for the HTTP specification",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-foundation/tree/v7.4.0"
|
||||
"source": "https://github.com/symfony/http-foundation/tree/v7.4.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -7849,20 +7902,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-13T08:49:24+00:00"
|
||||
"time": "2025-12-07T11:13:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-kernel",
|
||||
"version": "v7.4.0",
|
||||
"version": "v7.4.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-kernel.git",
|
||||
"reference": "7348193cd384495a755554382e4526f27c456085"
|
||||
"reference": "f6e6f0a5fa8763f75a504b930163785fb6dd055f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/7348193cd384495a755554382e4526f27c456085",
|
||||
"reference": "7348193cd384495a755554382e4526f27c456085",
|
||||
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/f6e6f0a5fa8763f75a504b930163785fb6dd055f",
|
||||
"reference": "f6e6f0a5fa8763f75a504b930163785fb6dd055f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -7948,7 +8001,7 @@
|
||||
"description": "Provides a structured process for converting a Request into a Response",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-kernel/tree/v7.4.0"
|
||||
"source": "https://github.com/symfony/http-kernel/tree/v7.4.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -7968,7 +8021,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-27T13:38:24+00:00"
|
||||
"time": "2025-12-08T07:43:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/mime",
|
||||
@@ -9369,16 +9422,16 @@
|
||||
},
|
||||
{
|
||||
"name": "vimeo/psalm",
|
||||
"version": "6.13.1",
|
||||
"version": "6.14.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/vimeo/psalm.git",
|
||||
"reference": "1e3b7f0a8ab32b23197b91107adc0a7ed8a05b51"
|
||||
"reference": "bbd217fc98c0daa0a13aea2a7f119d03ba3fc9a0"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/vimeo/psalm/zipball/1e3b7f0a8ab32b23197b91107adc0a7ed8a05b51",
|
||||
"reference": "1e3b7f0a8ab32b23197b91107adc0a7ed8a05b51",
|
||||
"url": "https://api.github.com/repos/vimeo/psalm/zipball/bbd217fc98c0daa0a13aea2a7f119d03ba3fc9a0",
|
||||
"reference": "bbd217fc98c0daa0a13aea2a7f119d03ba3fc9a0",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -9401,7 +9454,7 @@
|
||||
"fidry/cpu-core-counter": "^0.4.1 || ^0.5.1 || ^1.0.0",
|
||||
"netresearch/jsonmapper": "^5.0",
|
||||
"nikic/php-parser": "^5.0.0",
|
||||
"php": "~8.1.31 || ~8.2.27 || ~8.3.16 || ~8.4.3",
|
||||
"php": "~8.1.31 || ~8.2.27 || ~8.3.16 || ~8.4.3 || ~8.5.0",
|
||||
"sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0",
|
||||
"spatie/array-to-xml": "^2.17.0 || ^3.0",
|
||||
"symfony/console": "^6.0 || ^7.0",
|
||||
@@ -9483,7 +9536,7 @@
|
||||
"issues": "https://github.com/vimeo/psalm/issues",
|
||||
"source": "https://github.com/vimeo/psalm"
|
||||
},
|
||||
"time": "2025-08-06T10:10:28+00:00"
|
||||
"time": "2025-12-11T08:58:52+00:00"
|
||||
},
|
||||
{
|
||||
"name": "voku/portable-ascii",
|
||||
|
||||
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>
|
||||
37
docker/Dockerfile
Normal file
37
docker/Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
||||
FROM php:8.2-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 and configure Xdebug for code coverage
|
||||
RUN apk add --no-cache $PHPIZE_DEPS \
|
||||
&& pecl install xdebug \
|
||||
&& docker-php-ext-enable xdebug \
|
||||
&& 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 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"]
|
||||
30
docker/docker-compose.yml
Normal file
30
docker/docker-compose.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
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
|
||||
|
||||
# PHP 8.3 for testing compatibility
|
||||
php83:
|
||||
image: php:8.3-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.2-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.3 for testing compatibility
|
||||
php83:
|
||||
image: php:8.3-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.2', '8.3']
|
||||
|
||||
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.2 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
|
||||
@@ -61,6 +61,7 @@ echo "Expected: 5 regular logs + rate limit warnings\n\n";
|
||||
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'
|
||||
@@ -80,6 +81,7 @@ 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'
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,18 +4,27 @@ declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter;
|
||||
|
||||
use Adbar\Dot;
|
||||
use Ivuorinen\MonologGdprFilter\ArrayAccessor\ArrayAccessorFactory;
|
||||
use Closure;
|
||||
use Monolog\LogRecord;
|
||||
|
||||
/**
|
||||
* Factory for creating conditional masking rules.
|
||||
*
|
||||
* This class provides static methods to create various types of
|
||||
* 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.
|
||||
*
|
||||
@@ -37,8 +46,9 @@ final class ConditionalRuleFactory
|
||||
*/
|
||||
public static function createContextFieldRule(string $fieldPath): Closure
|
||||
{
|
||||
return function (LogRecord $record) use ($fieldPath): bool {
|
||||
$accessor = new Dot($record->context);
|
||||
$factory = ArrayAccessorFactory::default();
|
||||
return function (LogRecord $record) use ($fieldPath, $factory): bool {
|
||||
$accessor = $factory->create($record->context);
|
||||
return $accessor->has($fieldPath);
|
||||
};
|
||||
}
|
||||
@@ -53,8 +63,9 @@ final class ConditionalRuleFactory
|
||||
*/
|
||||
public static function createContextValueRule(string $fieldPath, mixed $expectedValue): Closure
|
||||
{
|
||||
return function (LogRecord $record) use ($fieldPath, $expectedValue): bool {
|
||||
$accessor = new Dot($record->context);
|
||||
$factory = ArrayAccessorFactory::default();
|
||||
return function (LogRecord $record) use ($fieldPath, $expectedValue, $factory): bool {
|
||||
$accessor = $factory->create($record->context);
|
||||
return $accessor->get($fieldPath) === $expectedValue;
|
||||
};
|
||||
}
|
||||
@@ -70,4 +81,37 @@ final class ConditionalRuleFactory
|
||||
{
|
||||
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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter;
|
||||
|
||||
use Adbar\Dot;
|
||||
use Ivuorinen\MonologGdprFilter\Contracts\ArrayAccessorInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
@@ -34,11 +34,11 @@ class ContextProcessor
|
||||
/**
|
||||
* Mask field paths in the context using the configured field masks.
|
||||
*
|
||||
* @param Dot<array-key, mixed> $accessor
|
||||
* @param ArrayAccessorInterface $accessor
|
||||
* @return string[] Array of processed field paths
|
||||
* @psalm-return list<string>
|
||||
*/
|
||||
public function maskFieldPaths(Dot $accessor): array
|
||||
public function maskFieldPaths(ArrayAccessorInterface $accessor): array
|
||||
{
|
||||
$processedFields = [];
|
||||
foreach ($this->fieldPaths as $path => $config) {
|
||||
@@ -70,11 +70,11 @@ class ContextProcessor
|
||||
/**
|
||||
* Process custom callbacks on context fields.
|
||||
*
|
||||
* @param Dot<array-key, mixed> $accessor
|
||||
* @param ArrayAccessorInterface $accessor
|
||||
* @return string[] Array of processed field paths
|
||||
* @psalm-return list<string>
|
||||
*/
|
||||
public function processCustomCallbacks(Dot $accessor): array
|
||||
public function processCustomCallbacks(ArrayAccessorInterface $accessor): array
|
||||
{
|
||||
$processedFields = [];
|
||||
foreach ($this->customCallbacks as $path => $callback) {
|
||||
|
||||
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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter;
|
||||
|
||||
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 Error;
|
||||
use Adbar\Dot;
|
||||
use Monolog\LogRecord;
|
||||
use Monolog\Processor\ProcessorInterface;
|
||||
|
||||
@@ -15,14 +15,18 @@ 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 DataTypeMasker $dataTypeMasker;
|
||||
private readonly JsonMasker $jsonMasker;
|
||||
private readonly ContextProcessor $contextProcessor;
|
||||
private readonly RecursiveProcessor $recursiveProcessor;
|
||||
private readonly MaskingOrchestrator $orchestrator;
|
||||
|
||||
/**
|
||||
* @var callable(string,mixed,mixed):void|null
|
||||
*/
|
||||
private $auditLogger;
|
||||
|
||||
/**
|
||||
* @param array<string,string> $patterns Regex pattern => replacement
|
||||
@@ -34,18 +38,22 @@ class GdprProcessor implements ProcessorInterface
|
||||
* @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 = []
|
||||
private readonly array $conditionalRules = [],
|
||||
?ArrayAccessorFactory $arrayAccessorFactory = null
|
||||
) {
|
||||
$this->auditLogger = $auditLogger;
|
||||
|
||||
// Validate all constructor parameters using InputValidator
|
||||
InputValidator::validateAll(
|
||||
$patterns,
|
||||
@@ -58,48 +66,34 @@ class GdprProcessor implements ProcessorInterface
|
||||
);
|
||||
|
||||
// Pre-validate and cache patterns for better performance
|
||||
/** @psalm-suppress DeprecatedMethod - Internal use of caching mechanism */
|
||||
PatternValidator::cachePatterns($patterns);
|
||||
|
||||
// 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(
|
||||
// Create orchestrator to handle actual masking work
|
||||
$this->orchestrator = new MaskingOrchestrator(
|
||||
$patterns,
|
||||
$fieldPaths,
|
||||
$customCallbacks,
|
||||
$auditLogger,
|
||||
$this->regExpMessage(...)
|
||||
$maxDepth,
|
||||
$dataTypeMasks,
|
||||
$arrayAccessorFactory
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 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'
|
||||
*
|
||||
* @deprecated Use AuditLoggerFactory::create()->createRateLimited() instead
|
||||
*/
|
||||
public static function createRateLimitedAuditLogger(
|
||||
callable $auditLogger,
|
||||
string $profile = 'default'
|
||||
): RateLimitedAuditLogger {
|
||||
return RateLimitedAuditLogger::create($auditLogger, $profile);
|
||||
return AuditLoggerFactory::create()->createRateLimited($auditLogger, $profile);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,30 +105,17 @@ class GdprProcessor implements ProcessorInterface
|
||||
* @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 - The closure always sets timestamp, but Psalm can't infer this through RateLimitedAuditLogger wrapper
|
||||
*
|
||||
* @deprecated Use AuditLoggerFactory::create()->createArrayLogger() instead
|
||||
*/
|
||||
public static function createArrayAuditLogger(
|
||||
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
|
||||
? self::createRateLimitedAuditLogger($baseLogger, 'testing')
|
||||
: $baseLogger;
|
||||
return AuditLoggerFactory::create()->createArrayLogger($logStorage, $rateLimited);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Process a log record to mask sensitive information.
|
||||
*
|
||||
@@ -149,36 +130,10 @@ class GdprProcessor implements ProcessorInterface
|
||||
return $record;
|
||||
}
|
||||
|
||||
$message = $this->regExpMessage($record->message);
|
||||
$context = $record->context;
|
||||
$accessor = new Dot($context);
|
||||
$processedFields = [];
|
||||
// Delegate to orchestrator
|
||||
$result = $this->orchestrator->process($record->message, $record->context);
|
||||
|
||||
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
|
||||
$context = $this->dataTypeMasker->applyToContext(
|
||||
$context,
|
||||
$processedFields,
|
||||
'',
|
||||
$this->recursiveProcessor->recursiveMask(...)
|
||||
);
|
||||
} else {
|
||||
$context = $this->recursiveProcessor->recursiveMask($context, 0);
|
||||
}
|
||||
|
||||
return $record->with(message: $message, context: $context);
|
||||
return $record->with(message: $result['message'], context: $result['context']);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,57 +182,7 @@ class GdprProcessor implements ProcessorInterface
|
||||
*/
|
||||
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;
|
||||
return $this->orchestrator->regExpMessage($message);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -290,7 +195,7 @@ class GdprProcessor implements ProcessorInterface
|
||||
*/
|
||||
public function recursiveMask(array|string $data, int $currentDepth = 0): array|string
|
||||
{
|
||||
return $this->recursiveProcessor->recursiveMask($data, $currentDepth);
|
||||
return $this->orchestrator->recursiveMask($data, $currentDepth);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,7 +219,7 @@ class GdprProcessor implements ProcessorInterface
|
||||
}
|
||||
|
||||
return $result;
|
||||
} catch (Error $error) {
|
||||
} catch (\Error $error) {
|
||||
if ($this->auditLogger !== null) {
|
||||
($this->auditLogger)('regex_batch_error', implode(', ', $keys), $error->getMessage());
|
||||
}
|
||||
@@ -331,10 +236,15 @@ class GdprProcessor implements ProcessorInterface
|
||||
public function setAuditLogger(?callable $auditLogger): void
|
||||
{
|
||||
$this->auditLogger = $auditLogger;
|
||||
$this->orchestrator->setAuditLogger($auditLogger);
|
||||
}
|
||||
|
||||
// Propagate to child processors
|
||||
$this->contextProcessor->setAuditLogger($auditLogger);
|
||||
$this->recursiveProcessor->setAuditLogger($auditLogger);
|
||||
/**
|
||||
* Get the underlying orchestrator for direct access.
|
||||
*/
|
||||
public function getOrchestrator(): MaskingOrchestrator
|
||||
{
|
||||
return $this->orchestrator;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -347,6 +257,7 @@ class GdprProcessor implements ProcessorInterface
|
||||
public static function validatePatternsArray(array $patterns): void
|
||||
{
|
||||
try {
|
||||
/** @psalm-suppress DeprecatedMethod - Wrapper for deprecated validation */
|
||||
PatternValidator::validateAll($patterns);
|
||||
} catch (InvalidRegexPatternException $e) {
|
||||
throw PatternValidationException::forMultiplePatterns(
|
||||
|
||||
@@ -81,6 +81,7 @@ final class InputValidator
|
||||
}
|
||||
|
||||
// Validate regex pattern syntax
|
||||
/** @psalm-suppress DeprecatedMethod - Internal validation use */
|
||||
if (!PatternValidator::isValid($pattern)) {
|
||||
throw InvalidRegexPatternException::forPattern(
|
||||
$pattern,
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -12,133 +12,186 @@ use Ivuorinen\MonologGdprFilter\Exceptions\InvalidRegexPatternException;
|
||||
*
|
||||
* This class provides pattern validation with ReDoS (Regular Expression Denial of Service)
|
||||
* protection and caching for improved performance.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class PatternValidator
|
||||
{
|
||||
/**
|
||||
* Static cache for compiled regex patterns to improve performance.
|
||||
* 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 = [];
|
||||
|
||||
/**
|
||||
* Clear the pattern validation cache (useful for testing).
|
||||
* Dangerous pattern checks.
|
||||
* @var list<non-empty-string>
|
||||
*/
|
||||
public static function clearCache(): void
|
||||
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()
|
||||
{
|
||||
self::$validPatternCache = [];
|
||||
// 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.
|
||||
* This helps prevent regex injection and ReDoS attacks.
|
||||
*/
|
||||
public static function isValid(string $pattern): bool
|
||||
public function validate(string $pattern): bool
|
||||
{
|
||||
// Check cache first
|
||||
if (isset(self::$validPatternCache[$pattern])) {
|
||||
return self::$validPatternCache[$pattern];
|
||||
// Check instance cache first
|
||||
if (isset($this->instanceCache[$pattern])) {
|
||||
return $this->instanceCache[$pattern];
|
||||
}
|
||||
|
||||
$isValid = true;
|
||||
$isValid = $this->performValidation($pattern);
|
||||
$this->instanceCache[$pattern] = $isValid;
|
||||
|
||||
// Check for basic regex structure
|
||||
if (strlen($pattern) < 3) {
|
||||
$isValid = false;
|
||||
}
|
||||
return $isValid;
|
||||
}
|
||||
|
||||
// Must start and end with delimiters
|
||||
if ($isValid) {
|
||||
$firstChar = $pattern[0];
|
||||
$lastDelimPos = strrpos($pattern, $firstChar);
|
||||
if ($lastDelimPos === false || $lastDelimPos === 0) {
|
||||
$isValid = false;
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced ReDoS protection - check for potentially dangerous patterns
|
||||
if ($isValid && self::hasDangerousPattern($pattern)) {
|
||||
$isValid = false;
|
||||
/**
|
||||
* 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
|
||||
if ($isValid) {
|
||||
set_error_handler(
|
||||
/**
|
||||
* @return true
|
||||
*/
|
||||
static fn(): bool => true
|
||||
);
|
||||
|
||||
try {
|
||||
/** @psalm-suppress ArgumentTypeCoercion */
|
||||
$result = preg_match($pattern, '');
|
||||
$isValid = $result !== false;
|
||||
} catch (Error) {
|
||||
$isValid = false;
|
||||
} finally {
|
||||
restore_error_handler();
|
||||
}
|
||||
}
|
||||
|
||||
self::$validPatternCache[$pattern] = $isValid;
|
||||
return $isValid;
|
||||
return $this->testPatternCompilation($pattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a pattern contains dangerous constructs that could cause ReDoS.
|
||||
*/
|
||||
private static function hasDangerousPattern(string $pattern): bool
|
||||
private function checkDangerousPattern(string $pattern): bool
|
||||
{
|
||||
$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,}\)\+/',
|
||||
];
|
||||
|
||||
foreach ($dangerousPatterns as $dangerousPattern) {
|
||||
foreach (self::$dangerousPatterns as $dangerousPattern) {
|
||||
if (preg_match($dangerousPattern, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
@@ -147,15 +200,74 @@ final class PatternValidator
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -167,10 +279,12 @@ final class PatternValidator
|
||||
*
|
||||
* @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,
|
||||
@@ -184,6 +298,7 @@ final class PatternValidator
|
||||
* Get the current pattern cache.
|
||||
*
|
||||
* @return array<string, bool>
|
||||
* @deprecated Use instance method getInstanceCache() instead
|
||||
*/
|
||||
public static function getCache(): array
|
||||
{
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,33 @@ class RateLimitedAuditLogger
|
||||
*
|
||||
* @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>}}
|
||||
* @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
|
||||
{
|
||||
|
||||
@@ -129,7 +129,11 @@ class RateLimiter
|
||||
*
|
||||
* @return int[]
|
||||
*
|
||||
* @psalm-return array{current_requests: int<0, max>, remaining_requests: int<0, max>, time_until_reset: int<0, max>}
|
||||
* @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
|
||||
@@ -223,7 +227,13 @@ class RateLimiter
|
||||
*
|
||||
* @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}
|
||||
* @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
|
||||
{
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
60
src/Recovery/RecoveryStrategy.php
Normal file
60
src/Recovery/RecoveryStrategy.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Recovery;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Interface for implementing recovery strategies when masking operations fail.
|
||||
*
|
||||
* Recovery strategies define how the processor should handle errors during
|
||||
* masking operations, including retry logic and fallback behavior.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
interface RecoveryStrategy
|
||||
{
|
||||
/**
|
||||
* Attempt to execute an operation with recovery logic.
|
||||
*
|
||||
* @param callable $operation The masking operation to execute
|
||||
* @param mixed $originalValue The original value being masked
|
||||
* @param string $path The field path
|
||||
* @param callable|null $auditLogger Optional audit logger for recovery events
|
||||
*
|
||||
* @return RecoveryResult The result of the operation (success or fallback)
|
||||
*/
|
||||
public function execute(
|
||||
callable $operation,
|
||||
mixed $originalValue,
|
||||
string $path,
|
||||
?callable $auditLogger = null
|
||||
): RecoveryResult;
|
||||
|
||||
/**
|
||||
* Determine if an error is recoverable (worth retrying).
|
||||
*
|
||||
* @param Throwable $error The error that occurred
|
||||
* @return bool True if the operation should be retried
|
||||
*/
|
||||
public function isRecoverable(Throwable $error): bool;
|
||||
|
||||
/**
|
||||
* Get the failure mode for this recovery strategy.
|
||||
*/
|
||||
public function getFailureMode(): FailureMode;
|
||||
|
||||
/**
|
||||
* Get the maximum number of retry attempts.
|
||||
*/
|
||||
public function getMaxAttempts(): int;
|
||||
|
||||
/**
|
||||
* Get configuration information about this strategy.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getConfiguration(): array;
|
||||
}
|
||||
307
src/Recovery/RetryStrategy.php
Normal file
307
src/Recovery/RetryStrategy.php
Normal file
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Recovery;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Audit\ErrorContext;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\MaskingOperationFailedException;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\RecursionDepthExceededException;
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Retry strategy with exponential backoff and fallback behavior.
|
||||
*
|
||||
* Attempts to retry failed masking operations with configurable
|
||||
* delays and maximum attempts, then falls back to a safe value.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class RetryStrategy implements RecoveryStrategy
|
||||
{
|
||||
private const DEFAULT_MAX_ATTEMPTS = 3;
|
||||
private const DEFAULT_BASE_DELAY_MS = 10;
|
||||
private const DEFAULT_MAX_DELAY_MS = 100;
|
||||
|
||||
/**
|
||||
* @param int $maxAttempts Maximum number of attempts (1 = no retry)
|
||||
* @param int $baseDelayMs Base delay in milliseconds for exponential backoff
|
||||
* @param int $maxDelayMs Maximum delay cap in milliseconds
|
||||
* @param FailureMode $failureMode How to handle final failure
|
||||
* @param string|null $fallbackMask Custom fallback mask value
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly int $maxAttempts = self::DEFAULT_MAX_ATTEMPTS,
|
||||
private readonly int $baseDelayMs = self::DEFAULT_BASE_DELAY_MS,
|
||||
private readonly int $maxDelayMs = self::DEFAULT_MAX_DELAY_MS,
|
||||
private readonly FailureMode $failureMode = FailureMode::FAIL_SAFE,
|
||||
private readonly ?string $fallbackMask = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a retry strategy with default settings.
|
||||
*/
|
||||
public static function default(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a strategy that doesn't retry (immediate fallback).
|
||||
*/
|
||||
public static function noRetry(FailureMode $failureMode = FailureMode::FAIL_SAFE): self
|
||||
{
|
||||
return new self(maxAttempts: 1, failureMode: $failureMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a strategy optimized for fast recovery.
|
||||
*/
|
||||
public static function fast(): self
|
||||
{
|
||||
return new self(
|
||||
maxAttempts: 2,
|
||||
baseDelayMs: 5,
|
||||
maxDelayMs: 20,
|
||||
failureMode: FailureMode::FAIL_SAFE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a strategy for thorough retry attempts.
|
||||
*/
|
||||
public static function thorough(): self
|
||||
{
|
||||
return new self(
|
||||
maxAttempts: 5,
|
||||
baseDelayMs: 20,
|
||||
maxDelayMs: 200,
|
||||
failureMode: FailureMode::FAIL_CLOSED
|
||||
);
|
||||
}
|
||||
|
||||
public function execute(
|
||||
callable $operation,
|
||||
mixed $originalValue,
|
||||
string $path,
|
||||
?callable $auditLogger = null
|
||||
): RecoveryResult {
|
||||
$startTime = microtime(true);
|
||||
$lastError = null;
|
||||
|
||||
for ($attempt = 1; $attempt <= $this->maxAttempts; $attempt++) {
|
||||
$attemptResult = $this->handleRetryAttempt($operation, $startTime, $attempt, $path, $auditLogger);
|
||||
|
||||
if ($attemptResult['success'] && $attemptResult['result'] instanceof RecoveryResult) {
|
||||
return $attemptResult['result'];
|
||||
}
|
||||
|
||||
$lastError = $attemptResult['errorContext'];
|
||||
$exception = $attemptResult['exception'];
|
||||
|
||||
if (!$this->shouldContinueRetrying($exception, $attempt)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->applyFallback($originalValue, $path, $startTime, $lastError, $auditLogger);
|
||||
}
|
||||
|
||||
public function isRecoverable(Throwable $error): bool
|
||||
{
|
||||
// These errors indicate permanent failures that won't recover with retry
|
||||
if ($error instanceof RecursionDepthExceededException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Some MaskingOperationFailedException errors are non-recoverable
|
||||
if ($error instanceof MaskingOperationFailedException) {
|
||||
$message = $error->getMessage();
|
||||
$isNonRecoverable = str_contains($message, 'Pattern compilation failed')
|
||||
|| str_contains($message, 'ReDoS');
|
||||
|
||||
return !$isNonRecoverable;
|
||||
}
|
||||
|
||||
// Transient errors like timeouts might recover
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getFailureMode(): FailureMode
|
||||
{
|
||||
return $this->failureMode;
|
||||
}
|
||||
|
||||
public function getMaxAttempts(): int
|
||||
{
|
||||
return $this->maxAttempts;
|
||||
}
|
||||
|
||||
public function getConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'max_attempts' => $this->maxAttempts,
|
||||
'base_delay_ms' => $this->baseDelayMs,
|
||||
'max_delay_ms' => $this->maxDelayMs,
|
||||
'failure_mode' => $this->failureMode->value,
|
||||
'fallback_mask' => $this->fallbackMask ?? '[auto]',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a single retry attempt.
|
||||
*
|
||||
* @return array{
|
||||
* success: bool,
|
||||
* result: RecoveryResult|null,
|
||||
* errorContext: ErrorContext|null,
|
||||
* exception: Throwable|null
|
||||
* }
|
||||
*/
|
||||
private function handleRetryAttempt(
|
||||
callable $operation,
|
||||
float $startTime,
|
||||
int $attempt,
|
||||
string $path,
|
||||
?callable $auditLogger
|
||||
): array {
|
||||
try {
|
||||
$result = $operation();
|
||||
$duration = (microtime(true) - $startTime) * 1000.0;
|
||||
|
||||
$recoveryResult = $attempt === 1
|
||||
? RecoveryResult::success($result, $duration)
|
||||
: RecoveryResult::recovered($result, $attempt, $duration);
|
||||
|
||||
return ['success' => true, 'result' => $recoveryResult, 'errorContext' => null, 'exception' => null];
|
||||
} catch (Throwable $e) {
|
||||
$errorContext = ErrorContext::fromThrowable($e);
|
||||
$this->logRetryAttempt($path, $attempt, $errorContext, $auditLogger);
|
||||
|
||||
return ['success' => false, 'result' => null, 'errorContext' => $errorContext, 'exception' => $e];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a retry attempt to the audit logger.
|
||||
*/
|
||||
private function logRetryAttempt(
|
||||
string $path,
|
||||
int $attempt,
|
||||
ErrorContext $errorContext,
|
||||
?callable $auditLogger
|
||||
): void {
|
||||
if ($auditLogger !== null && $attempt < $this->maxAttempts) {
|
||||
$auditLogger(
|
||||
'recovery_retry',
|
||||
['path' => $path, 'attempt' => $attempt],
|
||||
['error' => $errorContext->message, 'will_retry' => true]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if retry should be continued.
|
||||
*/
|
||||
private function shouldContinueRetrying(?Throwable $exception, int $attempt): bool
|
||||
{
|
||||
if (!$exception instanceof \Throwable || !$this->isRecoverable($exception)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($attempt >= $this->maxAttempts) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->delay($attempt);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply fallback value after all retry attempts failed.
|
||||
*/
|
||||
private function applyFallback(
|
||||
mixed $originalValue,
|
||||
string $path,
|
||||
float $startTime,
|
||||
?ErrorContext $lastError,
|
||||
?callable $auditLogger
|
||||
): RecoveryResult {
|
||||
$duration = (microtime(true) - $startTime) * 1000.0;
|
||||
$fallbackValue = $this->getFallbackValue($originalValue);
|
||||
|
||||
$errorContext = $lastError ?? ErrorContext::create('unknown', 'No error captured');
|
||||
|
||||
if ($auditLogger !== null) {
|
||||
$auditLogger(
|
||||
'recovery_fallback',
|
||||
['path' => $path, 'mode' => $this->failureMode->value],
|
||||
['error' => $errorContext->message, 'fallback_applied' => true]
|
||||
);
|
||||
}
|
||||
|
||||
return RecoveryResult::fallback(
|
||||
$fallbackValue,
|
||||
$this->maxAttempts,
|
||||
$errorContext,
|
||||
$duration
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fallback value based on failure mode.
|
||||
*/
|
||||
private function getFallbackValue(mixed $originalValue): mixed
|
||||
{
|
||||
if ($this->fallbackMask !== null) {
|
||||
return $this->fallbackMask;
|
||||
}
|
||||
|
||||
return match ($this->failureMode) {
|
||||
FailureMode::FAIL_OPEN => $originalValue,
|
||||
FailureMode::FAIL_CLOSED => MaskConstants::MASK_REDACTED,
|
||||
FailureMode::FAIL_SAFE => $this->getSafeFallback($originalValue),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a safe fallback value that preserves type information.
|
||||
*/
|
||||
private function getSafeFallback(mixed $originalValue): mixed
|
||||
{
|
||||
return match (gettype($originalValue)) {
|
||||
'string' => MaskConstants::MASK_STRING,
|
||||
'integer' => MaskConstants::MASK_INT,
|
||||
'double' => MaskConstants::MASK_FLOAT,
|
||||
'boolean' => MaskConstants::MASK_BOOL,
|
||||
'array' => MaskConstants::MASK_ARRAY,
|
||||
'object' => MaskConstants::MASK_OBJECT,
|
||||
'NULL' => MaskConstants::MASK_NULL,
|
||||
default => MaskConstants::MASK_MASKED,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply exponential backoff delay.
|
||||
*
|
||||
* @param int $attempt Current attempt number (1-based)
|
||||
*/
|
||||
private function delay(int $attempt): void
|
||||
{
|
||||
// Exponential backoff: baseDelay * 2^(attempt-1)
|
||||
$delay = $this->baseDelayMs * (2 ** ($attempt - 1));
|
||||
|
||||
// Apply jitter (random 0-25% of delay)
|
||||
$jitterMax = (int) floor((float) $delay * 0.25);
|
||||
$jitter = random_int(0, $jitterMax);
|
||||
$delay += $jitter;
|
||||
|
||||
// Cap at max delay
|
||||
$delay = min($delay, $this->maxDelayMs);
|
||||
|
||||
// Convert to microseconds and sleep
|
||||
usleep($delay * 1000);
|
||||
}
|
||||
}
|
||||
113
src/Retention/RetentionPolicy.php
Normal file
113
src/Retention/RetentionPolicy.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Retention;
|
||||
|
||||
/**
|
||||
* Data retention policy configuration for GDPR compliance.
|
||||
*
|
||||
* Defines how long different types of data should be retained
|
||||
* and what actions to take when the retention period expires.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class RetentionPolicy
|
||||
{
|
||||
public const ACTION_DELETE = 'delete';
|
||||
public const ACTION_ANONYMIZE = 'anonymize';
|
||||
public const ACTION_ARCHIVE = 'archive';
|
||||
|
||||
/**
|
||||
* @param string $name Policy name
|
||||
* @param int $retentionDays Number of days to retain data
|
||||
* @param string $action Action to take when retention expires
|
||||
* @param list<string> $fields Fields this policy applies to (empty = all fields)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $name,
|
||||
private readonly int $retentionDays,
|
||||
private readonly string $action = self::ACTION_DELETE,
|
||||
private readonly array $fields = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the policy name.
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the retention period in days.
|
||||
*/
|
||||
public function getRetentionDays(): int
|
||||
{
|
||||
return $this->retentionDays;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expiration action.
|
||||
*/
|
||||
public function getAction(): string
|
||||
{
|
||||
return $this->action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fields this policy applies to.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getFields(): array
|
||||
{
|
||||
return $this->fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a date is within the retention period.
|
||||
*/
|
||||
public function isWithinRetention(\DateTimeInterface $date): bool
|
||||
{
|
||||
$now = new \DateTimeImmutable();
|
||||
$cutoff = $now->modify("-{$this->retentionDays} days");
|
||||
|
||||
return $date >= $cutoff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the retention cutoff date.
|
||||
*/
|
||||
public function getCutoffDate(): \DateTimeImmutable
|
||||
{
|
||||
return (new \DateTimeImmutable())->modify("-{$this->retentionDays} days");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a GDPR standard 30-day retention policy.
|
||||
*/
|
||||
public static function gdpr30Days(string $name = 'gdpr_standard'): self
|
||||
{
|
||||
return new self($name, 30, self::ACTION_DELETE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a long-term archival policy (7 years).
|
||||
*/
|
||||
public static function archival(string $name = 'archival'): self
|
||||
{
|
||||
return new self($name, 2555, self::ACTION_ARCHIVE); // ~7 years
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an anonymization policy.
|
||||
*
|
||||
* @param list<string> $fields Fields to anonymize
|
||||
*/
|
||||
public static function anonymize(string $name, int $days, array $fields = []): self
|
||||
{
|
||||
return new self($name, $days, self::ACTION_ANONYMIZE, $fields);
|
||||
}
|
||||
}
|
||||
@@ -64,8 +64,10 @@ final class SecuritySanitizer
|
||||
'/\b[a-z_]*secret[a-z_]*[=:\s]+[\w\d_-]{10,}/i' => 'secret=***',
|
||||
'/\b[a-z_]*key[a-z_]*[=:\s]+[\w\d_-]{10,}/i' => 'key=***',
|
||||
|
||||
// IP addresses in internal ranges
|
||||
'/\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b/' => '***.***.***',
|
||||
// IP addresses in internal ranges (10.x.x.x, 172.16-31.x.x, 192.168.x.x)
|
||||
'/\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|'
|
||||
. '172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|'
|
||||
. '192\.168\.\d{1,3}\.\d{1,3})\b/' => '***.***.***',
|
||||
];
|
||||
|
||||
$sanitized = $message;
|
||||
|
||||
264
src/SerializedDataProcessor.php
Normal file
264
src/SerializedDataProcessor.php
Normal file
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter;
|
||||
|
||||
/**
|
||||
* Processes serialized data formats like print_r, var_export, and serialize output.
|
||||
*
|
||||
* Detects and masks sensitive data within:
|
||||
* - print_r() output: Array (key => value) format
|
||||
* - var_export() output: array('key' => 'value') format
|
||||
* - serialize() output: a:1:{s:3:"key";s:5:"value";}
|
||||
* - json_encode() output: {"key":"value"}
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class SerializedDataProcessor
|
||||
{
|
||||
/**
|
||||
* @var callable(string):string
|
||||
*/
|
||||
private $stringMasker;
|
||||
|
||||
/**
|
||||
* @var callable(string,mixed,mixed):void|null
|
||||
*/
|
||||
private $auditLogger;
|
||||
|
||||
/**
|
||||
* @param callable(string):string $stringMasker Function to mask strings
|
||||
* @param callable(string,mixed,mixed):void|null $auditLogger Optional audit logger
|
||||
*/
|
||||
public function __construct(
|
||||
callable $stringMasker,
|
||||
?callable $auditLogger = null
|
||||
) {
|
||||
$this->stringMasker = $stringMasker;
|
||||
$this->auditLogger = $auditLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a message that may contain serialized data.
|
||||
*
|
||||
* Automatically detects the format and applies masking.
|
||||
*
|
||||
* @param string $message The message to process
|
||||
* @return string The processed message with masked data
|
||||
*/
|
||||
public function process(string $message): string
|
||||
{
|
||||
if ($message === '') {
|
||||
return $message;
|
||||
}
|
||||
|
||||
// Try to detect and process JSON embedded in the message
|
||||
$message = $this->processEmbeddedJson($message);
|
||||
|
||||
// Try to detect and process print_r output
|
||||
$message = $this->processPrintROutput($message);
|
||||
|
||||
// Try to detect and process var_export output
|
||||
$message = $this->processVarExportOutput($message);
|
||||
|
||||
// Try to detect and process serialize output
|
||||
$message = $this->processSerializeOutput($message);
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process embedded JSON strings in the message.
|
||||
*/
|
||||
private function processEmbeddedJson(string $message): string
|
||||
{
|
||||
// Match JSON objects and arrays
|
||||
$pattern = '/(\{(?:[^{}]|(?1))*\}|\[(?:[^\[\]]|(?1))*\])/';
|
||||
|
||||
return (string) preg_replace_callback($pattern, function (array $matches): string {
|
||||
$json = $matches[0];
|
||||
|
||||
// Try to decode
|
||||
$decoded = json_decode($json, true);
|
||||
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
|
||||
// Not valid JSON, return as-is
|
||||
return $json;
|
||||
}
|
||||
|
||||
// Process the decoded data
|
||||
$masked = $this->maskRecursive($decoded, 'json');
|
||||
|
||||
// Re-encode
|
||||
$result = json_encode($masked, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
return $result === false ? $json : $result;
|
||||
}, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process print_r output format.
|
||||
*
|
||||
* Example format:
|
||||
* Array
|
||||
* (
|
||||
* [key] => value
|
||||
* [nested] => Array
|
||||
* (
|
||||
* [inner] => data
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
private function processPrintROutput(string $message): string
|
||||
{
|
||||
// Check if message contains print_r style output
|
||||
if (!preg_match('/Array\s*\(\s*\[/s', $message)) {
|
||||
return $message;
|
||||
}
|
||||
|
||||
// Process key => value pairs in print_r format
|
||||
// Match: [key] => string_value (including multi-line values)
|
||||
$pattern = '/(\[\S+\])\s*=>\s*([^\n\[]+)/';
|
||||
|
||||
return (string) preg_replace_callback($pattern, function (array $matches): string {
|
||||
$key = trim($matches[1], '[]');
|
||||
$value = trim($matches[2]);
|
||||
|
||||
// Skip if value looks like "Array" (nested structure)
|
||||
if ($value === 'Array') {
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
$masked = ($this->stringMasker)($value);
|
||||
|
||||
if ($masked !== $value) {
|
||||
$this->logAudit("print_r.{$key}", $value, $masked);
|
||||
return "[{$key}] => {$masked}";
|
||||
}
|
||||
|
||||
return $matches[0];
|
||||
}, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process var_export output format.
|
||||
*
|
||||
* Example format:
|
||||
* array (
|
||||
* 'key' => 'value',
|
||||
* 'nested' => array (
|
||||
* 'inner' => 'data',
|
||||
* ),
|
||||
* )
|
||||
*/
|
||||
private function processVarExportOutput(string $message): string
|
||||
{
|
||||
// Check if message contains var_export style output
|
||||
if (!preg_match('/array\s*\(\s*[\'"]?\w+[\'"]?\s*=>/s', $message)) {
|
||||
return $message;
|
||||
}
|
||||
|
||||
// Process 'key' => 'value' pairs
|
||||
$pattern = "/(['\"])(\w+)\\1\s*=>\s*(['\"])([^'\"]+)\\3/";
|
||||
|
||||
return (string) preg_replace_callback($pattern, function (array $matches): string {
|
||||
$keyQuote = $matches[1];
|
||||
$key = $matches[2];
|
||||
$valueQuote = $matches[3];
|
||||
$value = $matches[4];
|
||||
|
||||
$masked = ($this->stringMasker)($value);
|
||||
|
||||
if ($masked !== $value) {
|
||||
$this->logAudit("var_export.{$key}", $value, $masked);
|
||||
return "{$keyQuote}{$key}{$keyQuote} => {$valueQuote}{$masked}{$valueQuote}";
|
||||
}
|
||||
|
||||
return $matches[0];
|
||||
}, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process PHP serialize() output format.
|
||||
*
|
||||
* Example format: a:1:{s:3:"key";s:5:"value";}
|
||||
*/
|
||||
private function processSerializeOutput(string $message): string
|
||||
{
|
||||
// Check if message contains serialize style output
|
||||
if (!preg_match('/[aOCs]:\d+:/s', $message)) {
|
||||
return $message;
|
||||
}
|
||||
|
||||
// Match serialized strings: s:length:"value";
|
||||
$pattern = '/s:(\d+):"([^"]*)";/';
|
||||
|
||||
return (string) preg_replace_callback($pattern, function (array $matches): string {
|
||||
$originalLength = (int) $matches[1];
|
||||
$value = $matches[2];
|
||||
|
||||
// Verify the length matches
|
||||
if (strlen($value) !== $originalLength) {
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
$masked = ($this->stringMasker)($value);
|
||||
|
||||
if ($masked !== $value) {
|
||||
$newLength = strlen($masked);
|
||||
$this->logAudit('serialize.string', $value, $masked);
|
||||
return "s:{$newLength}:\"{$masked}\";";
|
||||
}
|
||||
|
||||
return $matches[0];
|
||||
}, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively mask values in an array.
|
||||
*
|
||||
* @param mixed $data The data to mask
|
||||
* @param string $path Current path for audit logging
|
||||
* @return mixed The masked data
|
||||
*/
|
||||
private function maskRecursive(mixed $data, string $path): mixed
|
||||
{
|
||||
if (is_string($data)) {
|
||||
$masked = ($this->stringMasker)($data);
|
||||
if ($masked !== $data) {
|
||||
$this->logAudit($path, $data, $masked);
|
||||
}
|
||||
return $masked;
|
||||
}
|
||||
|
||||
if (is_array($data)) {
|
||||
$result = [];
|
||||
foreach ($data as $key => $value) {
|
||||
$newPath = $path . '.' . $key;
|
||||
$result[$key] = $this->maskRecursive($value, $newPath);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log audit event if logger is configured.
|
||||
*/
|
||||
private function logAudit(string $path, string $original, string $masked): void
|
||||
{
|
||||
if ($this->auditLogger !== null) {
|
||||
($this->auditLogger)($path, $original, $masked);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the audit logger.
|
||||
*
|
||||
* @param callable(string,mixed,mixed):void|null $auditLogger
|
||||
*/
|
||||
public function setAuditLogger(?callable $auditLogger): void
|
||||
{
|
||||
$this->auditLogger = $auditLogger;
|
||||
}
|
||||
}
|
||||
210
src/Strategies/CallbackMaskingStrategy.php
Normal file
210
src/Strategies/CallbackMaskingStrategy.php
Normal file
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Strategies;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\MaskingOperationFailedException;
|
||||
use Monolog\LogRecord;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Masking strategy that uses custom callbacks for field-specific masking.
|
||||
*
|
||||
* This strategy allows wrapping legacy custom callback functions as proper
|
||||
* strategy implementations, enabling gradual migration to the strategy pattern.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class CallbackMaskingStrategy extends AbstractMaskingStrategy
|
||||
{
|
||||
/** @var callable(mixed): mixed */
|
||||
private $callback;
|
||||
|
||||
/**
|
||||
* @param string $fieldPath The field path this callback applies to
|
||||
* @param callable(mixed): mixed $callback The masking callback function
|
||||
* @param int $priority Strategy priority (default: 50)
|
||||
* @param bool $exactMatch Whether to require exact path match (default: true)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $fieldPath,
|
||||
callable $callback,
|
||||
int $priority = 50,
|
||||
private readonly bool $exactMatch = true
|
||||
) {
|
||||
parent::__construct($priority, [
|
||||
'field_path' => $fieldPath,
|
||||
'exact_match' => $exactMatch,
|
||||
]);
|
||||
|
||||
$this->callback = $callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a strategy for multiple field paths with the same callback.
|
||||
*
|
||||
* @param array<string> $fieldPaths Array of field paths
|
||||
* @param callable(mixed): mixed $callback The masking callback
|
||||
* @param int $priority Strategy priority
|
||||
* @return array<self> Array of CallbackMaskingStrategy instances
|
||||
*/
|
||||
public static function forPaths(
|
||||
array $fieldPaths,
|
||||
callable $callback,
|
||||
int $priority = 50
|
||||
): array {
|
||||
return array_map(
|
||||
fn(string $path): self => new self($path, $callback, $priority),
|
||||
$fieldPaths
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a strategy that always returns a constant value.
|
||||
*
|
||||
* @param string $fieldPath The field path
|
||||
* @param string $replacementValue The constant replacement value
|
||||
* @param int $priority Strategy priority
|
||||
*/
|
||||
public static function constant(
|
||||
string $fieldPath,
|
||||
string $replacementValue,
|
||||
int $priority = 50
|
||||
): self {
|
||||
return new self(
|
||||
$fieldPath,
|
||||
fn(mixed $value): string => $replacementValue,
|
||||
$priority
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a strategy that hashes the original value.
|
||||
*
|
||||
* @param string $fieldPath The field path
|
||||
* @param string $algorithm Hash algorithm (default: 'sha256')
|
||||
* @param int $truncateLength Truncate hash to this length (0 = no truncation)
|
||||
* @param int $priority Strategy priority
|
||||
*/
|
||||
public static function hash(
|
||||
string $fieldPath,
|
||||
string $algorithm = 'sha256',
|
||||
int $truncateLength = 8,
|
||||
int $priority = 50
|
||||
): self {
|
||||
return new self(
|
||||
$fieldPath,
|
||||
function (mixed $value) use ($algorithm, $truncateLength): string {
|
||||
$stringValue = is_scalar($value) ? (string) $value : json_encode($value);
|
||||
$hash = hash($algorithm, $stringValue === false ? '' : $stringValue);
|
||||
return $truncateLength > 0
|
||||
? substr($hash, 0, $truncateLength) . '...'
|
||||
: $hash;
|
||||
},
|
||||
$priority
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a strategy that partially masks a value (e.g., email@****.com).
|
||||
*
|
||||
* @param string $fieldPath The field path
|
||||
* @param int $visibleStart Characters to show at start
|
||||
* @param int $visibleEnd Characters to show at end
|
||||
* @param string $maskChar Character to use for masking
|
||||
* @param int $priority Strategy priority
|
||||
*/
|
||||
public static function partial(
|
||||
string $fieldPath,
|
||||
int $visibleStart = 2,
|
||||
int $visibleEnd = 2,
|
||||
string $maskChar = '*',
|
||||
int $priority = 50
|
||||
): self {
|
||||
return new self(
|
||||
$fieldPath,
|
||||
function (mixed $value) use ($visibleStart, $visibleEnd, $maskChar): string {
|
||||
$str = is_scalar($value) ? (string) $value : '[OBJECT]';
|
||||
$len = strlen($str);
|
||||
|
||||
if ($len <= $visibleStart + $visibleEnd) {
|
||||
return str_repeat($maskChar, $len);
|
||||
}
|
||||
|
||||
$start = substr($str, 0, $visibleStart);
|
||||
$end = substr($str, -$visibleEnd);
|
||||
$masked = str_repeat($maskChar, $len - $visibleStart - $visibleEnd);
|
||||
|
||||
return $start . $masked . $end;
|
||||
},
|
||||
$priority
|
||||
);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function mask(mixed $value, string $path, LogRecord $logRecord): mixed
|
||||
{
|
||||
try {
|
||||
return ($this->callback)($value);
|
||||
} catch (Throwable $e) {
|
||||
throw MaskingOperationFailedException::customCallbackFailed(
|
||||
$path,
|
||||
$value,
|
||||
'Callback threw exception: ' . $e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function shouldApply(mixed $value, string $path, LogRecord $logRecord): bool
|
||||
{
|
||||
if ($this->exactMatch) {
|
||||
return $path === $this->fieldPath;
|
||||
}
|
||||
|
||||
return $this->pathMatches($path, $this->fieldPath);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getName(): string
|
||||
{
|
||||
return sprintf('Callback Masking (%s)', $this->fieldPath);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function validate(): bool
|
||||
{
|
||||
if ($this->fieldPath === '' || $this->fieldPath === '0') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return is_callable($this->callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the field path this strategy applies to.
|
||||
*/
|
||||
public function getFieldPath(): string
|
||||
{
|
||||
return $this->fieldPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this strategy uses exact matching.
|
||||
*/
|
||||
public function isExactMatch(): bool
|
||||
{
|
||||
return $this->exactMatch;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'field_path' => $this->fieldPath,
|
||||
'exact_match' => $this->exactMatch,
|
||||
'priority' => $this->priority,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,14 @@ class ConditionalMaskingStrategy extends AbstractMaskingStrategy
|
||||
{
|
||||
$conditionCount = count($this->conditions);
|
||||
$logic = $this->requireAllConditions ? 'AND' : 'OR';
|
||||
return sprintf('Conditional Masking (%d conditions, %s logic) -> %s', $conditionCount, $logic, $this->wrappedStrategy->getName());
|
||||
$wrappedName = $this->wrappedStrategy->getName();
|
||||
|
||||
return sprintf(
|
||||
'Conditional Masking (%d conditions, %s logic) -> %s',
|
||||
$conditionCount,
|
||||
$logic,
|
||||
$wrappedName
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -243,7 +243,27 @@ class StrategyManager
|
||||
*
|
||||
* @return (((array|int|string)[]|int)[]|int)[]
|
||||
*
|
||||
* @psalm-return array{total_strategies: int<0, max>, strategy_types: array<string, 1|2>, priority_distribution: array{'90-100 (Critical)'?: 1|2, '80-89 (High)'?: 1|2, '60-79 (Medium-High)'?: 1|2, '40-59 (Medium)'?: 1|2, '20-39 (Low-Medium)'?: 1|2, '0-19 (Low)'?: 1|2}, strategies: list{0?: array{name: string, class: string, priority: int<min, max>, configuration: array<string, mixed>},...}}
|
||||
* @psalm-return array{
|
||||
* total_strategies: int<0, max>,
|
||||
* strategy_types: array<string, 1|2>,
|
||||
* priority_distribution: array{
|
||||
* '90-100 (Critical)'?: 1|2,
|
||||
* '80-89 (High)'?: 1|2,
|
||||
* '60-79 (Medium-High)'?: 1|2,
|
||||
* '40-59 (Medium)'?: 1|2,
|
||||
* '20-39 (Low-Medium)'?: 1|2,
|
||||
* '0-19 (Low)'?: 1|2
|
||||
* },
|
||||
* strategies: list{
|
||||
* 0?: array{
|
||||
* name: string,
|
||||
* class: string,
|
||||
* priority: int<min, max>,
|
||||
* configuration: array<string, mixed>
|
||||
* },
|
||||
* ...
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
|
||||
207
src/Streaming/StreamingProcessor.php
Normal file
207
src/Streaming/StreamingProcessor.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Streaming;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\StreamingOperationFailedException;
|
||||
use Ivuorinen\MonologGdprFilter\MaskingOrchestrator;
|
||||
|
||||
/**
|
||||
* Streaming processor for handling large log files.
|
||||
*
|
||||
* Processes logs in chunks to minimize memory usage when
|
||||
* dealing with large log files or data streams.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class StreamingProcessor
|
||||
{
|
||||
private const DEFAULT_CHUNK_SIZE = 1000;
|
||||
|
||||
/**
|
||||
* @var callable(string,mixed,mixed):void|null
|
||||
*/
|
||||
private $auditLogger;
|
||||
|
||||
/**
|
||||
* @param MaskingOrchestrator $orchestrator The masking orchestrator
|
||||
* @param int $chunkSize Number of records to process at once
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly MaskingOrchestrator $orchestrator,
|
||||
private readonly int $chunkSize = self::DEFAULT_CHUNK_SIZE,
|
||||
?callable $auditLogger = null
|
||||
) {
|
||||
$this->auditLogger = $auditLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a generator of records.
|
||||
*
|
||||
* @param iterable<array{message: string, context: array<string,mixed>}> $records
|
||||
* @return \Generator<array{message: string, context: array<string,mixed>}>
|
||||
*/
|
||||
public function processStream(iterable $records): \Generator
|
||||
{
|
||||
$buffer = [];
|
||||
$count = 0;
|
||||
|
||||
foreach ($records as $record) {
|
||||
$buffer[] = $record;
|
||||
$count++;
|
||||
|
||||
if ($count >= $this->chunkSize) {
|
||||
foreach ($this->processChunk($buffer) as $item) {
|
||||
yield $item;
|
||||
}
|
||||
$buffer = [];
|
||||
$count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Process remaining records
|
||||
if ($buffer !== []) {
|
||||
foreach ($this->processChunk($buffer) as $item) {
|
||||
yield $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a file line by line.
|
||||
*
|
||||
* @param string $filePath Path to the log file
|
||||
* @param callable(string):array{message: string, context: array<string,mixed>} $lineParser
|
||||
* @return \Generator<array{message: string, context: array<string,mixed>}>
|
||||
*/
|
||||
public function processFile(string $filePath, callable $lineParser): \Generator
|
||||
{
|
||||
$handle = @fopen($filePath, 'r');
|
||||
if ($handle === false) {
|
||||
throw StreamingOperationFailedException::cannotOpenInputFile($filePath);
|
||||
}
|
||||
|
||||
try {
|
||||
$buffer = [];
|
||||
$count = 0;
|
||||
|
||||
while (($line = fgets($handle)) !== false) {
|
||||
$line = trim($line);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$record = $lineParser($line);
|
||||
$buffer[] = $record;
|
||||
$count++;
|
||||
|
||||
if ($count >= $this->chunkSize) {
|
||||
foreach ($this->processChunk($buffer) as $item) {
|
||||
yield $item;
|
||||
}
|
||||
$buffer = [];
|
||||
$count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Process remaining records
|
||||
if ($buffer !== []) {
|
||||
foreach ($this->processChunk($buffer) as $item) {
|
||||
yield $item;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process and write to an output file.
|
||||
*
|
||||
* @param iterable<array{message: string, context: array<string,mixed>}> $records
|
||||
* @param string $outputPath Path to output file
|
||||
* @param callable(array{message: string, context: array<string,mixed>}):string $formatter
|
||||
* @return int Number of records processed
|
||||
*/
|
||||
public function processToFile(
|
||||
iterable $records,
|
||||
string $outputPath,
|
||||
callable $formatter
|
||||
): int {
|
||||
$handle = @fopen($outputPath, 'w');
|
||||
if ($handle === false) {
|
||||
throw StreamingOperationFailedException::cannotOpenOutputFile($outputPath);
|
||||
}
|
||||
|
||||
try {
|
||||
$count = 0;
|
||||
foreach ($this->processStream($records) as $record) {
|
||||
fwrite($handle, $formatter($record) . "\n");
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a chunk of records.
|
||||
*
|
||||
* @param list<array{message: string, context: array<string,mixed>}> $chunk
|
||||
* @return \Generator<array{message: string, context: array<string,mixed>}>
|
||||
*/
|
||||
private function processChunk(array $chunk): \Generator
|
||||
{
|
||||
foreach ($chunk as $record) {
|
||||
$processed = $this->orchestrator->process($record['message'], $record['context']);
|
||||
|
||||
if ($this->auditLogger !== null) {
|
||||
($this->auditLogger)('streaming.processed', count($chunk), 1);
|
||||
}
|
||||
|
||||
yield $processed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about a streaming operation.
|
||||
*
|
||||
* @param iterable<array{message: string, context: array<string,mixed>}> $records
|
||||
* @return array{processed: int, masked: int, errors: int}
|
||||
*/
|
||||
public function getStatistics(iterable $records): array
|
||||
{
|
||||
$stats = ['processed' => 0, 'masked' => 0, 'errors' => 0];
|
||||
|
||||
foreach ($this->processStream($records) as $record) {
|
||||
$stats['processed']++;
|
||||
// Count if any masking occurred (simple heuristic)
|
||||
if (str_contains($record['message'], '***') || str_contains($record['message'], '[')) {
|
||||
$stats['masked']++;
|
||||
}
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the audit logger.
|
||||
*
|
||||
* @param callable(string,mixed,mixed):void|null $auditLogger
|
||||
*/
|
||||
public function setAuditLogger(?callable $auditLogger): void
|
||||
{
|
||||
$this->auditLogger = $auditLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the chunk size.
|
||||
*/
|
||||
public function getChunkSize(): int
|
||||
{
|
||||
return $this->chunkSize;
|
||||
}
|
||||
}
|
||||
279
tests/Anonymization/KAnonymizerTest.php
Normal file
279
tests/Anonymization/KAnonymizerTest.php
Normal file
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Anonymization;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Anonymization\GeneralizationStrategy;
|
||||
use Ivuorinen\MonologGdprFilter\Anonymization\KAnonymizer;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tests\TestConstants;
|
||||
|
||||
#[CoversClass(KAnonymizer::class)]
|
||||
#[CoversClass(GeneralizationStrategy::class)]
|
||||
final class KAnonymizerTest extends TestCase
|
||||
{
|
||||
public function testAnonymizeWithAgeStrategy(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerAgeStrategy('age');
|
||||
|
||||
$record = ['name' => 'John', 'age' => 25];
|
||||
$result = $anonymizer->anonymize($record);
|
||||
|
||||
$this->assertSame(TestConstants::AGE_RANGE_20_29, $result['age']);
|
||||
$this->assertSame('John', $result['name']);
|
||||
}
|
||||
|
||||
public function testAnonymizeWithAgeStrategyDifferentRanges(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerAgeStrategy('age', 5);
|
||||
|
||||
$this->assertSame('20-24', $anonymizer->anonymize(['age' => 22])['age']);
|
||||
$this->assertSame('25-29', $anonymizer->anonymize(['age' => 27])['age']);
|
||||
}
|
||||
|
||||
public function testAnonymizeWithDateStrategyMonth(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerDateStrategy('created_at', 'month');
|
||||
|
||||
$record = ['created_at' => '2024-03-15'];
|
||||
$result = $anonymizer->anonymize($record);
|
||||
|
||||
$this->assertSame('2024-03', $result['created_at']);
|
||||
}
|
||||
|
||||
public function testAnonymizeWithDateStrategyYear(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerDateStrategy('birth_date', 'year');
|
||||
|
||||
$record = ['birth_date' => '1990-05-20'];
|
||||
$result = $anonymizer->anonymize($record);
|
||||
|
||||
$this->assertSame('1990', $result['birth_date']);
|
||||
}
|
||||
|
||||
public function testAnonymizeWithDateStrategyQuarter(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerDateStrategy('quarter_date', 'quarter');
|
||||
|
||||
$this->assertSame('2024-Q1', $anonymizer->anonymize(['quarter_date' => '2024-02-15'])['quarter_date']);
|
||||
$this->assertSame('2024-Q2', $anonymizer->anonymize(['quarter_date' => '2024-05-15'])['quarter_date']);
|
||||
$this->assertSame('2024-Q3', $anonymizer->anonymize(['quarter_date' => '2024-08-15'])['quarter_date']);
|
||||
$this->assertSame('2024-Q4', $anonymizer->anonymize(['quarter_date' => '2024-11-15'])['quarter_date']);
|
||||
}
|
||||
|
||||
public function testAnonymizeWithDateTimeObject(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerDateStrategy('date', 'month');
|
||||
|
||||
$record = ['date' => new \DateTimeImmutable('2024-06-15')];
|
||||
$result = $anonymizer->anonymize($record);
|
||||
|
||||
$this->assertSame('2024-06', $result['date']);
|
||||
}
|
||||
|
||||
public function testAnonymizeWithLocationStrategy(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerLocationStrategy('zip_code', 3);
|
||||
|
||||
$record = ['zip_code' => '12345'];
|
||||
$result = $anonymizer->anonymize($record);
|
||||
|
||||
$this->assertSame('123**', $result['zip_code']);
|
||||
}
|
||||
|
||||
public function testAnonymizeWithLocationStrategyShortValue(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerLocationStrategy('zip', 5);
|
||||
|
||||
$record = ['zip' => '123'];
|
||||
$result = $anonymizer->anonymize($record);
|
||||
|
||||
$this->assertSame('123', $result['zip']);
|
||||
}
|
||||
|
||||
public function testAnonymizeWithNumericRangeStrategy(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerNumericRangeStrategy('salary', 1000);
|
||||
|
||||
$record = ['salary' => 52500];
|
||||
$result = $anonymizer->anonymize($record);
|
||||
|
||||
$this->assertSame('52000-52999', $result['salary']);
|
||||
}
|
||||
|
||||
public function testAnonymizeWithCustomStrategy(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerCustomStrategy('email', fn(mixed $v): string => explode('@', (string) $v)[1] ?? 'unknown');
|
||||
|
||||
$record = ['email' => 'john@example.com'];
|
||||
$result = $anonymizer->anonymize($record);
|
||||
|
||||
$this->assertSame('example.com', $result['email']);
|
||||
}
|
||||
|
||||
public function testRegisterStrategy(): void
|
||||
{
|
||||
$strategy = new GeneralizationStrategy(fn(mixed $v): string => 'masked', 'test');
|
||||
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerStrategy('field', $strategy);
|
||||
|
||||
$record = ['field' => 'value'];
|
||||
$result = $anonymizer->anonymize($record);
|
||||
|
||||
$this->assertSame('masked', $result['field']);
|
||||
}
|
||||
|
||||
public function testAnonymizeIgnoresMissingFields(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerAgeStrategy('age');
|
||||
|
||||
$record = ['name' => 'John'];
|
||||
$result = $anonymizer->anonymize($record);
|
||||
|
||||
$this->assertSame(['name' => 'John'], $result);
|
||||
}
|
||||
|
||||
public function testAnonymizeBatch(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerAgeStrategy('age');
|
||||
|
||||
$records = [
|
||||
['name' => 'John', 'age' => 25],
|
||||
['name' => 'Jane', 'age' => 32],
|
||||
];
|
||||
|
||||
$results = $anonymizer->anonymizeBatch($records);
|
||||
|
||||
$this->assertCount(2, $results);
|
||||
$this->assertSame(TestConstants::AGE_RANGE_20_29, $results[0]['age']);
|
||||
$this->assertSame('30-39', $results[1]['age']);
|
||||
}
|
||||
|
||||
public function testAnonymizeWithAuditLogger(): void
|
||||
{
|
||||
$logs = [];
|
||||
$auditLogger = function (string $path, mixed $original, mixed $masked) use (&$logs): void {
|
||||
$logs[] = ['path' => $path, 'original' => $original, TestConstants::DATA_MASKED => $masked];
|
||||
};
|
||||
|
||||
$anonymizer = new KAnonymizer($auditLogger);
|
||||
$anonymizer->registerAgeStrategy('age');
|
||||
|
||||
$anonymizer->anonymize(['age' => 25]);
|
||||
|
||||
$this->assertCount(1, $logs);
|
||||
$this->assertSame('k-anonymity.age', $logs[0]['path']);
|
||||
$this->assertSame(25, $logs[0]['original']);
|
||||
$this->assertSame(TestConstants::AGE_RANGE_20_29, $logs[0][TestConstants::DATA_MASKED]);
|
||||
}
|
||||
|
||||
public function testSetAuditLogger(): void
|
||||
{
|
||||
$logs = [];
|
||||
$auditLogger = function (string $path) use (&$logs): void {
|
||||
$logs[] = ['path' => $path];
|
||||
};
|
||||
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->setAuditLogger($auditLogger);
|
||||
$anonymizer->registerAgeStrategy('age');
|
||||
|
||||
$anonymizer->anonymize(['age' => 25]);
|
||||
|
||||
$this->assertNotEmpty($logs);
|
||||
}
|
||||
|
||||
public function testGetStrategies(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerAgeStrategy('age');
|
||||
$anonymizer->registerLocationStrategy('zip', 3);
|
||||
|
||||
$strategies = $anonymizer->getStrategies();
|
||||
|
||||
$this->assertCount(2, $strategies);
|
||||
$this->assertArrayHasKey('age', $strategies);
|
||||
$this->assertArrayHasKey('zip', $strategies);
|
||||
}
|
||||
|
||||
public function testCreateGdprDefault(): void
|
||||
{
|
||||
$anonymizer = KAnonymizer::createGdprDefault();
|
||||
|
||||
$strategies = $anonymizer->getStrategies();
|
||||
|
||||
$this->assertArrayHasKey('age', $strategies);
|
||||
$this->assertArrayHasKey('birth_date', $strategies);
|
||||
$this->assertArrayHasKey('created_at', $strategies);
|
||||
$this->assertArrayHasKey('zip_code', $strategies);
|
||||
$this->assertArrayHasKey('postal_code', $strategies);
|
||||
}
|
||||
|
||||
public function testCreateGdprDefaultWithAuditLogger(): void
|
||||
{
|
||||
$logs = [];
|
||||
$auditLogger = function (string $path) use (&$logs): void {
|
||||
$logs[] = ['path' => $path];
|
||||
};
|
||||
|
||||
$anonymizer = KAnonymizer::createGdprDefault($auditLogger);
|
||||
$anonymizer->anonymize(['age' => 35]);
|
||||
|
||||
$this->assertNotEmpty($logs);
|
||||
}
|
||||
|
||||
public function testGeneralizationStrategyGetType(): void
|
||||
{
|
||||
$strategy = new GeneralizationStrategy(fn(mixed $v): string => (string) $v, 'test_type');
|
||||
|
||||
$this->assertSame('test_type', $strategy->getType());
|
||||
}
|
||||
|
||||
public function testGeneralizationStrategyGeneralize(): void
|
||||
{
|
||||
$strategy = new GeneralizationStrategy(fn(mixed $v): string => strtoupper((string) $v));
|
||||
|
||||
$this->assertSame('HELLO', $strategy->generalize('hello'));
|
||||
}
|
||||
|
||||
public function testMultipleStrategiesOnSameRecord(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerAgeStrategy('age');
|
||||
$anonymizer->registerLocationStrategy('zip', 2);
|
||||
$anonymizer->registerDateStrategy('date', 'year');
|
||||
|
||||
$record = ['age' => 28, 'zip' => '12345', 'date' => '2024-06-15', 'name' => 'John'];
|
||||
$result = $anonymizer->anonymize($record);
|
||||
|
||||
$this->assertSame(TestConstants::AGE_RANGE_20_29, $result['age']);
|
||||
$this->assertSame('12***', $result['zip']);
|
||||
$this->assertSame('2024', $result['date']);
|
||||
$this->assertSame('John', $result['name']);
|
||||
}
|
||||
|
||||
public function testFluentInterface(): void
|
||||
{
|
||||
$anonymizer = (new KAnonymizer())
|
||||
->registerAgeStrategy('age')
|
||||
->registerLocationStrategy('zip', 3)
|
||||
->registerDateStrategy('date', 'month');
|
||||
|
||||
$this->assertCount(3, $anonymizer->getStrategies());
|
||||
}
|
||||
}
|
||||
132
tests/ArrayAccessor/ArrayAccessorFactoryTest.php
Normal file
132
tests/ArrayAccessor/ArrayAccessorFactoryTest.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\ArrayAccessor;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\ArrayAccessor\ArrayAccessorFactory;
|
||||
use Ivuorinen\MonologGdprFilter\ArrayAccessor\DotArrayAccessor;
|
||||
use Ivuorinen\MonologGdprFilter\Contracts\ArrayAccessorInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests for ArrayAccessorFactory.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class ArrayAccessorFactoryTest extends TestCase
|
||||
{
|
||||
public function testDefaultFactoryCreatesDotAccessor(): void
|
||||
{
|
||||
$factory = ArrayAccessorFactory::default();
|
||||
$accessor = $factory->create(['test' => 'value']);
|
||||
|
||||
$this->assertInstanceOf(ArrayAccessorInterface::class, $accessor);
|
||||
$this->assertInstanceOf(DotArrayAccessor::class, $accessor);
|
||||
}
|
||||
|
||||
public function testCreateWithDataPassesDataToAccessor(): void
|
||||
{
|
||||
$factory = ArrayAccessorFactory::default();
|
||||
$accessor = $factory->create([
|
||||
'user' => ['email' => 'test@example.com'],
|
||||
]);
|
||||
|
||||
$this->assertTrue($accessor->has('user.email'));
|
||||
$this->assertSame('test@example.com', $accessor->get('user.email'));
|
||||
}
|
||||
|
||||
public function testWithClassFactoryMethod(): void
|
||||
{
|
||||
$factory = ArrayAccessorFactory::withClass(DotArrayAccessor::class);
|
||||
$accessor = $factory->create(['foo' => 'bar']);
|
||||
|
||||
$this->assertInstanceOf(DotArrayAccessor::class, $accessor);
|
||||
$this->assertSame('bar', $accessor->get('foo'));
|
||||
}
|
||||
|
||||
public function testWithCallableFactoryMethod(): void
|
||||
{
|
||||
$customFactory = (fn(array $data): ArrayAccessorInterface => new DotArrayAccessor(array_merge($data, ['injected' => true])));
|
||||
|
||||
$factory = ArrayAccessorFactory::withCallable($customFactory);
|
||||
$accessor = $factory->create(['original' => 'data']);
|
||||
|
||||
$this->assertTrue($accessor->get('injected'));
|
||||
$this->assertSame('data', $accessor->get('original'));
|
||||
}
|
||||
|
||||
public function testConstructorWithNullUsesDefault(): void
|
||||
{
|
||||
$factory = new ArrayAccessorFactory(null);
|
||||
$accessor = $factory->create(['test' => 'value']);
|
||||
|
||||
$this->assertInstanceOf(DotArrayAccessor::class, $accessor);
|
||||
}
|
||||
|
||||
public function testConstructorWithClassName(): void
|
||||
{
|
||||
$factory = new ArrayAccessorFactory(DotArrayAccessor::class);
|
||||
$accessor = $factory->create(['key' => 'value']);
|
||||
|
||||
$this->assertInstanceOf(DotArrayAccessor::class, $accessor);
|
||||
$this->assertSame('value', $accessor->get('key'));
|
||||
}
|
||||
|
||||
public function testConstructorWithCallable(): void
|
||||
{
|
||||
$callCount = 0;
|
||||
$customFactory = function (array $data) use (&$callCount): ArrayAccessorInterface {
|
||||
$callCount++;
|
||||
return new DotArrayAccessor($data);
|
||||
};
|
||||
|
||||
$factory = new ArrayAccessorFactory($customFactory);
|
||||
$factory->create([]);
|
||||
$factory->create([]);
|
||||
|
||||
$this->assertSame(2, $callCount);
|
||||
}
|
||||
|
||||
public function testCreateMultipleAccessorsAreIndependent(): void
|
||||
{
|
||||
$factory = ArrayAccessorFactory::default();
|
||||
|
||||
$accessor1 = $factory->create(['key' => 'value1']);
|
||||
$accessor2 = $factory->create(['key' => 'value2']);
|
||||
|
||||
$accessor1->set('key', 'modified');
|
||||
|
||||
$this->assertSame('modified', $accessor1->get('key'));
|
||||
$this->assertSame('value2', $accessor2->get('key'));
|
||||
}
|
||||
|
||||
public function testFactoryWithEmptyArray(): void
|
||||
{
|
||||
$factory = ArrayAccessorFactory::default();
|
||||
$accessor = $factory->create([]);
|
||||
|
||||
$this->assertSame([], $accessor->all());
|
||||
$this->assertFalse($accessor->has('anything'));
|
||||
}
|
||||
|
||||
public function testFactoryPreservesComplexStructure(): void
|
||||
{
|
||||
$data = [
|
||||
'users' => [
|
||||
['id' => 1, 'name' => 'Alice'],
|
||||
['id' => 2, 'name' => 'Bob'],
|
||||
],
|
||||
'metadata' => [
|
||||
'version' => '1.0',
|
||||
'nested' => ['deep' => ['value' => true]],
|
||||
],
|
||||
];
|
||||
|
||||
$factory = ArrayAccessorFactory::default();
|
||||
$accessor = $factory->create($data);
|
||||
|
||||
$this->assertSame($data, $accessor->all());
|
||||
$this->assertTrue($accessor->get('metadata.nested.deep.value'));
|
||||
}
|
||||
}
|
||||
188
tests/ArrayAccessor/ArrayAccessorInterfaceTest.php
Normal file
188
tests/ArrayAccessor/ArrayAccessorInterfaceTest.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\ArrayAccessor;
|
||||
|
||||
use Adbar\Dot;
|
||||
use Ivuorinen\MonologGdprFilter\ArrayAccessor\DotArrayAccessor;
|
||||
use Ivuorinen\MonologGdprFilter\Contracts\ArrayAccessorInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tests\TestConstants;
|
||||
|
||||
/**
|
||||
* Tests for ArrayAccessorInterface implementations.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class ArrayAccessorInterfaceTest extends TestCase
|
||||
{
|
||||
public function testDotArrayAccessorImplementsInterface(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor([]);
|
||||
|
||||
$this->assertInstanceOf(ArrayAccessorInterface::class, $accessor);
|
||||
}
|
||||
|
||||
public function testHasReturnsTrueForExistingPath(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor([
|
||||
'user' => [
|
||||
'email' => TestConstants::EMAIL_TEST,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertTrue($accessor->has('user.email'));
|
||||
}
|
||||
|
||||
public function testHasReturnsFalseForMissingPath(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor([
|
||||
'user' => [
|
||||
'email' => TestConstants::EMAIL_TEST,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertFalse($accessor->has('user.name'));
|
||||
$this->assertFalse($accessor->has('nonexistent'));
|
||||
}
|
||||
|
||||
public function testGetReturnsValueForExistingPath(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor([
|
||||
'user' => [
|
||||
'email' => TestConstants::EMAIL_TEST,
|
||||
'age' => 25,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertSame(TestConstants::EMAIL_TEST, $accessor->get('user.email'));
|
||||
$this->assertSame(25, $accessor->get('user.age'));
|
||||
}
|
||||
|
||||
public function testGetReturnsDefaultForMissingPath(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor(['key' => 'value']);
|
||||
|
||||
$this->assertNull($accessor->get('missing'));
|
||||
$this->assertSame('default', $accessor->get('missing', 'default'));
|
||||
}
|
||||
|
||||
public function testSetCreatesNewPath(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor([]);
|
||||
|
||||
$accessor->set('user.email', TestConstants::EMAIL_NEW);
|
||||
|
||||
$this->assertTrue($accessor->has('user.email'));
|
||||
$this->assertSame(TestConstants::EMAIL_NEW, $accessor->get('user.email'));
|
||||
}
|
||||
|
||||
public function testSetOverwritesExistingPath(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor([
|
||||
'user' => ['email' => 'old@example.com'],
|
||||
]);
|
||||
|
||||
$accessor->set('user.email', TestConstants::EMAIL_NEW);
|
||||
|
||||
$this->assertSame(TestConstants::EMAIL_NEW, $accessor->get('user.email'));
|
||||
}
|
||||
|
||||
public function testDeleteRemovesPath(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor([
|
||||
'user' => [
|
||||
'email' => TestConstants::EMAIL_TEST,
|
||||
'name' => 'Test User',
|
||||
],
|
||||
]);
|
||||
|
||||
$accessor->delete('user.email');
|
||||
|
||||
$this->assertFalse($accessor->has('user.email'));
|
||||
$this->assertTrue($accessor->has('user.name'));
|
||||
}
|
||||
|
||||
public function testAllReturnsCompleteArray(): void
|
||||
{
|
||||
$data = [
|
||||
'user' => [
|
||||
'email' => TestConstants::EMAIL_TEST,
|
||||
'profile' => [
|
||||
'bio' => 'Hello world',
|
||||
],
|
||||
],
|
||||
'settings' => ['theme' => 'dark'],
|
||||
];
|
||||
|
||||
$accessor = new DotArrayAccessor($data);
|
||||
|
||||
$this->assertSame($data, $accessor->all());
|
||||
}
|
||||
|
||||
public function testAllReflectsModifications(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor(['key' => 'original']);
|
||||
|
||||
$accessor->set('key', 'modified');
|
||||
$accessor->set('new', 'value');
|
||||
|
||||
$result = $accessor->all();
|
||||
|
||||
$this->assertSame('modified', $result['key']);
|
||||
$this->assertSame('value', $result['new']);
|
||||
}
|
||||
|
||||
public function testFromArrayFactoryMethod(): void
|
||||
{
|
||||
$data = ['foo' => 'bar'];
|
||||
$accessor = DotArrayAccessor::fromArray($data);
|
||||
|
||||
$this->assertInstanceOf(DotArrayAccessor::class, $accessor);
|
||||
$this->assertSame('bar', $accessor->get('foo'));
|
||||
}
|
||||
|
||||
public function testGetDotReturnsUnderlyingInstance(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor(['test' => 'value']);
|
||||
$dot = $accessor->getDot();
|
||||
|
||||
$this->assertInstanceOf(Dot::class, $dot);
|
||||
$this->assertSame('value', $dot->get('test'));
|
||||
}
|
||||
|
||||
public function testDeepNestedAccess(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor([
|
||||
'level1' => [
|
||||
'level2' => [
|
||||
'level3' => [
|
||||
'level4' => 'deep value',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertTrue($accessor->has('level1.level2.level3.level4'));
|
||||
$this->assertSame('deep value', $accessor->get('level1.level2.level3.level4'));
|
||||
|
||||
$accessor->set('level1.level2.level3.level4', 'modified');
|
||||
$this->assertSame('modified', $accessor->get('level1.level2.level3.level4'));
|
||||
}
|
||||
|
||||
public function testNumericKeys(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor([
|
||||
'items' => [
|
||||
0 => 'first',
|
||||
1 => 'second',
|
||||
2 => 'third',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertTrue($accessor->has('items.0'));
|
||||
$this->assertSame('first', $accessor->get('items.0'));
|
||||
$this->assertSame('second', $accessor->get('items.1'));
|
||||
}
|
||||
}
|
||||
190
tests/Audit/AuditContextTest.php
Normal file
190
tests/Audit/AuditContextTest.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Audit;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Audit\AuditContext;
|
||||
use Ivuorinen\MonologGdprFilter\Audit\ErrorContext;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests for AuditContext value object.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class AuditContextTest extends TestCase
|
||||
{
|
||||
public function testSuccessCreation(): void
|
||||
{
|
||||
$context = AuditContext::success(
|
||||
AuditContext::OP_REGEX,
|
||||
12.5,
|
||||
['key' => 'value']
|
||||
);
|
||||
|
||||
$this->assertSame(AuditContext::OP_REGEX, $context->operationType);
|
||||
$this->assertSame(AuditContext::STATUS_SUCCESS, $context->status);
|
||||
$this->assertSame(1, $context->attemptNumber);
|
||||
$this->assertSame(12.5, $context->durationMs);
|
||||
$this->assertNull($context->error);
|
||||
$this->assertSame(['key' => 'value'], $context->metadata);
|
||||
}
|
||||
|
||||
public function testFailedCreation(): void
|
||||
{
|
||||
$error = ErrorContext::create('TestError', 'Something went wrong');
|
||||
$context = AuditContext::failed(
|
||||
AuditContext::OP_FIELD_PATH,
|
||||
$error,
|
||||
3,
|
||||
50.0,
|
||||
['retry' => true]
|
||||
);
|
||||
|
||||
$this->assertSame(AuditContext::OP_FIELD_PATH, $context->operationType);
|
||||
$this->assertSame(AuditContext::STATUS_FAILED, $context->status);
|
||||
$this->assertSame(3, $context->attemptNumber);
|
||||
$this->assertSame(50.0, $context->durationMs);
|
||||
$this->assertSame($error, $context->error);
|
||||
$this->assertArrayHasKey('retry', $context->metadata);
|
||||
}
|
||||
|
||||
public function testRecoveredCreation(): void
|
||||
{
|
||||
$context = AuditContext::recovered(
|
||||
AuditContext::OP_CALLBACK,
|
||||
2,
|
||||
25.0
|
||||
);
|
||||
|
||||
$this->assertSame(AuditContext::OP_CALLBACK, $context->operationType);
|
||||
$this->assertSame(AuditContext::STATUS_RECOVERED, $context->status);
|
||||
$this->assertSame(2, $context->attemptNumber);
|
||||
$this->assertSame(25.0, $context->durationMs);
|
||||
}
|
||||
|
||||
public function testSkippedCreation(): void
|
||||
{
|
||||
$context = AuditContext::skipped(
|
||||
AuditContext::OP_CONDITIONAL,
|
||||
'Condition not met'
|
||||
);
|
||||
|
||||
$this->assertSame(AuditContext::OP_CONDITIONAL, $context->operationType);
|
||||
$this->assertSame(AuditContext::STATUS_SKIPPED, $context->status);
|
||||
$this->assertArrayHasKey('skip_reason', $context->metadata);
|
||||
$this->assertSame('Condition not met', $context->metadata['skip_reason']);
|
||||
}
|
||||
|
||||
public function testWithCorrelationId(): void
|
||||
{
|
||||
$context = AuditContext::success(AuditContext::OP_REGEX);
|
||||
$this->assertNull($context->correlationId);
|
||||
|
||||
$withId = $context->withCorrelationId('abc123');
|
||||
|
||||
$this->assertNull($context->correlationId);
|
||||
$this->assertSame('abc123', $withId->correlationId);
|
||||
$this->assertSame($context->operationType, $withId->operationType);
|
||||
$this->assertSame($context->status, $withId->status);
|
||||
}
|
||||
|
||||
public function testWithMetadata(): void
|
||||
{
|
||||
$context = AuditContext::success(
|
||||
AuditContext::OP_REGEX,
|
||||
0.0,
|
||||
['original' => 'value']
|
||||
);
|
||||
|
||||
$withMeta = $context->withMetadata(['added' => 'new']);
|
||||
|
||||
$this->assertArrayHasKey('original', $withMeta->metadata);
|
||||
$this->assertArrayHasKey('added', $withMeta->metadata);
|
||||
$this->assertSame('value', $withMeta->metadata['original']);
|
||||
$this->assertSame('new', $withMeta->metadata['added']);
|
||||
}
|
||||
|
||||
public function testIsSuccess(): void
|
||||
{
|
||||
$success = AuditContext::success(AuditContext::OP_REGEX);
|
||||
$recovered = AuditContext::recovered(AuditContext::OP_REGEX, 2);
|
||||
$error = ErrorContext::create('Error', 'msg');
|
||||
$failed = AuditContext::failed(AuditContext::OP_REGEX, $error);
|
||||
$skipped = AuditContext::skipped(AuditContext::OP_REGEX, 'reason');
|
||||
|
||||
$this->assertTrue($success->isSuccess());
|
||||
$this->assertTrue($recovered->isSuccess());
|
||||
$this->assertFalse($failed->isSuccess());
|
||||
$this->assertFalse($skipped->isSuccess());
|
||||
}
|
||||
|
||||
public function testToArray(): void
|
||||
{
|
||||
$context = AuditContext::success(
|
||||
AuditContext::OP_REGEX,
|
||||
15.123456,
|
||||
['key' => 'value']
|
||||
);
|
||||
|
||||
$array = $context->toArray();
|
||||
|
||||
$this->assertArrayHasKey('operation_type', $array);
|
||||
$this->assertArrayHasKey('status', $array);
|
||||
$this->assertArrayHasKey('attempt_number', $array);
|
||||
$this->assertArrayHasKey('duration_ms', $array);
|
||||
$this->assertArrayHasKey('metadata', $array);
|
||||
$this->assertSame(15.123, $array['duration_ms']);
|
||||
}
|
||||
|
||||
public function testToArrayWithError(): void
|
||||
{
|
||||
$error = ErrorContext::create('TestError', 'Message');
|
||||
$context = AuditContext::failed(AuditContext::OP_REGEX, $error);
|
||||
|
||||
$array = $context->toArray();
|
||||
|
||||
$this->assertArrayHasKey('error', $array);
|
||||
$this->assertIsArray($array['error']);
|
||||
}
|
||||
|
||||
public function testToArrayWithCorrelationId(): void
|
||||
{
|
||||
$context = AuditContext::success(AuditContext::OP_REGEX)
|
||||
->withCorrelationId('test-id');
|
||||
|
||||
$array = $context->toArray();
|
||||
|
||||
$this->assertArrayHasKey('correlation_id', $array);
|
||||
$this->assertSame('test-id', $array['correlation_id']);
|
||||
}
|
||||
|
||||
public function testGenerateCorrelationId(): void
|
||||
{
|
||||
$id1 = AuditContext::generateCorrelationId();
|
||||
$id2 = AuditContext::generateCorrelationId();
|
||||
|
||||
$this->assertIsString($id1);
|
||||
$this->assertSame(16, strlen($id1));
|
||||
$this->assertNotSame($id1, $id2);
|
||||
}
|
||||
|
||||
public function testOperationTypeConstants(): void
|
||||
{
|
||||
$this->assertSame('regex', AuditContext::OP_REGEX);
|
||||
$this->assertSame('field_path', AuditContext::OP_FIELD_PATH);
|
||||
$this->assertSame('callback', AuditContext::OP_CALLBACK);
|
||||
$this->assertSame('data_type', AuditContext::OP_DATA_TYPE);
|
||||
$this->assertSame('json', AuditContext::OP_JSON);
|
||||
$this->assertSame('conditional', AuditContext::OP_CONDITIONAL);
|
||||
}
|
||||
|
||||
public function testStatusConstants(): void
|
||||
{
|
||||
$this->assertSame('success', AuditContext::STATUS_SUCCESS);
|
||||
$this->assertSame('failed', AuditContext::STATUS_FAILED);
|
||||
$this->assertSame('recovered', AuditContext::STATUS_RECOVERED);
|
||||
$this->assertSame('skipped', AuditContext::STATUS_SKIPPED);
|
||||
}
|
||||
}
|
||||
194
tests/Audit/ErrorContextTest.php
Normal file
194
tests/Audit/ErrorContextTest.php
Normal file
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Audit;
|
||||
|
||||
use Exception;
|
||||
use InvalidArgumentException;
|
||||
use Ivuorinen\MonologGdprFilter\Audit\ErrorContext;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
use Tests\TestConstants;
|
||||
|
||||
/**
|
||||
* Tests for ErrorContext value object.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class ErrorContextTest extends TestCase
|
||||
{
|
||||
public function testBasicConstruction(): void
|
||||
{
|
||||
$context = new ErrorContext(
|
||||
errorType: 'TestError',
|
||||
message: 'Something went wrong',
|
||||
code: 42,
|
||||
file: '/path/to/file.php',
|
||||
line: 123,
|
||||
metadata: ['key' => 'value']
|
||||
);
|
||||
|
||||
$this->assertSame('TestError', $context->errorType);
|
||||
$this->assertSame('Something went wrong', $context->message);
|
||||
$this->assertSame(42, $context->code);
|
||||
$this->assertSame('/path/to/file.php', $context->file);
|
||||
$this->assertSame(123, $context->line);
|
||||
$this->assertSame(['key' => 'value'], $context->metadata);
|
||||
}
|
||||
|
||||
public function testFromThrowable(): void
|
||||
{
|
||||
$exception = new RuntimeException('Test exception', 500);
|
||||
$context = ErrorContext::fromThrowable($exception);
|
||||
|
||||
$this->assertSame(RuntimeException::class, $context->errorType);
|
||||
$this->assertSame('Test exception', $context->message);
|
||||
$this->assertSame(500, $context->code);
|
||||
$this->assertNull($context->file);
|
||||
$this->assertNull($context->line);
|
||||
}
|
||||
|
||||
public function testFromThrowableWithSensitiveDetails(): void
|
||||
{
|
||||
$exception = new Exception('Error at /home/user/app');
|
||||
$context = ErrorContext::fromThrowable($exception, includeSensitive: true);
|
||||
|
||||
$this->assertNotNull($context->file);
|
||||
$this->assertNotNull($context->line);
|
||||
$this->assertArrayHasKey('trace', $context->metadata);
|
||||
}
|
||||
|
||||
public function testCreate(): void
|
||||
{
|
||||
$context = ErrorContext::create(
|
||||
'CustomError',
|
||||
'Error message',
|
||||
['detail' => 'info']
|
||||
);
|
||||
|
||||
$this->assertSame('CustomError', $context->errorType);
|
||||
$this->assertSame('Error message', $context->message);
|
||||
$this->assertSame(0, $context->code);
|
||||
$this->assertArrayHasKey('detail', $context->metadata);
|
||||
}
|
||||
|
||||
public function testSanitizesPasswordsInMessage(): void
|
||||
{
|
||||
$message = 'Connection failed: password=secret123';
|
||||
$context = ErrorContext::create('DbError', $message);
|
||||
|
||||
$this->assertStringNotContainsString('secret123', $context->message);
|
||||
$this->assertStringContainsString(TestConstants::MASK_REDACTED_BRACKETS, $context->message);
|
||||
}
|
||||
|
||||
public function testSanitizesApiKeysInMessage(): void
|
||||
{
|
||||
$message = 'Auth failed: api_key=sk_live_1234567890';
|
||||
$context = ErrorContext::create('ApiError', $message);
|
||||
|
||||
$this->assertStringNotContainsString('sk_live_1234567890', $context->message);
|
||||
$this->assertStringContainsString(TestConstants::MASK_REDACTED_BRACKETS, $context->message);
|
||||
}
|
||||
|
||||
public function testSanitizesTokensInMessage(): void
|
||||
{
|
||||
$message = 'Auth failed with bearer abc123def456';
|
||||
$context = ErrorContext::create('AuthError', $message);
|
||||
|
||||
$this->assertStringNotContainsString('abc123def456', $context->message);
|
||||
$this->assertStringContainsString(TestConstants::MASK_REDACTED_BRACKETS, $context->message);
|
||||
}
|
||||
|
||||
public function testSanitizesTokenValueInMessage(): void
|
||||
{
|
||||
$message = 'Invalid token=secret_value_here';
|
||||
$context = ErrorContext::create('AuthError', $message);
|
||||
|
||||
$this->assertStringNotContainsString('secret_value_here', $context->message);
|
||||
$this->assertStringContainsString(TestConstants::MASK_REDACTED_BRACKETS, $context->message);
|
||||
}
|
||||
|
||||
public function testSanitizesConnectionStrings(): void
|
||||
{
|
||||
$message = 'Failed: redis://admin:password@localhost:6379';
|
||||
$context = ErrorContext::create('ConnError', $message);
|
||||
|
||||
$this->assertStringNotContainsString('password', $context->message);
|
||||
$this->assertStringContainsString(TestConstants::MASK_REDACTED_BRACKETS, $context->message);
|
||||
}
|
||||
|
||||
public function testSanitizesUserCredentials(): void
|
||||
{
|
||||
$message = 'DB error: user=admin host=secret.internal.com';
|
||||
$context = ErrorContext::create('DbError', $message);
|
||||
|
||||
$this->assertStringNotContainsString('admin', $context->message);
|
||||
$this->assertStringNotContainsString('secret.internal.com', $context->message);
|
||||
$this->assertStringContainsString(TestConstants::MASK_REDACTED_BRACKETS, $context->message);
|
||||
}
|
||||
|
||||
public function testSanitizesFilePaths(): void
|
||||
{
|
||||
$message = 'Cannot read /var/www/secret-app/config/credentials.php';
|
||||
$context = ErrorContext::create('FileError', $message);
|
||||
|
||||
$this->assertStringNotContainsString('/var/www/secret-app', $context->message);
|
||||
$this->assertStringContainsString('[PATH_REDACTED]', $context->message);
|
||||
}
|
||||
|
||||
public function testToArray(): void
|
||||
{
|
||||
$context = new ErrorContext(
|
||||
errorType: 'TestError',
|
||||
message: 'Test message',
|
||||
code: 100,
|
||||
file: '/test/file.php',
|
||||
line: 50,
|
||||
metadata: ['key' => 'value']
|
||||
);
|
||||
|
||||
$array = $context->toArray();
|
||||
|
||||
$this->assertArrayHasKey('error_type', $array);
|
||||
$this->assertArrayHasKey('message', $array);
|
||||
$this->assertArrayHasKey('code', $array);
|
||||
$this->assertArrayHasKey('file', $array);
|
||||
$this->assertArrayHasKey('line', $array);
|
||||
$this->assertArrayHasKey('metadata', $array);
|
||||
|
||||
$this->assertSame('TestError', $array['error_type']);
|
||||
$this->assertSame('Test message', $array['message']);
|
||||
$this->assertSame(100, $array['code']);
|
||||
}
|
||||
|
||||
public function testToArrayOmitsNullValues(): void
|
||||
{
|
||||
$context = ErrorContext::create('Error', 'Message');
|
||||
|
||||
$array = $context->toArray();
|
||||
|
||||
$this->assertArrayNotHasKey('file', $array);
|
||||
$this->assertArrayNotHasKey('line', $array);
|
||||
}
|
||||
|
||||
public function testToArrayOmitsEmptyMetadata(): void
|
||||
{
|
||||
$context = ErrorContext::create('Error', 'Message', []);
|
||||
|
||||
$array = $context->toArray();
|
||||
|
||||
$this->assertArrayNotHasKey('metadata', $array);
|
||||
}
|
||||
|
||||
public function testFromThrowableWithNestedException(): void
|
||||
{
|
||||
$inner = new InvalidArgumentException('Inner error');
|
||||
$outer = new RuntimeException('Outer error', 0, $inner);
|
||||
|
||||
$context = ErrorContext::fromThrowable($outer);
|
||||
|
||||
$this->assertSame(RuntimeException::class, $context->errorType);
|
||||
$this->assertStringContainsString('Outer error', $context->message);
|
||||
}
|
||||
}
|
||||
207
tests/Audit/StructuredAuditLoggerTest.php
Normal file
207
tests/Audit/StructuredAuditLoggerTest.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Audit;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Audit\AuditContext;
|
||||
use Ivuorinen\MonologGdprFilter\Audit\ErrorContext;
|
||||
use Ivuorinen\MonologGdprFilter\Audit\StructuredAuditLogger;
|
||||
use Ivuorinen\MonologGdprFilter\RateLimitedAuditLogger;
|
||||
use Ivuorinen\MonologGdprFilter\RateLimiter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tests\TestConstants;
|
||||
|
||||
/**
|
||||
* Tests for StructuredAuditLogger.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class StructuredAuditLoggerTest extends TestCase
|
||||
{
|
||||
/** @var array<array{path: string, original: mixed, masked: mixed}> */
|
||||
private array $logs;
|
||||
|
||||
#[\Override]
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->logs = [];
|
||||
RateLimiter::clearAll();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function tearDown(): void
|
||||
{
|
||||
RateLimiter::clearAll();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function createBaseLogger(): callable
|
||||
{
|
||||
return function (string $path, mixed $original, mixed $masked): void {
|
||||
$this->logs[] = [
|
||||
'path' => $path,
|
||||
'original' => $original,
|
||||
'masked' => $masked
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
public function testBasicLogging(): void
|
||||
{
|
||||
$logger = new StructuredAuditLogger($this->createBaseLogger());
|
||||
|
||||
$logger->log('user.email', TestConstants::EMAIL_JOHN, TestConstants::MASK_MASKED_BRACKETS);
|
||||
|
||||
$this->assertCount(1, $this->logs);
|
||||
$this->assertSame('user.email', $this->logs[0]['path']);
|
||||
$this->assertSame(TestConstants::EMAIL_JOHN, $this->logs[0]['original']);
|
||||
$this->assertSame(TestConstants::MASK_MASKED_BRACKETS, $this->logs[0]['masked']);
|
||||
}
|
||||
|
||||
public function testLogWithContext(): void
|
||||
{
|
||||
$logger = new StructuredAuditLogger($this->createBaseLogger());
|
||||
$context = AuditContext::success(AuditContext::OP_REGEX, 5.0);
|
||||
|
||||
$logger->log('user.email', TestConstants::EMAIL_JOHN, TestConstants::MASK_MASKED_BRACKETS, $context);
|
||||
|
||||
$this->assertCount(1, $this->logs);
|
||||
}
|
||||
|
||||
public function testLogSuccess(): void
|
||||
{
|
||||
$logger = new StructuredAuditLogger($this->createBaseLogger());
|
||||
|
||||
$logger->logSuccess(
|
||||
'user.ssn',
|
||||
'123-45-6789',
|
||||
'[SSN]',
|
||||
AuditContext::OP_REGEX,
|
||||
10.5
|
||||
);
|
||||
|
||||
$this->assertCount(1, $this->logs);
|
||||
$this->assertSame('user.ssn', $this->logs[0]['path']);
|
||||
}
|
||||
|
||||
public function testLogFailure(): void
|
||||
{
|
||||
$logger = new StructuredAuditLogger($this->createBaseLogger());
|
||||
$error = ErrorContext::create('RegexError', 'Pattern failed');
|
||||
|
||||
$logger->logFailure(
|
||||
'user.data',
|
||||
'sensitive value',
|
||||
AuditContext::OP_REGEX,
|
||||
$error
|
||||
);
|
||||
|
||||
$this->assertCount(1, $this->logs);
|
||||
$this->assertSame('[MASKING_FAILED]', $this->logs[0]['masked']);
|
||||
}
|
||||
|
||||
public function testLogRecovery(): void
|
||||
{
|
||||
$logger = new StructuredAuditLogger($this->createBaseLogger());
|
||||
|
||||
$logger->logRecovery(
|
||||
'user.email',
|
||||
TestConstants::EMAIL_JOHN,
|
||||
TestConstants::MASK_MASKED_BRACKETS,
|
||||
AuditContext::OP_REGEX,
|
||||
2,
|
||||
25.0
|
||||
);
|
||||
|
||||
$this->assertCount(1, $this->logs);
|
||||
}
|
||||
|
||||
public function testLogSkipped(): void
|
||||
{
|
||||
$logger = new StructuredAuditLogger($this->createBaseLogger());
|
||||
|
||||
$logger->logSkipped(
|
||||
'user.public_name',
|
||||
TestConstants::NAME_FULL,
|
||||
AuditContext::OP_CONDITIONAL,
|
||||
'Field not in mask list'
|
||||
);
|
||||
|
||||
$this->assertCount(1, $this->logs);
|
||||
$this->assertSame(TestConstants::NAME_FULL, $this->logs[0]['original']);
|
||||
$this->assertSame(TestConstants::NAME_FULL, $this->logs[0]['masked']);
|
||||
}
|
||||
|
||||
public function testWrapStaticFactory(): void
|
||||
{
|
||||
$logger = StructuredAuditLogger::wrap($this->createBaseLogger());
|
||||
|
||||
$logger->log('test.path', 'original', 'masked');
|
||||
|
||||
$this->assertCount(1, $this->logs);
|
||||
}
|
||||
|
||||
public function testWithRateLimitedLogger(): void
|
||||
{
|
||||
$rateLimited = new RateLimitedAuditLogger(
|
||||
$this->createBaseLogger(),
|
||||
100,
|
||||
60
|
||||
);
|
||||
$logger = new StructuredAuditLogger($rateLimited);
|
||||
|
||||
$logger->log('user.email', TestConstants::EMAIL_JOHN, TestConstants::MASK_MASKED_BRACKETS);
|
||||
|
||||
$this->assertCount(1, $this->logs);
|
||||
}
|
||||
|
||||
public function testTimerMethods(): void
|
||||
{
|
||||
$logger = new StructuredAuditLogger($this->createBaseLogger());
|
||||
|
||||
$start = $logger->startTimer();
|
||||
usleep(10000);
|
||||
$elapsed = $logger->elapsed($start);
|
||||
|
||||
$this->assertGreaterThan(0, $elapsed);
|
||||
$this->assertLessThan(100, $elapsed);
|
||||
}
|
||||
|
||||
public function testGetWrappedLogger(): void
|
||||
{
|
||||
$baseLogger = $this->createBaseLogger();
|
||||
$logger = new StructuredAuditLogger($baseLogger);
|
||||
|
||||
$wrapped = $logger->getWrappedLogger();
|
||||
|
||||
// Verify the wrapped logger works by calling it
|
||||
$wrapped('test.path', 'original', 'masked');
|
||||
$this->assertCount(1, $this->logs);
|
||||
}
|
||||
|
||||
public function testDisableTimestamp(): void
|
||||
{
|
||||
$logger = new StructuredAuditLogger(
|
||||
$this->createBaseLogger(),
|
||||
includeTimestamp: false
|
||||
);
|
||||
|
||||
$logger->log('test', 'original', 'masked');
|
||||
|
||||
$this->assertCount(1, $this->logs);
|
||||
}
|
||||
|
||||
public function testDisableDuration(): void
|
||||
{
|
||||
$logger = new StructuredAuditLogger(
|
||||
$this->createBaseLogger(),
|
||||
includeDuration: false
|
||||
);
|
||||
|
||||
$logger->log('test', 'original', 'masked');
|
||||
|
||||
$this->assertCount(1, $this->logs);
|
||||
}
|
||||
}
|
||||
427
tests/Builder/GdprProcessorBuilderEdgeCasesTest.php
Normal file
427
tests/Builder/GdprProcessorBuilderEdgeCasesTest.php
Normal file
@@ -0,0 +1,427 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Builder;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Ivuorinen\MonologGdprFilter\ArrayAccessor\ArrayAccessorFactory;
|
||||
use Ivuorinen\MonologGdprFilter\Builder\GdprProcessorBuilder;
|
||||
use Ivuorinen\MonologGdprFilter\Builder\PluginAwareProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants;
|
||||
use Ivuorinen\MonologGdprFilter\Plugins\AbstractMaskingPlugin;
|
||||
use Monolog\Level;
|
||||
use Monolog\LogRecord;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tests\TestConstants;
|
||||
|
||||
/**
|
||||
* Edge case tests for GdprProcessorBuilder.
|
||||
*/
|
||||
#[CoversClass(GdprProcessorBuilder::class)]
|
||||
#[CoversClass(PluginAwareProcessor::class)]
|
||||
final class GdprProcessorBuilderEdgeCasesTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function createLogRecord(string $message = 'Test', array $context = []): LogRecord
|
||||
{
|
||||
return new LogRecord(
|
||||
datetime: new DateTimeImmutable(),
|
||||
channel: 'test',
|
||||
level: Level::Info,
|
||||
message: $message,
|
||||
context: $context,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function setPatternsReplacesExisting(): void
|
||||
{
|
||||
$builder = GdprProcessorBuilder::create()
|
||||
->addPattern('/old/', 'OLD_MASK')
|
||||
->setPatterns(['/new/' => 'NEW_MASK']);
|
||||
|
||||
$patterns = $builder->getPatterns();
|
||||
|
||||
$this->assertArrayNotHasKey('/old/', $patterns);
|
||||
$this->assertArrayHasKey('/new/', $patterns);
|
||||
$this->assertSame('NEW_MASK', $patterns['/new/']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function setFieldPathsReplacesExisting(): void
|
||||
{
|
||||
$builder = GdprProcessorBuilder::create()
|
||||
->addFieldPath('old.path', '[OLD]')
|
||||
->setFieldPaths(['new.path' => '[NEW]']);
|
||||
|
||||
$paths = $builder->getFieldPaths();
|
||||
|
||||
$this->assertArrayNotHasKey('old.path', $paths);
|
||||
$this->assertArrayHasKey('new.path', $paths);
|
||||
$this->assertSame('[NEW]', $paths['new.path']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function addCallbacksAddsMultipleCallbacks(): void
|
||||
{
|
||||
$callback1 = fn(mixed $value): string => 'CALLBACK1';
|
||||
$callback2 = fn(mixed $value): string => 'CALLBACK2';
|
||||
|
||||
$builder = GdprProcessorBuilder::create()
|
||||
->addCallbacks([
|
||||
'path.one' => $callback1,
|
||||
'path.two' => $callback2,
|
||||
]);
|
||||
|
||||
$processor = $builder->build();
|
||||
|
||||
$record = $this->createLogRecord('Test', [
|
||||
'path' => [
|
||||
'one' => 'value1',
|
||||
'two' => 'value2',
|
||||
],
|
||||
]);
|
||||
|
||||
$processed = $processor($record);
|
||||
|
||||
$this->assertSame('CALLBACK1', $processed->context['path']['one']);
|
||||
$this->assertSame('CALLBACK2', $processed->context['path']['two']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function addDataTypeMasksAddsMultipleMasks(): void
|
||||
{
|
||||
$builder = GdprProcessorBuilder::create()
|
||||
->addDataTypeMasks([
|
||||
'integer' => '999',
|
||||
'boolean' => 'false',
|
||||
]);
|
||||
|
||||
$processor = $builder->build();
|
||||
|
||||
$record = $this->createLogRecord('Test', [
|
||||
'count' => 42,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$processed = $processor($record);
|
||||
|
||||
$this->assertSame(999, $processed->context['count']);
|
||||
$this->assertFalse($processed->context['active']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function addConditionalRulesAddsMultipleRules(): void
|
||||
{
|
||||
$rule1Called = false;
|
||||
$rule2Called = false;
|
||||
|
||||
$builder = GdprProcessorBuilder::create()
|
||||
->addPattern('/sensitive/', TestConstants::MASK_MASKED_BRACKETS)
|
||||
->addConditionalRules([
|
||||
'rule1' => function (LogRecord $record) use (&$rule1Called): bool {
|
||||
$rule1Called = true;
|
||||
return $record->channel === 'test';
|
||||
},
|
||||
'rule2' => function () use (&$rule2Called): bool {
|
||||
$rule2Called = true;
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$processor = $builder->build();
|
||||
|
||||
$record = $this->createLogRecord('Contains sensitive data');
|
||||
$processed = $processor($record);
|
||||
|
||||
$this->assertTrue($rule1Called);
|
||||
$this->assertTrue($rule2Called);
|
||||
$this->assertStringContainsString(TestConstants::MASK_MASKED_BRACKETS, $processed->message);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getPluginsReturnsRegisteredPlugins(): void
|
||||
{
|
||||
$plugin1 = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'plugin-1';
|
||||
}
|
||||
};
|
||||
|
||||
$plugin2 = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'plugin-2';
|
||||
}
|
||||
};
|
||||
|
||||
$builder = GdprProcessorBuilder::create()
|
||||
->addPlugin($plugin1)
|
||||
->addPlugin($plugin2);
|
||||
|
||||
$plugins = $builder->getPlugins();
|
||||
|
||||
$this->assertCount(2, $plugins);
|
||||
$this->assertSame('plugin-1', $plugins[0]->getName());
|
||||
$this->assertSame('plugin-2', $plugins[1]->getName());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function addPluginsAddsMultiplePlugins(): void
|
||||
{
|
||||
$plugin1 = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'plugin-1';
|
||||
}
|
||||
};
|
||||
|
||||
$plugin2 = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'plugin-2';
|
||||
}
|
||||
};
|
||||
|
||||
$builder = GdprProcessorBuilder::create()
|
||||
->addPlugins([$plugin1, $plugin2]);
|
||||
|
||||
$plugins = $builder->getPlugins();
|
||||
|
||||
$this->assertCount(2, $plugins);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function buildWithPluginsReturnsGdprProcessorWhenNoPlugins(): void
|
||||
{
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->withDefaultPatterns()
|
||||
->buildWithPlugins();
|
||||
|
||||
// buildWithPlugins returns GdprProcessor when no plugins are registered
|
||||
// We can't use assertNotInstanceOf due to PHPStan's static analysis
|
||||
// Instead we verify the actual return type
|
||||
$this->assertSame(GdprProcessor::class, $processor::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function buildWithPluginsReturnsPluginAwareProcessorWithPlugins(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPlugin($plugin)
|
||||
->buildWithPlugins();
|
||||
|
||||
$this->assertInstanceOf(PluginAwareProcessor::class, $processor);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function buildWithPluginsSortsPluginsByPriority(): void
|
||||
{
|
||||
$lowPriority = new class (200) extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'low-priority';
|
||||
}
|
||||
};
|
||||
|
||||
$highPriority = new class (10) extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'high-priority';
|
||||
}
|
||||
};
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPlugin($lowPriority)
|
||||
->addPlugin($highPriority)
|
||||
->buildWithPlugins();
|
||||
|
||||
$this->assertInstanceOf(PluginAwareProcessor::class, $processor);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function pluginContributesPatterns(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'pattern-plugin';
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getPatterns(): array
|
||||
{
|
||||
return ['/PLUGIN-\d+/' => '[PLUGIN-ID]'];
|
||||
}
|
||||
};
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPlugin($plugin)
|
||||
->buildWithPlugins();
|
||||
|
||||
$record = $this->createLogRecord('Reference: PLUGIN-12345');
|
||||
$processed = $processor($record);
|
||||
|
||||
$this->assertSame('Reference: [PLUGIN-ID]', $processed->message);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function pluginContributesFieldPaths(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'field-plugin';
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getFieldPaths(): array
|
||||
{
|
||||
return ['secret.key' => FieldMaskConfig::replace('[REDACTED]')];
|
||||
}
|
||||
};
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPlugin($plugin)
|
||||
->buildWithPlugins();
|
||||
|
||||
$record = $this->createLogRecord('Test', ['secret' => ['key' => 'sensitive-value']]);
|
||||
$processed = $processor($record);
|
||||
|
||||
$this->assertSame('[REDACTED]', $processed->context['secret']['key']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function withArrayAccessorFactoryConfiguresProcessor(): void
|
||||
{
|
||||
$factory = ArrayAccessorFactory::default();
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->withArrayAccessorFactory($factory)
|
||||
->addFieldPath('user.email', MaskConstants::MASK_EMAIL)
|
||||
->build();
|
||||
|
||||
$record = $this->createLogRecord('Test', [
|
||||
'user' => ['email' => 'test@example.com'],
|
||||
]);
|
||||
|
||||
$processed = $processor($record);
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_EMAIL, $processed->context['user']['email']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function withMaxDepthLimitsRecursion(): void
|
||||
{
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->withMaxDepth(2)
|
||||
->addPattern('/secret/', TestConstants::MASK_MASKED_BRACKETS)
|
||||
->build();
|
||||
|
||||
$record = $this->createLogRecord('Test', [
|
||||
'level1' => [
|
||||
'level2' => [
|
||||
'level3' => 'secret data',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$processed = $processor($record);
|
||||
|
||||
// Processor should handle the record without throwing
|
||||
$this->assertIsArray($processed->context);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function withAuditLoggerConfiguresLogging(): void
|
||||
{
|
||||
$auditLogs = [];
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addFieldPath('password', MaskConstants::MASK_REDACTED)
|
||||
->withAuditLogger(function ($path, $original, $masked) use (&$auditLogs): void {
|
||||
$auditLogs[] = ['path' => $path, 'original' => $original, 'masked' => $masked];
|
||||
})
|
||||
->build();
|
||||
|
||||
$record = $this->createLogRecord('Test', ['password' => 'secret123']);
|
||||
$processor($record);
|
||||
|
||||
$this->assertNotEmpty($auditLogs);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function addConditionalRuleConfiguresProcessor(): void
|
||||
{
|
||||
$ruleExecuted = false;
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPattern('/data/', TestConstants::MASK_MASKED_BRACKETS)
|
||||
->addConditionalRule('track-execution', function () use (&$ruleExecuted): bool {
|
||||
$ruleExecuted = true;
|
||||
return true;
|
||||
})
|
||||
->build();
|
||||
|
||||
$record = $this->createLogRecord('Contains data value');
|
||||
$processor($record);
|
||||
|
||||
// Verify the conditional rule was executed
|
||||
$this->assertTrue($ruleExecuted);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function addFieldPathsAddsMultiplePaths(): void
|
||||
{
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addFieldPaths([
|
||||
'user.email' => MaskConstants::MASK_EMAIL,
|
||||
'user.phone' => MaskConstants::MASK_PHONE,
|
||||
])
|
||||
->build();
|
||||
|
||||
$record = $this->createLogRecord('Test', [
|
||||
'user' => [
|
||||
'email' => 'test@example.com',
|
||||
'phone' => '555-1234',
|
||||
],
|
||||
]);
|
||||
|
||||
$processed = $processor($record);
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_EMAIL, $processed->context['user']['email']);
|
||||
$this->assertSame(MaskConstants::MASK_PHONE, $processed->context['user']['phone']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function addPatternsAddsMultiplePatterns(): void
|
||||
{
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPatterns([
|
||||
'/\d{3}-\d{2}-\d{4}/' => '[SSN]',
|
||||
'/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/' => '[EMAIL]',
|
||||
])
|
||||
->build();
|
||||
|
||||
$record = $this->createLogRecord('SSN: 123-45-6789, Email: user@example.com');
|
||||
$processed = $processor($record);
|
||||
|
||||
$this->assertStringContainsString('[SSN]', $processed->message);
|
||||
$this->assertStringContainsString('[EMAIL]', $processed->message);
|
||||
}
|
||||
}
|
||||
365
tests/Builder/GdprProcessorBuilderTest.php
Normal file
365
tests/Builder/GdprProcessorBuilderTest.php
Normal file
@@ -0,0 +1,365 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Builder;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\ArrayAccessor\ArrayAccessorFactory;
|
||||
use Ivuorinen\MonologGdprFilter\Builder\GdprProcessorBuilder;
|
||||
use Ivuorinen\MonologGdprFilter\Builder\PluginAwareProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
|
||||
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants;
|
||||
use Ivuorinen\MonologGdprFilter\Plugins\AbstractMaskingPlugin;
|
||||
use Monolog\Level;
|
||||
use Monolog\LogRecord;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tests\TestConstants;
|
||||
|
||||
#[CoversClass(GdprProcessorBuilder::class)]
|
||||
final class GdprProcessorBuilderTest extends TestCase
|
||||
{
|
||||
public function testCreateReturnsBuilder(): void
|
||||
{
|
||||
$builder = GdprProcessorBuilder::create();
|
||||
|
||||
$this->assertInstanceOf(GdprProcessorBuilder::class, $builder);
|
||||
}
|
||||
|
||||
public function testBuildReturnsGdprProcessor(): void
|
||||
{
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPattern(TestConstants::PATTERN_TEST, MaskConstants::MASK_GENERIC)
|
||||
->build();
|
||||
|
||||
$this->assertInstanceOf(GdprProcessor::class, $processor);
|
||||
}
|
||||
|
||||
public function testWithDefaultPatterns(): void
|
||||
{
|
||||
$builder = GdprProcessorBuilder::create()->withDefaultPatterns();
|
||||
$patterns = $builder->getPatterns();
|
||||
|
||||
$this->assertNotEmpty($patterns);
|
||||
$this->assertSame(DefaultPatterns::get(), $patterns);
|
||||
}
|
||||
|
||||
public function testAddPattern(): void
|
||||
{
|
||||
$builder = GdprProcessorBuilder::create()
|
||||
->addPattern(TestConstants::PATTERN_DIGITS, TestConstants::MASK_DIGITS_BRACKETS);
|
||||
|
||||
$this->assertArrayHasKey(TestConstants::PATTERN_DIGITS, $builder->getPatterns());
|
||||
}
|
||||
|
||||
public function testAddPatterns(): void
|
||||
{
|
||||
$patterns = [
|
||||
TestConstants::PATTERN_DIGITS => TestConstants::MASK_DIGITS_BRACKETS,
|
||||
TestConstants::PATTERN_TEST => '[TEST]',
|
||||
];
|
||||
|
||||
$builder = GdprProcessorBuilder::create()->addPatterns($patterns);
|
||||
|
||||
$this->assertSame($patterns, $builder->getPatterns());
|
||||
}
|
||||
|
||||
public function testSetPatternsReplacesExisting(): void
|
||||
{
|
||||
$builder = GdprProcessorBuilder::create()
|
||||
->addPattern(TestConstants::PATTERN_DIGITS, TestConstants::MASK_DIGITS_BRACKETS)
|
||||
->setPatterns([TestConstants::PATTERN_TEST => '[TEST]']);
|
||||
|
||||
$patterns = $builder->getPatterns();
|
||||
|
||||
$this->assertCount(1, $patterns);
|
||||
$this->assertArrayHasKey(TestConstants::PATTERN_TEST, $patterns);
|
||||
$this->assertArrayNotHasKey(TestConstants::PATTERN_DIGITS, $patterns);
|
||||
}
|
||||
|
||||
public function testAddFieldPath(): void
|
||||
{
|
||||
$builder = GdprProcessorBuilder::create()
|
||||
->addFieldPath(TestConstants::CONTEXT_EMAIL, FieldMaskConfig::replace('[EMAIL]'));
|
||||
|
||||
$this->assertArrayHasKey(TestConstants::CONTEXT_EMAIL, $builder->getFieldPaths());
|
||||
}
|
||||
|
||||
public function testAddFieldPaths(): void
|
||||
{
|
||||
$fieldPaths = [
|
||||
TestConstants::CONTEXT_EMAIL => FieldMaskConfig::replace('[EMAIL]'),
|
||||
TestConstants::CONTEXT_PASSWORD => FieldMaskConfig::remove(),
|
||||
];
|
||||
|
||||
$builder = GdprProcessorBuilder::create()->addFieldPaths($fieldPaths);
|
||||
|
||||
$this->assertCount(2, $builder->getFieldPaths());
|
||||
}
|
||||
|
||||
public function testAddCallback(): void
|
||||
{
|
||||
$callback = fn(mixed $val): string => strtoupper((string) $val);
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addCallback('name', $callback)
|
||||
->build();
|
||||
|
||||
$record = new LogRecord(
|
||||
datetime: new \DateTimeImmutable(),
|
||||
channel: TestConstants::CHANNEL_TEST,
|
||||
level: Level::Info,
|
||||
message: TestConstants::MESSAGE_DEFAULT,
|
||||
context: ['name' => 'john']
|
||||
);
|
||||
|
||||
$result = $processor($record);
|
||||
|
||||
$this->assertSame('JOHN', $result->context['name']);
|
||||
}
|
||||
|
||||
public function testWithAuditLogger(): void
|
||||
{
|
||||
$logs = [];
|
||||
$auditLogger = function (string $path, mixed $original, mixed $masked) use (&$logs): void {
|
||||
$logs[] = ['path' => $path, 'original' => $original, TestConstants::DATA_MASKED => $masked];
|
||||
};
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addFieldPath('field', FieldMaskConfig::replace('[MASKED]'))
|
||||
->withAuditLogger($auditLogger)
|
||||
->build();
|
||||
|
||||
$record = new LogRecord(
|
||||
datetime: new \DateTimeImmutable(),
|
||||
channel: TestConstants::CHANNEL_TEST,
|
||||
level: Level::Info,
|
||||
message: TestConstants::MESSAGE_DEFAULT,
|
||||
context: ['field' => 'value']
|
||||
);
|
||||
|
||||
$processor($record);
|
||||
|
||||
$this->assertCount(1, $logs);
|
||||
}
|
||||
|
||||
public function testWithMaxDepth(): void
|
||||
{
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPattern(TestConstants::PATTERN_TEST, MaskConstants::MASK_GENERIC)
|
||||
->withMaxDepth(50)
|
||||
->build();
|
||||
|
||||
// The processor should still work
|
||||
$result = $processor->regExpMessage(TestConstants::MESSAGE_TEST_LOWERCASE);
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_GENERIC . ' message', $result);
|
||||
}
|
||||
|
||||
public function testAddDataTypeMask(): void
|
||||
{
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addDataTypeMask('integer', TestConstants::MASK_INT_BRACKETS)
|
||||
->build();
|
||||
|
||||
$record = new LogRecord(
|
||||
datetime: new \DateTimeImmutable(),
|
||||
channel: TestConstants::CHANNEL_TEST,
|
||||
level: Level::Info,
|
||||
message: TestConstants::MESSAGE_DEFAULT,
|
||||
context: ['count' => 42]
|
||||
);
|
||||
|
||||
$result = $processor($record);
|
||||
|
||||
$this->assertSame(TestConstants::MASK_INT_BRACKETS, $result->context['count']);
|
||||
}
|
||||
|
||||
public function testAddConditionalRule(): void
|
||||
{
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPattern(TestConstants::PATTERN_TEST, MaskConstants::MASK_GENERIC)
|
||||
->addConditionalRule('skip_debug', fn(LogRecord $r): bool => $r->level !== Level::Debug)
|
||||
->build();
|
||||
|
||||
$debugRecord = new LogRecord(
|
||||
datetime: new \DateTimeImmutable(),
|
||||
channel: TestConstants::CHANNEL_TEST,
|
||||
level: Level::Debug,
|
||||
message: TestConstants::MESSAGE_TEST_LOWERCASE,
|
||||
context: []
|
||||
);
|
||||
|
||||
$infoRecord = new LogRecord(
|
||||
datetime: new \DateTimeImmutable(),
|
||||
channel: TestConstants::CHANNEL_TEST,
|
||||
level: Level::Info,
|
||||
message: TestConstants::MESSAGE_TEST_LOWERCASE,
|
||||
context: []
|
||||
);
|
||||
|
||||
// Debug should not be masked
|
||||
$this->assertSame(TestConstants::MESSAGE_TEST_LOWERCASE, $processor($debugRecord)->message);
|
||||
|
||||
// Info should be masked
|
||||
$this->assertSame(MaskConstants::MASK_GENERIC . ' message', $processor($infoRecord)->message);
|
||||
}
|
||||
|
||||
public function testWithArrayAccessorFactory(): void
|
||||
{
|
||||
$factory = ArrayAccessorFactory::default();
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPattern(TestConstants::PATTERN_TEST, MaskConstants::MASK_GENERIC)
|
||||
->withArrayAccessorFactory($factory)
|
||||
->build();
|
||||
|
||||
$this->assertInstanceOf(GdprProcessor::class, $processor);
|
||||
}
|
||||
|
||||
public function testAddPlugin(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
|
||||
public function getPatterns(): array
|
||||
{
|
||||
return ['/secret/' => '[SECRET]'];
|
||||
}
|
||||
};
|
||||
|
||||
$builder = GdprProcessorBuilder::create()->addPlugin($plugin);
|
||||
|
||||
$this->assertCount(1, $builder->getPlugins());
|
||||
}
|
||||
|
||||
public function testAddPlugins(): void
|
||||
{
|
||||
$plugin1 = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'plugin1';
|
||||
}
|
||||
};
|
||||
|
||||
$plugin2 = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'plugin2';
|
||||
}
|
||||
};
|
||||
|
||||
$builder = GdprProcessorBuilder::create()->addPlugins([$plugin1, $plugin2]);
|
||||
|
||||
$this->assertCount(2, $builder->getPlugins());
|
||||
}
|
||||
|
||||
public function testBuildWithPluginsReturnsGdprProcessorWhenNoPlugins(): void
|
||||
{
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPattern(TestConstants::PATTERN_TEST, MaskConstants::MASK_GENERIC)
|
||||
->buildWithPlugins();
|
||||
|
||||
$this->assertInstanceOf(GdprProcessor::class, $processor);
|
||||
// When no plugins, it returns GdprProcessor directly, not PluginAwareProcessor
|
||||
$this->assertSame(GdprProcessor::class, $processor::class);
|
||||
}
|
||||
|
||||
public function testBuildWithPluginsReturnsPluginAwareProcessor(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPattern(TestConstants::PATTERN_TEST, MaskConstants::MASK_GENERIC)
|
||||
->addPlugin($plugin)
|
||||
->buildWithPlugins();
|
||||
|
||||
$this->assertInstanceOf(PluginAwareProcessor::class, $processor);
|
||||
}
|
||||
|
||||
public function testPluginPatternsAreApplied(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'secret-plugin';
|
||||
}
|
||||
|
||||
public function getPatterns(): array
|
||||
{
|
||||
return ['/secret/' => '[SECRET]'];
|
||||
}
|
||||
};
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPlugin($plugin)
|
||||
->build();
|
||||
|
||||
$record = new LogRecord(
|
||||
datetime: new \DateTimeImmutable(),
|
||||
channel: TestConstants::CHANNEL_TEST,
|
||||
level: Level::Info,
|
||||
message: 'This is secret data',
|
||||
context: []
|
||||
);
|
||||
|
||||
$result = $processor($record);
|
||||
|
||||
$this->assertSame('This is [SECRET] data', $result->message);
|
||||
}
|
||||
|
||||
public function testPluginFieldPathsAreApplied(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'field-plugin';
|
||||
}
|
||||
|
||||
public function getFieldPaths(): array
|
||||
{
|
||||
return ['api_key' => FieldMaskConfig::replace('[API_KEY]')];
|
||||
}
|
||||
};
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPlugin($plugin)
|
||||
->build();
|
||||
|
||||
$record = new LogRecord(
|
||||
datetime: new \DateTimeImmutable(),
|
||||
channel: TestConstants::CHANNEL_TEST,
|
||||
level: Level::Info,
|
||||
message: TestConstants::MESSAGE_DEFAULT,
|
||||
context: ['api_key' => 'abc123']
|
||||
);
|
||||
|
||||
$result = $processor($record);
|
||||
|
||||
$this->assertSame('[API_KEY]', $result->context['api_key']);
|
||||
}
|
||||
|
||||
public function testFluentChaining(): void
|
||||
{
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->withDefaultPatterns()
|
||||
->addPattern('/custom/', '[CUSTOM]')
|
||||
->addFieldPath('secret', FieldMaskConfig::remove())
|
||||
->addCallback('name', fn(mixed $v): string => strtoupper((string) $v))
|
||||
->withMaxDepth(50)
|
||||
->addDataTypeMask('integer', TestConstants::MASK_INT_BRACKETS)
|
||||
->build();
|
||||
|
||||
$this->assertInstanceOf(GdprProcessor::class, $processor);
|
||||
}
|
||||
}
|
||||
370
tests/Builder/PluginAwareProcessorTest.php
Normal file
370
tests/Builder/PluginAwareProcessorTest.php
Normal file
@@ -0,0 +1,370 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Builder;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Builder\GdprProcessorBuilder;
|
||||
use Ivuorinen\MonologGdprFilter\Builder\PluginAwareProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\GdprProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants;
|
||||
use Ivuorinen\MonologGdprFilter\Plugins\AbstractMaskingPlugin;
|
||||
use Monolog\Level;
|
||||
use Monolog\LogRecord;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tests\TestConstants;
|
||||
|
||||
#[CoversClass(PluginAwareProcessor::class)]
|
||||
final class PluginAwareProcessorTest extends TestCase
|
||||
{
|
||||
public function testInvokeAppliesPreProcessing(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'uppercase-plugin';
|
||||
}
|
||||
|
||||
public function preProcessMessage(string $message): string
|
||||
{
|
||||
return strtoupper($message);
|
||||
}
|
||||
};
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPattern('/TEST/', '[MASKED]')
|
||||
->addPlugin($plugin)
|
||||
->buildWithPlugins();
|
||||
|
||||
$record = new LogRecord(
|
||||
datetime: new \DateTimeImmutable(),
|
||||
channel: TestConstants::CHANNEL_TEST,
|
||||
level: Level::Info,
|
||||
message: 'This is a test message',
|
||||
context: []
|
||||
);
|
||||
|
||||
$result = $processor($record);
|
||||
|
||||
// Message should be uppercased, then 'TEST' should be masked
|
||||
$this->assertStringContainsString('[MASKED]', $result->message);
|
||||
}
|
||||
|
||||
public function testInvokeAppliesPostProcessing(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'suffix-plugin';
|
||||
}
|
||||
|
||||
public function postProcessMessage(string $message): string
|
||||
{
|
||||
return $message . ' [processed]';
|
||||
}
|
||||
};
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPattern(TestConstants::PATTERN_TEST, MaskConstants::MASK_GENERIC)
|
||||
->addPlugin($plugin)
|
||||
->buildWithPlugins();
|
||||
|
||||
$record = new LogRecord(
|
||||
datetime: new \DateTimeImmutable(),
|
||||
channel: TestConstants::CHANNEL_TEST,
|
||||
level: Level::Info,
|
||||
message: 'test',
|
||||
context: []
|
||||
);
|
||||
|
||||
$result = $processor($record);
|
||||
|
||||
$this->assertStringEndsWith('[processed]', $result->message);
|
||||
}
|
||||
|
||||
public function testInvokeAppliesPreProcessContext(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'add-field-plugin';
|
||||
}
|
||||
|
||||
public function preProcessContext(array $context): array
|
||||
{
|
||||
$context['added_by_plugin'] = 'true';
|
||||
return $context;
|
||||
}
|
||||
};
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPlugin($plugin)
|
||||
->buildWithPlugins();
|
||||
|
||||
$record = new LogRecord(
|
||||
datetime: new \DateTimeImmutable(),
|
||||
channel: TestConstants::CHANNEL_TEST,
|
||||
level: Level::Info,
|
||||
message: TestConstants::MESSAGE_DEFAULT,
|
||||
context: ['original' => 'data']
|
||||
);
|
||||
|
||||
$result = $processor($record);
|
||||
|
||||
$this->assertArrayHasKey('added_by_plugin', $result->context);
|
||||
}
|
||||
|
||||
public function testInvokeAppliesPostProcessContext(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'remove-field-plugin';
|
||||
}
|
||||
|
||||
public function postProcessContext(array $context): array
|
||||
{
|
||||
unset($context['to_remove']);
|
||||
return $context;
|
||||
}
|
||||
};
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPlugin($plugin)
|
||||
->buildWithPlugins();
|
||||
|
||||
$record = new LogRecord(
|
||||
datetime: new \DateTimeImmutable(),
|
||||
channel: TestConstants::CHANNEL_TEST,
|
||||
level: Level::Info,
|
||||
message: TestConstants::MESSAGE_DEFAULT,
|
||||
context: ['to_remove' => 'value', 'keep' => 'data']
|
||||
);
|
||||
|
||||
$result = $processor($record);
|
||||
|
||||
$this->assertArrayNotHasKey('to_remove', $result->context);
|
||||
$this->assertArrayHasKey('keep', $result->context);
|
||||
}
|
||||
|
||||
public function testPostProcessingRunsInReverseOrder(): void
|
||||
{
|
||||
// Test that post-processing happens by verifying the message is modified
|
||||
$plugin1 = new class extends AbstractMaskingPlugin {
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(10);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'plugin1';
|
||||
}
|
||||
|
||||
public function postProcessMessage(string $message): string
|
||||
{
|
||||
return $message . '-plugin1';
|
||||
}
|
||||
};
|
||||
|
||||
$plugin2 = new class extends AbstractMaskingPlugin {
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(20);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'plugin2';
|
||||
}
|
||||
|
||||
public function postProcessMessage(string $message): string
|
||||
{
|
||||
return $message . '-plugin2';
|
||||
}
|
||||
};
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPlugin($plugin1)
|
||||
->addPlugin($plugin2)
|
||||
->buildWithPlugins();
|
||||
|
||||
$record = new LogRecord(
|
||||
datetime: new \DateTimeImmutable(),
|
||||
channel: TestConstants::CHANNEL_TEST,
|
||||
level: Level::Info,
|
||||
message: 'base',
|
||||
context: []
|
||||
);
|
||||
|
||||
$result = $processor($record);
|
||||
|
||||
// Post-processing runs in reverse priority order (higher priority last)
|
||||
// plugin2 (priority 20) runs first in post-processing, then plugin1 (priority 10)
|
||||
$this->assertSame('base-plugin2-plugin1', $result->message);
|
||||
}
|
||||
|
||||
public function testGetProcessor(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPattern(TestConstants::PATTERN_TEST, MaskConstants::MASK_GENERIC)
|
||||
->addPlugin($plugin)
|
||||
->buildWithPlugins();
|
||||
|
||||
$this->assertInstanceOf(PluginAwareProcessor::class, $processor);
|
||||
$this->assertInstanceOf(GdprProcessor::class, $processor->getProcessor());
|
||||
}
|
||||
|
||||
public function testGetPlugins(): void
|
||||
{
|
||||
$plugin1 = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'plugin1';
|
||||
}
|
||||
};
|
||||
|
||||
$plugin2 = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'plugin2';
|
||||
}
|
||||
};
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPlugin($plugin1)
|
||||
->addPlugin($plugin2)
|
||||
->buildWithPlugins();
|
||||
|
||||
$this->assertInstanceOf(PluginAwareProcessor::class, $processor);
|
||||
$this->assertCount(2, $processor->getPlugins());
|
||||
}
|
||||
|
||||
public function testRegExpMessageDelegates(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPattern(TestConstants::PATTERN_TEST, MaskConstants::MASK_GENERIC)
|
||||
->addPlugin($plugin)
|
||||
->buildWithPlugins();
|
||||
|
||||
$this->assertInstanceOf(PluginAwareProcessor::class, $processor);
|
||||
$this->assertSame(MaskConstants::MASK_GENERIC . ' message', $processor->regExpMessage('test message'));
|
||||
}
|
||||
|
||||
public function testRecursiveMaskDelegates(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPattern(TestConstants::PATTERN_TEST, MaskConstants::MASK_GENERIC)
|
||||
->addPlugin($plugin)
|
||||
->buildWithPlugins();
|
||||
|
||||
$this->assertInstanceOf(PluginAwareProcessor::class, $processor);
|
||||
|
||||
$result = $processor->recursiveMask(['key' => 'test value']);
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_GENERIC . ' value', $result['key']);
|
||||
}
|
||||
|
||||
public function testSetAuditLoggerDelegates(): void
|
||||
{
|
||||
$logs = [];
|
||||
$auditLogger = function (string $path) use (&$logs): void {
|
||||
$logs[] = ['path' => $path];
|
||||
};
|
||||
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPlugin($plugin)
|
||||
->buildWithPlugins();
|
||||
|
||||
$this->assertInstanceOf(PluginAwareProcessor::class, $processor);
|
||||
$processor->setAuditLogger($auditLogger);
|
||||
|
||||
// Audit logger should be set on underlying processor
|
||||
$this->assertTrue(true); // No exception means it worked
|
||||
}
|
||||
|
||||
public function testMultiplePluginsProcessInPriorityOrder(): void
|
||||
{
|
||||
// Test that pre-processing runs in priority order (lower number first)
|
||||
$plugin1 = new class extends AbstractMaskingPlugin {
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(20); // Lower priority (runs second)
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'plugin1';
|
||||
}
|
||||
|
||||
public function preProcessMessage(string $message): string
|
||||
{
|
||||
return $message . '-plugin1';
|
||||
}
|
||||
};
|
||||
|
||||
$plugin2 = new class extends AbstractMaskingPlugin {
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(10); // Higher priority (runs first)
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'plugin2';
|
||||
}
|
||||
|
||||
public function preProcessMessage(string $message): string
|
||||
{
|
||||
return $message . '-plugin2';
|
||||
}
|
||||
};
|
||||
|
||||
$processor = GdprProcessorBuilder::create()
|
||||
->addPlugin($plugin1)
|
||||
->addPlugin($plugin2)
|
||||
->buildWithPlugins();
|
||||
|
||||
$record = new LogRecord(
|
||||
datetime: new \DateTimeImmutable(),
|
||||
channel: TestConstants::CHANNEL_TEST,
|
||||
level: Level::Info,
|
||||
message: 'base',
|
||||
context: []
|
||||
);
|
||||
|
||||
$result = $processor($record);
|
||||
|
||||
// plugin2 (priority 10) runs first in pre-processing, then plugin1 (priority 20)
|
||||
$this->assertSame('base-plugin2-plugin1', $result->message);
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,10 @@ class ConditionalMaskingTest extends TestCase
|
||||
|
||||
public function testChannelBasedConditionalMasking(): void
|
||||
{
|
||||
// Create a processor that only masks logs from TestConstants::CHANNEL_SECURITY and TestConstants::CHANNEL_AUDIT channels
|
||||
// Create a processor that only masks logs from security and audit channels
|
||||
$channels = [TestConstants::CHANNEL_SECURITY, TestConstants::CHANNEL_AUDIT];
|
||||
$channelRule = ConditionalRuleFactory::createChannelBasedRule($channels);
|
||||
|
||||
$processor = $this->createProcessor(
|
||||
[TestConstants::PATTERN_EMAIL_TEST => MaskConstants::MASK_EMAIL],
|
||||
[],
|
||||
@@ -93,9 +96,7 @@ class ConditionalMaskingTest extends TestCase
|
||||
null,
|
||||
100,
|
||||
[],
|
||||
[
|
||||
'security_channels_only' => ConditionalRuleFactory::createChannelBasedRule([TestConstants::CHANNEL_SECURITY, TestConstants::CHANNEL_AUDIT])
|
||||
]
|
||||
['security_channels_only' => $channelRule]
|
||||
);
|
||||
|
||||
// Test security channel - should be masked
|
||||
@@ -143,7 +144,9 @@ class ConditionalMaskingTest extends TestCase
|
||||
100,
|
||||
[],
|
||||
[
|
||||
'sensitive_data_present' => ConditionalRuleFactory::createContextFieldRule(TestConstants::CONTEXT_SENSITIVE_DATA)
|
||||
'sensitive_data_present' => ConditionalRuleFactory::createContextFieldRule(
|
||||
TestConstants::CONTEXT_SENSITIVE_DATA
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
@@ -306,7 +309,7 @@ class ConditionalMaskingTest extends TestCase
|
||||
{
|
||||
// Create a custom rule that masks only logs with user_id > 1000
|
||||
$customRule = (
|
||||
fn(LogRecord $record): bool => isset($record->context[TestConstants::CONTEXT_USER_ID]) && $record->context[TestConstants::CONTEXT_USER_ID] > 1000
|
||||
fn(LogRecord $record): bool => ($record->context[TestConstants::CONTEXT_USER_ID] ?? 0) > 1000
|
||||
);
|
||||
|
||||
$processor = $this->createProcessor(
|
||||
|
||||
237
tests/ConditionalRuleFactoryInstanceTest.php
Normal file
237
tests/ConditionalRuleFactoryInstanceTest.php
Normal file
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Ivuorinen\MonologGdprFilter\ArrayAccessor\ArrayAccessorFactory;
|
||||
use Ivuorinen\MonologGdprFilter\ConditionalRuleFactory;
|
||||
use Monolog\Level;
|
||||
use Monolog\LogRecord;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests for ConditionalRuleFactory instance methods.
|
||||
*/
|
||||
#[CoversClass(ConditionalRuleFactory::class)]
|
||||
final class ConditionalRuleFactoryInstanceTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function createLogRecord(string $message, array $context = []): LogRecord
|
||||
{
|
||||
return new LogRecord(
|
||||
datetime: new DateTimeImmutable(),
|
||||
channel: 'test',
|
||||
level: Level::Info,
|
||||
message: $message,
|
||||
context: $context,
|
||||
);
|
||||
}
|
||||
|
||||
public function testConstructorWithDefaultFactory(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$this->assertInstanceOf(ConditionalRuleFactory::class, $factory);
|
||||
}
|
||||
|
||||
public function testConstructorWithCustomFactory(): void
|
||||
{
|
||||
$accessorFactory = ArrayAccessorFactory::default();
|
||||
$factory = new ConditionalRuleFactory($accessorFactory);
|
||||
$this->assertInstanceOf(ConditionalRuleFactory::class, $factory);
|
||||
}
|
||||
|
||||
public function testContextFieldRuleWithPresentField(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextFieldRule('user_id');
|
||||
|
||||
$record = $this->createLogRecord('Test message', ['user_id' => 123]);
|
||||
$this->assertTrue($rule($record));
|
||||
}
|
||||
|
||||
public function testContextFieldRuleWithMissingField(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextFieldRule('user_id');
|
||||
|
||||
$record = $this->createLogRecord('Test message', []);
|
||||
$this->assertFalse($rule($record));
|
||||
}
|
||||
|
||||
public function testContextFieldRuleWithNestedField(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextFieldRule('user.profile.id');
|
||||
|
||||
$recordWithField = $this->createLogRecord('Test', [
|
||||
'user' => ['profile' => ['id' => 456]],
|
||||
]);
|
||||
$this->assertTrue($rule($recordWithField));
|
||||
|
||||
$recordWithoutField = $this->createLogRecord('Test', [
|
||||
'user' => ['profile' => []],
|
||||
]);
|
||||
$this->assertFalse($rule($recordWithoutField));
|
||||
}
|
||||
|
||||
public function testContextValueRuleWithMatchingValue(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextValueRule('env', 'production');
|
||||
|
||||
$record = $this->createLogRecord('Test', ['env' => 'production']);
|
||||
$this->assertTrue($rule($record));
|
||||
}
|
||||
|
||||
public function testContextValueRuleWithNonMatchingValue(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextValueRule('env', 'production');
|
||||
|
||||
$record = $this->createLogRecord('Test', ['env' => 'development']);
|
||||
$this->assertFalse($rule($record));
|
||||
}
|
||||
|
||||
public function testContextValueRuleWithMissingField(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextValueRule('env', 'production');
|
||||
|
||||
$record = $this->createLogRecord('Test', []);
|
||||
$this->assertFalse($rule($record));
|
||||
}
|
||||
|
||||
public function testContextValueRuleWithNestedField(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextValueRule('config.debug', true);
|
||||
|
||||
$recordMatching = $this->createLogRecord('Test', [
|
||||
'config' => ['debug' => true],
|
||||
]);
|
||||
$this->assertTrue($rule($recordMatching));
|
||||
|
||||
$recordNonMatching = $this->createLogRecord('Test', [
|
||||
'config' => ['debug' => false],
|
||||
]);
|
||||
$this->assertFalse($rule($recordNonMatching));
|
||||
}
|
||||
|
||||
public function testContextValueRuleWithNullValue(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextValueRule('nullable', null);
|
||||
|
||||
$recordWithNull = $this->createLogRecord('Test', ['nullable' => null]);
|
||||
$this->assertTrue($rule($recordWithNull));
|
||||
|
||||
$recordWithValue = $this->createLogRecord('Test', ['nullable' => 'value']);
|
||||
$this->assertFalse($rule($recordWithValue));
|
||||
}
|
||||
|
||||
public function testContextValueRuleWithArrayValue(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$expectedArray = ['a', 'b', 'c'];
|
||||
$rule = $factory->contextValueRule('tags', $expectedArray);
|
||||
|
||||
$recordMatching = $this->createLogRecord('Test', ['tags' => ['a', 'b', 'c']]);
|
||||
$this->assertTrue($rule($recordMatching));
|
||||
|
||||
$recordNonMatching = $this->createLogRecord('Test', ['tags' => ['a', 'b']]);
|
||||
$this->assertFalse($rule($recordNonMatching));
|
||||
}
|
||||
|
||||
public function testContextValueRuleWithIntegerValue(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextValueRule('count', 42);
|
||||
|
||||
$recordMatching = $this->createLogRecord('Test', ['count' => 42]);
|
||||
$this->assertTrue($rule($recordMatching));
|
||||
|
||||
// Different type (string vs int) should not match
|
||||
$recordDifferentType = $this->createLogRecord('Test', ['count' => '42']);
|
||||
$this->assertFalse($rule($recordDifferentType));
|
||||
}
|
||||
|
||||
public function testCustomAccessorFactoryIsUsed(): void
|
||||
{
|
||||
// Create a custom accessor factory
|
||||
$customFactory = ArrayAccessorFactory::default();
|
||||
$ruleFactory = new ConditionalRuleFactory($customFactory);
|
||||
|
||||
$fieldRule = $ruleFactory->contextFieldRule('test.field');
|
||||
$valueRule = $ruleFactory->contextValueRule('test.value', 'expected');
|
||||
|
||||
$record = $this->createLogRecord('Test', [
|
||||
'test' => [
|
||||
'field' => 'present',
|
||||
'value' => 'expected',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertTrue($fieldRule($record));
|
||||
$this->assertTrue($valueRule($record));
|
||||
}
|
||||
|
||||
public function testInstanceMethodsVsStaticMethods(): void
|
||||
{
|
||||
$instanceFactory = new ConditionalRuleFactory();
|
||||
|
||||
// Create rules using both methods
|
||||
$instanceFieldRule = $instanceFactory->contextFieldRule('user.email');
|
||||
$staticFieldRule = ConditionalRuleFactory::createContextFieldRule('user.email');
|
||||
|
||||
$instanceValueRule = $instanceFactory->contextValueRule('type', 'admin');
|
||||
$staticValueRule = ConditionalRuleFactory::createContextValueRule('type', 'admin');
|
||||
|
||||
$record = $this->createLogRecord('Test', [
|
||||
'user' => ['email' => 'test@example.com'],
|
||||
'type' => 'admin',
|
||||
]);
|
||||
|
||||
// Both should produce the same results
|
||||
$this->assertSame($staticFieldRule($record), $instanceFieldRule($record));
|
||||
$this->assertSame($staticValueRule($record), $instanceValueRule($record));
|
||||
}
|
||||
|
||||
public function testContextFieldRuleWithEmptyPath(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextFieldRule('');
|
||||
|
||||
$record = $this->createLogRecord('Test', ['key' => 'value']);
|
||||
$this->assertFalse($rule($record));
|
||||
}
|
||||
|
||||
public function testContextValueRuleWithEmptyPath(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextValueRule('', 'value');
|
||||
|
||||
$record = $this->createLogRecord('Test', ['key' => 'value']);
|
||||
$this->assertFalse($rule($record));
|
||||
}
|
||||
|
||||
public function testContextFieldRuleWithDeeplyNestedField(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextFieldRule('a.b.c.d.e.f');
|
||||
|
||||
$deepRecord = $this->createLogRecord('Test', [
|
||||
'a' => ['b' => ['c' => ['d' => ['e' => ['f' => 'deep']]]]],
|
||||
]);
|
||||
$this->assertTrue($rule($deepRecord));
|
||||
|
||||
$shallowRecord = $this->createLogRecord('Test', [
|
||||
'a' => ['b' => ['c' => 'shallow']],
|
||||
]);
|
||||
$this->assertFalse($rule($shallowRecord));
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Adbar\Dot;
|
||||
use Ivuorinen\MonologGdprFilter\ArrayAccessor\DotArrayAccessor;
|
||||
use Ivuorinen\MonologGdprFilter\ContextProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\RuleExecutionException;
|
||||
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
|
||||
@@ -23,14 +23,18 @@ final class ContextProcessorTest extends TestCase
|
||||
public function testMaskFieldPathsWithRegexMask(): void
|
||||
{
|
||||
$regexProcessor = fn(string $val): string => str_replace('test', MaskConstants::MASK_GENERIC, $val);
|
||||
$emailConfig = FieldMaskConfig::regexMask(
|
||||
TestConstants::PATTERN_TEST,
|
||||
MaskConstants::MASK_GENERIC
|
||||
);
|
||||
$processor = new ContextProcessor(
|
||||
[TestConstants::CONTEXT_EMAIL => FieldMaskConfig::regexMask(TestConstants::PATTERN_TEST, MaskConstants::MASK_GENERIC)],
|
||||
[TestConstants::CONTEXT_EMAIL => $emailConfig],
|
||||
[],
|
||||
null,
|
||||
$regexProcessor
|
||||
);
|
||||
|
||||
$accessor = new Dot([TestConstants::CONTEXT_EMAIL => TestConstants::EMAIL_TEST]);
|
||||
$accessor = new DotArrayAccessor([TestConstants::CONTEXT_EMAIL => TestConstants::EMAIL_TEST]);
|
||||
$processed = $processor->maskFieldPaths($accessor);
|
||||
|
||||
$this->assertSame([TestConstants::CONTEXT_EMAIL], $processed);
|
||||
@@ -47,7 +51,7 @@ final class ContextProcessorTest extends TestCase
|
||||
$regexProcessor
|
||||
);
|
||||
|
||||
$accessor = new Dot(['secret' => 'confidential', 'public' => 'data']);
|
||||
$accessor = new DotArrayAccessor(['secret' => 'confidential', 'public' => 'data']);
|
||||
$processed = $processor->maskFieldPaths($accessor);
|
||||
|
||||
$this->assertSame(['secret'], $processed);
|
||||
@@ -65,7 +69,7 @@ final class ContextProcessorTest extends TestCase
|
||||
$regexProcessor
|
||||
);
|
||||
|
||||
$accessor = new Dot([TestConstants::CONTEXT_PASSWORD => 'secret123']);
|
||||
$accessor = new DotArrayAccessor([TestConstants::CONTEXT_PASSWORD => 'secret123']);
|
||||
$processed = $processor->maskFieldPaths($accessor);
|
||||
|
||||
$this->assertSame([TestConstants::CONTEXT_PASSWORD], $processed);
|
||||
@@ -82,7 +86,7 @@ final class ContextProcessorTest extends TestCase
|
||||
$regexProcessor
|
||||
);
|
||||
|
||||
$accessor = new Dot(['other' => 'value']);
|
||||
$accessor = new DotArrayAccessor(['other' => 'value']);
|
||||
$processed = $processor->maskFieldPaths($accessor);
|
||||
|
||||
$this->assertSame([], $processed);
|
||||
@@ -104,7 +108,7 @@ final class ContextProcessorTest extends TestCase
|
||||
$regexProcessor
|
||||
);
|
||||
|
||||
$accessor = new Dot(['field' => 'value']);
|
||||
$accessor = new DotArrayAccessor(['field' => 'value']);
|
||||
$processor->maskFieldPaths($accessor);
|
||||
|
||||
$this->assertCount(1, $auditLog);
|
||||
@@ -128,7 +132,7 @@ final class ContextProcessorTest extends TestCase
|
||||
$regexProcessor
|
||||
);
|
||||
|
||||
$accessor = new Dot(['secret' => 'data']);
|
||||
$accessor = new DotArrayAccessor(['secret' => 'data']);
|
||||
$processor->maskFieldPaths($accessor);
|
||||
|
||||
$this->assertCount(1, $auditLog);
|
||||
@@ -148,7 +152,7 @@ final class ContextProcessorTest extends TestCase
|
||||
$regexProcessor
|
||||
);
|
||||
|
||||
$accessor = new Dot(['name' => 'john']);
|
||||
$accessor = new DotArrayAccessor(['name' => 'john']);
|
||||
$processed = $processor->processCustomCallbacks($accessor);
|
||||
|
||||
$this->assertSame(['name'], $processed);
|
||||
@@ -167,7 +171,7 @@ final class ContextProcessorTest extends TestCase
|
||||
$regexProcessor
|
||||
);
|
||||
|
||||
$accessor = new Dot(['other' => 'value']);
|
||||
$accessor = new DotArrayAccessor(['other' => 'value']);
|
||||
$processed = $processor->processCustomCallbacks($accessor);
|
||||
|
||||
$this->assertSame([], $processed);
|
||||
@@ -192,7 +196,7 @@ final class ContextProcessorTest extends TestCase
|
||||
$regexProcessor
|
||||
);
|
||||
|
||||
$accessor = new Dot(['field' => 'value']);
|
||||
$accessor = new DotArrayAccessor(['field' => 'value']);
|
||||
$processed = $processor->processCustomCallbacks($accessor);
|
||||
|
||||
$this->assertSame(['field'], $processed);
|
||||
@@ -220,7 +224,7 @@ final class ContextProcessorTest extends TestCase
|
||||
$regexProcessor
|
||||
);
|
||||
|
||||
$accessor = new Dot(['field' => 'original']);
|
||||
$accessor = new DotArrayAccessor(['field' => 'original']);
|
||||
$processor->processCustomCallbacks($accessor);
|
||||
|
||||
$this->assertCount(1, $auditLog);
|
||||
@@ -332,7 +336,7 @@ final class ContextProcessorTest extends TestCase
|
||||
$regexProcessor
|
||||
);
|
||||
|
||||
$accessor = new Dot(['field' => 'value']);
|
||||
$accessor = new DotArrayAccessor(['field' => 'value']);
|
||||
$processor->processCustomCallbacks($accessor);
|
||||
|
||||
// Should not log when value unchanged
|
||||
|
||||
@@ -142,7 +142,8 @@ class CustomExceptionsTest extends TestCase
|
||||
'Invalid configuration'
|
||||
);
|
||||
|
||||
$this->assertStringContainsString("Field path masking failed for path '" . TestConstants::FIELD_USER_EMAIL . "'", $exception->getMessage());
|
||||
$expectedMsg = "Field path masking failed for path '" . TestConstants::FIELD_USER_EMAIL . "'";
|
||||
$this->assertStringContainsString($expectedMsg, $exception->getMessage());
|
||||
$this->assertStringContainsString('Invalid configuration', $exception->getMessage());
|
||||
$this->assertStringContainsString('operation_type: "field_path_masking"', $exception->getMessage());
|
||||
$this->assertStringContainsString('value_type: "string"', $exception->getMessage());
|
||||
@@ -356,7 +357,12 @@ class CustomExceptionsTest extends TestCase
|
||||
'input',
|
||||
'Failed'
|
||||
);
|
||||
$auditException = AuditLoggingException::callbackFailed('path', 'original', TestConstants::DATA_MASKED, 'Failed');
|
||||
$auditException = AuditLoggingException::callbackFailed(
|
||||
'path',
|
||||
'original',
|
||||
TestConstants::DATA_MASKED,
|
||||
'Failed'
|
||||
);
|
||||
$depthException = RecursionDepthExceededException::depthExceeded(10, 5, 'path');
|
||||
|
||||
// All should inherit from GdprProcessorException
|
||||
|
||||
161
tests/Factory/AuditLoggerFactoryTest.php
Normal file
161
tests/Factory/AuditLoggerFactoryTest.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Factory;
|
||||
|
||||
use Closure;
|
||||
use Ivuorinen\MonologGdprFilter\Factory\AuditLoggerFactory;
|
||||
use Ivuorinen\MonologGdprFilter\RateLimitedAuditLogger;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(AuditLoggerFactory::class)]
|
||||
final class AuditLoggerFactoryTest extends TestCase
|
||||
{
|
||||
public function testCreateReturnsFactoryInstance(): void
|
||||
{
|
||||
$factory = AuditLoggerFactory::create();
|
||||
|
||||
$this->assertInstanceOf(AuditLoggerFactory::class, $factory);
|
||||
}
|
||||
|
||||
public function testCreateRateLimitedReturnsRateLimitedLogger(): void
|
||||
{
|
||||
$factory = AuditLoggerFactory::create();
|
||||
$auditLogger = fn(string $path, mixed $original, mixed $masked): mixed => null;
|
||||
|
||||
$result = $factory->createRateLimited($auditLogger);
|
||||
|
||||
$this->assertInstanceOf(RateLimitedAuditLogger::class, $result);
|
||||
}
|
||||
|
||||
public function testCreateRateLimitedWithProfile(): void
|
||||
{
|
||||
$factory = AuditLoggerFactory::create();
|
||||
$auditLogger = fn(string $path, mixed $original, mixed $masked): mixed => null;
|
||||
|
||||
$result = $factory->createRateLimited($auditLogger, 'strict');
|
||||
|
||||
$this->assertInstanceOf(RateLimitedAuditLogger::class, $result);
|
||||
}
|
||||
|
||||
public function testCreateArrayLoggerReturnsClosureByDefault(): void
|
||||
{
|
||||
$factory = AuditLoggerFactory::create();
|
||||
$storage = [];
|
||||
|
||||
$result = $factory->createArrayLogger($storage);
|
||||
|
||||
$this->assertInstanceOf(Closure::class, $result);
|
||||
}
|
||||
|
||||
public function testCreateArrayLoggerWithRateLimiting(): void
|
||||
{
|
||||
$factory = AuditLoggerFactory::create();
|
||||
$storage = [];
|
||||
|
||||
$result = $factory->createArrayLogger($storage, true);
|
||||
|
||||
$this->assertInstanceOf(RateLimitedAuditLogger::class, $result);
|
||||
}
|
||||
|
||||
public function testCreateArrayLoggerStoresLogs(): void
|
||||
{
|
||||
$factory = AuditLoggerFactory::create();
|
||||
$storage = [];
|
||||
|
||||
$logger = $factory->createArrayLogger($storage);
|
||||
$logger('test.path', 'original', 'masked');
|
||||
|
||||
$this->assertCount(1, $storage);
|
||||
$this->assertSame('test.path', $storage[0]['path']);
|
||||
$this->assertSame('original', $storage[0]['original']);
|
||||
$this->assertSame('masked', $storage[0]['masked']);
|
||||
$this->assertArrayHasKey('timestamp', $storage[0]);
|
||||
}
|
||||
|
||||
public function testCreateNullLoggerReturnsClosure(): void
|
||||
{
|
||||
$factory = AuditLoggerFactory::create();
|
||||
|
||||
$result = $factory->createNullLogger();
|
||||
|
||||
$this->assertInstanceOf(Closure::class, $result);
|
||||
}
|
||||
|
||||
public function testCreateNullLoggerDoesNothing(): void
|
||||
{
|
||||
$factory = AuditLoggerFactory::create();
|
||||
$logger = $factory->createNullLogger();
|
||||
|
||||
// Should not throw
|
||||
$logger('path', 'original', 'masked');
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testCreateCallbackLoggerReturnsClosure(): void
|
||||
{
|
||||
$factory = AuditLoggerFactory::create();
|
||||
$callback = fn(string $path, mixed $original, mixed $masked): mixed => null;
|
||||
|
||||
$result = $factory->createCallbackLogger($callback);
|
||||
|
||||
$this->assertInstanceOf(Closure::class, $result);
|
||||
}
|
||||
|
||||
public function testCreateCallbackLoggerInvokesCallback(): void
|
||||
{
|
||||
$factory = AuditLoggerFactory::create();
|
||||
$calls = [];
|
||||
$callback = function (string $path, mixed $original, mixed $masked) use (&$calls): void {
|
||||
$calls[] = ['path' => $path, 'original' => $original, 'masked' => $masked];
|
||||
};
|
||||
|
||||
$logger = $factory->createCallbackLogger($callback);
|
||||
$logger('test.path', 'original', 'masked');
|
||||
|
||||
$this->assertCount(1, $calls);
|
||||
$this->assertSame('test.path', $calls[0]['path']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-suppress DeprecatedMethod - Testing deprecated method
|
||||
*/
|
||||
public function testDeprecatedRateLimitedStaticMethod(): void
|
||||
{
|
||||
$auditLogger = fn(string $path, mixed $original, mixed $masked): mixed => null;
|
||||
|
||||
$result = AuditLoggerFactory::rateLimited($auditLogger);
|
||||
|
||||
$this->assertInstanceOf(RateLimitedAuditLogger::class, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-suppress DeprecatedMethod - Testing deprecated method
|
||||
*/
|
||||
public function testDeprecatedArrayLoggerStaticMethod(): void
|
||||
{
|
||||
$storage = [];
|
||||
|
||||
$result = AuditLoggerFactory::arrayLogger($storage);
|
||||
|
||||
$this->assertInstanceOf(Closure::class, $result);
|
||||
}
|
||||
|
||||
public function testMultipleLogEntriesStoredCorrectly(): void
|
||||
{
|
||||
$factory = AuditLoggerFactory::create();
|
||||
$storage = [];
|
||||
|
||||
$logger = $factory->createArrayLogger($storage);
|
||||
$logger('path1', 'orig1', 'mask1');
|
||||
$logger('path2', 'orig2', 'mask2');
|
||||
$logger('path3', 'orig3', 'mask3');
|
||||
|
||||
$this->assertCount(3, $storage);
|
||||
$this->assertSame('path1', $storage[0]['path']);
|
||||
$this->assertSame('path2', $storage[1]['path']);
|
||||
$this->assertSame('path3', $storage[2]['path']);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tests\TestConstants;
|
||||
|
||||
/**
|
||||
* @psalm-suppress DeprecatedMethod - Tests for deprecated audit logger methods
|
||||
*/
|
||||
#[CoversClass(GdprProcessor::class)]
|
||||
final class GdprProcessorComprehensiveTest extends TestCase
|
||||
{
|
||||
|
||||
@@ -14,6 +14,9 @@ use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @psalm-suppress DeprecatedMethod - Tests for deprecated audit logger factory methods
|
||||
*/
|
||||
#[CoversClass(GdprProcessor::class)]
|
||||
final class GdprProcessorExtendedTest extends TestCase
|
||||
{
|
||||
|
||||
@@ -22,6 +22,7 @@ use Tests\TestHelpers;
|
||||
* Integration tests for GDPR processor with rate-limited audit logging.
|
||||
*
|
||||
* @api
|
||||
* @psalm-suppress DeprecatedMethod - Tests for deprecated audit logger methods
|
||||
*/
|
||||
class GdprProcessorRateLimitingIntegrationTest extends TestCase
|
||||
{
|
||||
@@ -60,12 +61,18 @@ class GdprProcessorRateLimitingIntegrationTest extends TestCase
|
||||
|
||||
// Process multiple log records
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
// Add context data to be masked
|
||||
$contextData = [
|
||||
'user' => [
|
||||
TestConstants::CONTEXT_EMAIL => sprintf(TestConstants::TEMPLATE_USER_EMAIL, $i)
|
||||
]
|
||||
];
|
||||
$logRecord = new LogRecord(
|
||||
new DateTimeImmutable(),
|
||||
'test',
|
||||
Level::Info,
|
||||
sprintf(TestConstants::TEMPLATE_MESSAGE_EMAIL, $i),
|
||||
['user' => [TestConstants::CONTEXT_EMAIL => sprintf(TestConstants::TEMPLATE_USER_EMAIL, $i)]] // Add context data to be masked
|
||||
$contextData
|
||||
);
|
||||
|
||||
$result = $processor($logRecord);
|
||||
@@ -266,7 +273,12 @@ class GdprProcessorRateLimitingIntegrationTest extends TestCase
|
||||
throw AuditLoggingException::callbackFailed($path, $original, $masked, 'Audit logging failed');
|
||||
}
|
||||
|
||||
$this->auditLogs[] = ['path' => $path, 'original' => $original, TestConstants::DATA_MASKED => $masked, 'timestamp' => time()];
|
||||
$this->auditLogs[] = [
|
||||
'path' => $path,
|
||||
'original' => $original,
|
||||
TestConstants::DATA_MASKED => $masked,
|
||||
'timestamp' => time()
|
||||
];
|
||||
};
|
||||
|
||||
$rateLimitedLogger = new RateLimitedAuditLogger($failingAuditLogger, 2, 60);
|
||||
@@ -291,9 +303,11 @@ class GdprProcessorRateLimitingIntegrationTest extends TestCase
|
||||
$baseLogger = GdprProcessor::createArrayAuditLogger($this->auditLogs, false);
|
||||
$rateLimitedLogger = RateLimitedAuditLogger::create($baseLogger, 'default');
|
||||
|
||||
// Add field path masking to generate more audit logs
|
||||
$fieldPaths = [TestConstants::FIELD_USER_EMAIL => 'user@masked.com'];
|
||||
$processor = $this->createProcessor(
|
||||
[TestConstants::PATTERN_EMAIL_TEST => MaskConstants::MASK_EMAIL],
|
||||
[TestConstants::FIELD_USER_EMAIL => 'user@masked.com'], // Add field path masking to generate more audit logs
|
||||
$fieldPaths,
|
||||
[],
|
||||
$rateLimitedLogger
|
||||
);
|
||||
|
||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\RateLimitedAuditLogger;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\PatternValidationException;
|
||||
use Ivuorinen\MonologGdprFilter\MaskingOrchestrator;
|
||||
use Tests\TestConstants;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidRegexPatternException;
|
||||
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
|
||||
@@ -133,7 +136,8 @@ class GdprProcessorTest extends TestCase
|
||||
);
|
||||
$processor($record);
|
||||
$this->assertNotEmpty($auditCalls);
|
||||
$this->assertSame([TestConstants::FIELD_USER_EMAIL, TestConstants::EMAIL_JOHN_DOE, Mask::MASK_EMAIL], $auditCalls[0]);
|
||||
$expected = [TestConstants::FIELD_USER_EMAIL, TestConstants::EMAIL_JOHN_DOE, Mask::MASK_EMAIL];
|
||||
$this->assertSame($expected, $auditCalls[0]);
|
||||
}
|
||||
|
||||
public function testMaskMessage(): void
|
||||
@@ -322,4 +326,140 @@ class GdprProcessorTest extends TestCase
|
||||
$result = $processor->regExpMessage('foo');
|
||||
$this->assertSame('foo', $result, 'Should return original message if preg_replace result is string "0"');
|
||||
}
|
||||
|
||||
public function testCreateRateLimitedAuditLoggerReturnsRateLimitedLogger(): void
|
||||
{
|
||||
$auditLog = [];
|
||||
$auditLogger = function (string $path, mixed $original, mixed $masked) use (&$auditLog): void {
|
||||
$auditLog[] = ['path' => $path, 'original' => $original, TestConstants::DATA_MASKED => $masked];
|
||||
};
|
||||
|
||||
/** @psalm-suppress DeprecatedMethod */
|
||||
$rateLimitedLogger = GdprProcessor::createRateLimitedAuditLogger($auditLogger, 'testing');
|
||||
|
||||
$this->assertInstanceOf(RateLimitedAuditLogger::class, $rateLimitedLogger);
|
||||
}
|
||||
|
||||
public function testCreateArrayAuditLoggerReturnsCallable(): void
|
||||
{
|
||||
$logStorage = [];
|
||||
|
||||
/** @psalm-suppress DeprecatedMethod */
|
||||
$logger = GdprProcessor::createArrayAuditLogger($logStorage, false);
|
||||
|
||||
// Logger is a Closure which is callable
|
||||
$this->assertInstanceOf(\Closure::class, $logger);
|
||||
}
|
||||
|
||||
public function testCreateArrayAuditLoggerWithRateLimiting(): void
|
||||
{
|
||||
$logStorage = [];
|
||||
|
||||
/** @psalm-suppress DeprecatedMethod */
|
||||
$logger = GdprProcessor::createArrayAuditLogger($logStorage, true);
|
||||
|
||||
$this->assertInstanceOf(RateLimitedAuditLogger::class, $logger);
|
||||
}
|
||||
|
||||
public function testValidatePatternsArraySucceedsWithValidPatterns(): void
|
||||
{
|
||||
$patterns = [
|
||||
TestConstants::PATTERN_EMAIL_FULL => Mask::MASK_EMAIL,
|
||||
TestConstants::PATTERN_SSN_FORMAT => Mask::MASK_SSN,
|
||||
];
|
||||
|
||||
// Should not throw
|
||||
GdprProcessor::validatePatternsArray($patterns);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testValidatePatternsArrayThrowsForInvalidPattern(): void
|
||||
{
|
||||
$this->expectException(PatternValidationException::class);
|
||||
|
||||
$patterns = [
|
||||
'/[invalid/' => 'MASKED',
|
||||
];
|
||||
|
||||
GdprProcessor::validatePatternsArray($patterns);
|
||||
}
|
||||
|
||||
public function testGetOrchestratorReturnsOrchestrator(): void
|
||||
{
|
||||
$processor = $this->createProcessor([TestConstants::PATTERN_TEST => Mask::MASK_GENERIC]);
|
||||
|
||||
$orchestrator = $processor->getOrchestrator();
|
||||
|
||||
$this->assertInstanceOf(MaskingOrchestrator::class, $orchestrator);
|
||||
}
|
||||
|
||||
public function testSetAuditLoggerUpdatesLogger(): void
|
||||
{
|
||||
$auditLog = [];
|
||||
$auditLogger = function (string $path, mixed $original, mixed $masked) use (&$auditLog): void {
|
||||
$auditLog[] = ['path' => $path, 'original' => $original, TestConstants::DATA_MASKED => $masked];
|
||||
};
|
||||
|
||||
$processor = $this->createProcessor(
|
||||
[TestConstants::PATTERN_SECRET => Mask::MASK_MASKED],
|
||||
[TestConstants::FIELD_USER_PASSWORD => FieldMaskConfig::replace(Mask::MASK_MASKED)]
|
||||
);
|
||||
|
||||
// Initially no audit logger
|
||||
$processor->setAuditLogger($auditLogger);
|
||||
|
||||
$record = $this->createLogRecord(
|
||||
context: ['user' => [TestConstants::CONTEXT_PASSWORD => TestConstants::PASSWORD]]
|
||||
);
|
||||
$processor($record);
|
||||
|
||||
$this->assertNotEmpty($auditLog);
|
||||
}
|
||||
|
||||
public function testSetAuditLoggerToNull(): void
|
||||
{
|
||||
$auditLog = [];
|
||||
$auditLogger = function (string $path, mixed $original, mixed $masked) use (&$auditLog): void {
|
||||
$auditLog[] = ['path' => $path, 'original' => $original, TestConstants::DATA_MASKED => $masked];
|
||||
};
|
||||
|
||||
$processor = $this->createProcessor(
|
||||
[TestConstants::PATTERN_SECRET => Mask::MASK_MASKED],
|
||||
[TestConstants::FIELD_USER_PASSWORD => FieldMaskConfig::replace(Mask::MASK_MASKED)],
|
||||
[],
|
||||
$auditLogger
|
||||
);
|
||||
|
||||
// Set to null
|
||||
$processor->setAuditLogger(null);
|
||||
|
||||
$record = $this->createLogRecord(
|
||||
context: ['user' => [TestConstants::CONTEXT_PASSWORD => TestConstants::PASSWORD]]
|
||||
);
|
||||
$processor($record);
|
||||
|
||||
// Audit log should be empty because logger was set to null
|
||||
$this->assertEmpty($auditLog);
|
||||
}
|
||||
|
||||
public function testMaskMessageHandlesEmptyPatterns(): void
|
||||
{
|
||||
$processor = $this->createProcessor([]);
|
||||
|
||||
$result = $processor->maskMessage('test message with nothing to mask');
|
||||
|
||||
$this->assertSame('test message with nothing to mask', $result);
|
||||
}
|
||||
|
||||
public function testMaskMessageAppliesAllPatterns(): void
|
||||
{
|
||||
$processor = $this->createProcessor([
|
||||
'/foo/' => 'bar',
|
||||
'/baz/' => 'qux',
|
||||
]);
|
||||
|
||||
$result = $processor->maskMessage('foo and baz');
|
||||
|
||||
$this->assertSame('bar and qux', $result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,25 @@ class ConfigValidationTest extends TestCase
|
||||
*
|
||||
* @return ((bool|int|string)[]|bool|int)[]
|
||||
*
|
||||
* @psalm-return array{auto_register: bool, channels: list{'single', 'daily', 'stack'}, patterns: array<never, never>, field_paths: array<never, never>, custom_callbacks: array<never, never>, max_depth: int<1, 1000>, audit_logging: array{enabled: bool, channel: string}, performance: array{chunk_size: int<100, 10000>, garbage_collection_threshold: int<1000, 100000>}, validation: array{max_pattern_length: int<10, 1000>, max_field_path_length: int<5, 500>, allow_empty_patterns: bool, strict_regex_validation: bool}}
|
||||
* @psalm-return array{
|
||||
* auto_register: bool,
|
||||
* channels: list{'single', 'daily', 'stack'},
|
||||
* patterns: array<never, never>,
|
||||
* field_paths: array<never, never>,
|
||||
* custom_callbacks: array<never, never>,
|
||||
* max_depth: int<1, 1000>,
|
||||
* audit_logging: array{enabled: bool, channel: string},
|
||||
* performance: array{
|
||||
* chunk_size: int<100, 10000>,
|
||||
* garbage_collection_threshold: int<1000, 100000>
|
||||
* },
|
||||
* validation: array{
|
||||
* max_pattern_length: int<10, 1000>,
|
||||
* max_field_path_length: int<5, 500>,
|
||||
* allow_empty_patterns: bool,
|
||||
* strict_regex_validation: bool
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
private function getTestConfig(): array
|
||||
{
|
||||
@@ -42,10 +60,22 @@ class ConfigValidationTest extends TestCase
|
||||
'garbage_collection_threshold' => max(1000, min(100000, (int) ($_ENV['GDPR_GC_THRESHOLD'] ?? 10000))),
|
||||
],
|
||||
'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),
|
||||
'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
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -426,9 +456,18 @@ class ConfigValidationTest extends TestCase
|
||||
|
||||
// Security-focused defaults
|
||||
$this->assertFalse($config['auto_register'], 'auto_register should default to false');
|
||||
$this->assertFalse($config['audit_logging']['enabled'], 'audit logging should default to false');
|
||||
$this->assertFalse($config['validation']['allow_empty_patterns'], 'empty patterns should not be allowed by default');
|
||||
$this->assertTrue($config['validation']['strict_regex_validation'], 'strict regex validation should be enabled by default');
|
||||
$this->assertFalse(
|
||||
$config['audit_logging']['enabled'],
|
||||
'audit logging should default to false'
|
||||
);
|
||||
$this->assertFalse(
|
||||
$config['validation']['allow_empty_patterns'],
|
||||
'empty patterns should not be allowed by default'
|
||||
);
|
||||
$this->assertTrue(
|
||||
$config['validation']['strict_regex_validation'],
|
||||
'strict regex validation should be enabled by default'
|
||||
);
|
||||
|
||||
// Restore environment variables
|
||||
foreach ($oldValues as $var => $value) {
|
||||
|
||||
@@ -21,6 +21,7 @@ use Tests\TestConstants;
|
||||
* Tests for the GdprProcessor class.
|
||||
*
|
||||
* @api
|
||||
* @psalm-suppress DeprecatedMethod - Tests for deprecated validation methods
|
||||
*/
|
||||
#[CoversClass(GdprProcessor::class)]
|
||||
class GdprProcessorValidationTest extends TestCase
|
||||
@@ -272,7 +273,8 @@ class GdprProcessorValidationTest extends TestCase
|
||||
public function constructorThrowsExceptionForInvalidDataTypeMaskKey(): void
|
||||
{
|
||||
$this->expectException(InvalidConfigurationException::class);
|
||||
$this->expectExceptionMessage("Must be one of: integer, double, string, boolean, NULL, array, object, resource");
|
||||
$expectedMsg = 'Must be one of: integer, double, string, boolean, NULL, array, object, resource';
|
||||
$this->expectExceptionMessage($expectedMsg);
|
||||
|
||||
new GdprProcessor([], [], [], null, 100, ['invalid_type' => MaskConstants::MASK_MASKED]);
|
||||
}
|
||||
@@ -408,8 +410,12 @@ class GdprProcessorValidationTest extends TestCase
|
||||
#[Test]
|
||||
public function constructorHandlesComplexValidRegexPatterns(): void
|
||||
{
|
||||
// Complex IP address pattern (IPv4 octet validation)
|
||||
$ipPattern = '/(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}'
|
||||
. '(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)/';
|
||||
|
||||
$complexPatterns = [
|
||||
'/(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)/' => MaskConstants::MASK_IP,
|
||||
$ipPattern => MaskConstants::MASK_IP,
|
||||
TestConstants::PATTERN_EMAIL_FULL => MaskConstants::MASK_EMAIL,
|
||||
'/\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/' => MaskConstants::MASK_CARD
|
||||
];
|
||||
|
||||
@@ -132,10 +132,15 @@ final class InputValidatorTest extends TestCase
|
||||
#[Test]
|
||||
public function validateFieldPathsPassesForValidPaths(): void
|
||||
{
|
||||
$ssnConfig = FieldMaskConfig::regexMask(
|
||||
TestConstants::PATTERN_SSN_FORMAT,
|
||||
MaskConstants::MASK_SSN_PATTERN
|
||||
);
|
||||
|
||||
InputValidator::validateFieldPaths([
|
||||
TestConstants::FIELD_USER_EMAIL => MaskConstants::MASK_EMAIL_PATTERN,
|
||||
TestConstants::FIELD_USER_PASSWORD => FieldMaskConfig::remove(),
|
||||
'user.ssn' => FieldMaskConfig::regexMask(TestConstants::PATTERN_SSN_FORMAT, MaskConstants::MASK_SSN_PATTERN),
|
||||
'user.ssn' => $ssnConfig,
|
||||
]);
|
||||
|
||||
$this->assertTrue(true);
|
||||
|
||||
@@ -319,9 +319,10 @@ class JsonMaskingTest extends TestCase
|
||||
$extractedJson = $this->extractJsonFromMessage($result);
|
||||
$this->assertNotNull($extractedJson);
|
||||
$this->assertCount(2, $extractedJson['users']);
|
||||
$this->assertEquals(MaskConstants::MASK_EMAIL, $extractedJson['users'][0][TestConstants::CONTEXT_EMAIL]);
|
||||
$this->assertEquals(MaskConstants::MASK_PHONE, $extractedJson['users'][0]['contacts']['phone']);
|
||||
$this->assertEquals(MaskConstants::MASK_EMAIL, $extractedJson['users'][0]['contacts']['emergency'][TestConstants::CONTEXT_EMAIL]);
|
||||
$user0 = $extractedJson['users'][0];
|
||||
$this->assertEquals(MaskConstants::MASK_EMAIL, $user0[TestConstants::CONTEXT_EMAIL]);
|
||||
$this->assertEquals(MaskConstants::MASK_PHONE, $user0['contacts']['phone']);
|
||||
$this->assertEquals(MaskConstants::MASK_EMAIL, $user0['contacts']['emergency'][TestConstants::CONTEXT_EMAIL]);
|
||||
}
|
||||
|
||||
public function testJsonMaskingErrorHandling(): void
|
||||
@@ -346,7 +347,7 @@ class JsonMaskingTest extends TestCase
|
||||
$this->assertStringContainsString('{"valid":true}', $result);
|
||||
|
||||
// No error logs should be generated for valid JSON
|
||||
$errorLogs = array_filter($auditLogs, fn(array $log): bool => str_contains($log['path'], 'error'));
|
||||
$errorLogs = array_filter($auditLogs, fn(array $log): bool => str_contains((string) $log['path'], 'error'));
|
||||
$this->assertEmpty($errorLogs);
|
||||
}
|
||||
|
||||
|
||||
258
tests/MaskingOrchestratorTest.php
Normal file
258
tests/MaskingOrchestratorTest.php
Normal file
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidConfigurationException;
|
||||
use Ivuorinen\MonologGdprFilter\ArrayAccessor\ArrayAccessorFactory;
|
||||
use Ivuorinen\MonologGdprFilter\ContextProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants;
|
||||
use Ivuorinen\MonologGdprFilter\MaskingOrchestrator;
|
||||
use Ivuorinen\MonologGdprFilter\RecursiveProcessor;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(MaskingOrchestrator::class)]
|
||||
final class MaskingOrchestratorTest extends TestCase
|
||||
{
|
||||
public function testProcessMasksMessage(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC]
|
||||
);
|
||||
|
||||
$result = $orchestrator->process('This is a test message', []);
|
||||
|
||||
$this->assertSame('This is a ' . MaskConstants::MASK_GENERIC . ' message', $result['message']);
|
||||
$this->assertSame([], $result['context']);
|
||||
}
|
||||
|
||||
public function testProcessMasksContext(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC]
|
||||
);
|
||||
|
||||
$result = $orchestrator->process('message', ['key' => TestConstants::VALUE_TEST]);
|
||||
|
||||
$this->assertSame('message', $result['message']);
|
||||
$this->assertSame(MaskConstants::MASK_GENERIC . TestConstants::VALUE_SUFFIX, $result['context']['key']);
|
||||
}
|
||||
|
||||
public function testProcessMasksFieldPaths(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[],
|
||||
[TestConstants::CONTEXT_EMAIL => FieldMaskConfig::replace(TestConstants::MASK_EMAIL_BRACKETS)]
|
||||
);
|
||||
|
||||
$result = $orchestrator->process('message', [TestConstants::CONTEXT_EMAIL => TestConstants::EMAIL_TEST]);
|
||||
|
||||
$this->assertSame(TestConstants::MASK_EMAIL_BRACKETS, $result['context'][TestConstants::CONTEXT_EMAIL]);
|
||||
}
|
||||
|
||||
public function testProcessExecutesCustomCallbacks(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[],
|
||||
[],
|
||||
['name' => fn(mixed $val): string => strtoupper((string) $val)]
|
||||
);
|
||||
|
||||
$result = $orchestrator->process('message', ['name' => 'john']);
|
||||
|
||||
$this->assertSame('JOHN', $result['context']['name']);
|
||||
}
|
||||
|
||||
public function testProcessContextDirectly(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC]
|
||||
);
|
||||
|
||||
$result = $orchestrator->processContext(['key' => TestConstants::VALUE_TEST]);
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_GENERIC . TestConstants::VALUE_SUFFIX, $result['key']);
|
||||
}
|
||||
|
||||
public function testRegExpMessageMasksPatterns(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[TestConstants::PATTERN_SSN_FORMAT => '[SSN]']
|
||||
);
|
||||
|
||||
$result = $orchestrator->regExpMessage('SSN: 123-45-6789');
|
||||
|
||||
$this->assertSame('SSN: [SSN]', $result);
|
||||
}
|
||||
|
||||
public function testRegExpMessagePreservesEmptyString(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC]);
|
||||
|
||||
$result = $orchestrator->regExpMessage('');
|
||||
|
||||
$this->assertSame('', $result);
|
||||
}
|
||||
|
||||
public function testRecursiveMaskMasksNestedArrays(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC]
|
||||
);
|
||||
|
||||
$result = $orchestrator->recursiveMask(['level1' => ['level2' => TestConstants::VALUE_TEST]]);
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertSame(MaskConstants::MASK_GENERIC . TestConstants::VALUE_SUFFIX, $result['level1']['level2']);
|
||||
}
|
||||
|
||||
public function testRecursiveMaskMasksString(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC]
|
||||
);
|
||||
|
||||
$result = $orchestrator->recursiveMask('test string');
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_GENERIC . ' string', $result);
|
||||
}
|
||||
|
||||
public function testCreateValidatesParameters(): void
|
||||
{
|
||||
$this->expectException(InvalidConfigurationException::class);
|
||||
|
||||
MaskingOrchestrator::create(
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
-1 // Invalid depth
|
||||
);
|
||||
}
|
||||
|
||||
public function testCreateWithValidParameters(): void
|
||||
{
|
||||
$orchestrator = MaskingOrchestrator::create(
|
||||
[TestConstants::PATTERN_DIGITS => '[DIGITS]'],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
50
|
||||
);
|
||||
|
||||
$result = $orchestrator->regExpMessage('Number: 12345');
|
||||
|
||||
$this->assertSame('Number: [DIGITS]', $result);
|
||||
}
|
||||
|
||||
public function testGetContextProcessor(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator([]);
|
||||
|
||||
$this->assertInstanceOf(ContextProcessor::class, $orchestrator->getContextProcessor());
|
||||
}
|
||||
|
||||
public function testGetRecursiveProcessor(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator([]);
|
||||
|
||||
$this->assertInstanceOf(RecursiveProcessor::class, $orchestrator->getRecursiveProcessor());
|
||||
}
|
||||
|
||||
public function testGetArrayAccessorFactory(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator([]);
|
||||
|
||||
$this->assertInstanceOf(ArrayAccessorFactory::class, $orchestrator->getArrayAccessorFactory());
|
||||
}
|
||||
|
||||
public function testSetAuditLogger(): void
|
||||
{
|
||||
$logs = [];
|
||||
$auditLogger = function (string $path, mixed $original, mixed $masked) use (&$logs): void {
|
||||
$logs[] = ['path' => $path, 'original' => $original, TestConstants::DATA_MASKED => $masked];
|
||||
};
|
||||
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[],
|
||||
['field' => FieldMaskConfig::replace('[MASKED]')]
|
||||
);
|
||||
|
||||
$orchestrator->setAuditLogger($auditLogger);
|
||||
$orchestrator->processContext(['field' => 'value']);
|
||||
|
||||
$this->assertCount(1, $logs);
|
||||
$this->assertSame('field', $logs[0]['path']);
|
||||
}
|
||||
|
||||
public function testWithCustomArrayAccessorFactory(): void
|
||||
{
|
||||
$customFactory = ArrayAccessorFactory::default();
|
||||
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
100,
|
||||
[],
|
||||
$customFactory
|
||||
);
|
||||
|
||||
$this->assertSame($customFactory, $orchestrator->getArrayAccessorFactory());
|
||||
}
|
||||
|
||||
public function testProcessWithFieldPathsAndCustomCallbacksCombined(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC],
|
||||
[TestConstants::CONTEXT_EMAIL => FieldMaskConfig::replace(TestConstants::MASK_EMAIL_BRACKETS)],
|
||||
['name' => fn(mixed $val): string => strtoupper((string) $val)]
|
||||
);
|
||||
|
||||
$result = $orchestrator->process(
|
||||
'Hello test',
|
||||
[
|
||||
TestConstants::CONTEXT_EMAIL => TestConstants::EMAIL_TEST,
|
||||
'name' => 'john',
|
||||
'message' => 'test'
|
||||
]
|
||||
);
|
||||
|
||||
$this->assertSame('Hello ' . MaskConstants::MASK_GENERIC, $result['message']);
|
||||
$this->assertSame(TestConstants::MASK_EMAIL_BRACKETS, $result['context'][TestConstants::CONTEXT_EMAIL]);
|
||||
$this->assertSame('JOHN', $result['context']['name']);
|
||||
}
|
||||
|
||||
public function testProcessWithDataTypeMasks(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
100,
|
||||
['integer' => '[INT]']
|
||||
);
|
||||
|
||||
$result = $orchestrator->processContext(['count' => 42]);
|
||||
|
||||
$this->assertSame('[INT]', $result['count']);
|
||||
}
|
||||
|
||||
public function testProcessContextWithRemoveConfig(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[],
|
||||
['secret' => FieldMaskConfig::remove()]
|
||||
);
|
||||
|
||||
$result = $orchestrator->processContext(['secret' => 'value', 'public' => 'data']);
|
||||
|
||||
$this->assertArrayNotHasKey('secret', $result);
|
||||
$this->assertSame('data', $result['public']);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use PHPUnit\Framework\TestCase;
|
||||
* Test PatternValidator functionality.
|
||||
*
|
||||
* @api
|
||||
* @psalm-suppress DeprecatedMethod - Tests for deprecated static API
|
||||
*/
|
||||
#[CoversClass(PatternValidator::class)]
|
||||
class PatternValidatorTest extends TestCase
|
||||
@@ -41,14 +42,14 @@ class PatternValidatorTest extends TestCase
|
||||
{
|
||||
$this->assertTrue(PatternValidator::isValid(TestConstants::PATTERN_DIGITS));
|
||||
$this->assertTrue(PatternValidator::isValid('/[a-z]+/i'));
|
||||
$this->assertTrue(PatternValidator::isValid('/^test$/'));
|
||||
$this->assertTrue(PatternValidator::isValid(TestConstants::PATTERN_VALID_SIMPLE));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isValidReturnsFalseForInvalidPattern(): void
|
||||
{
|
||||
$this->assertFalse(PatternValidator::isValid('invalid'));
|
||||
$this->assertFalse(PatternValidator::isValid('/unclosed'));
|
||||
$this->assertFalse(PatternValidator::isValid(TestConstants::PATTERN_INVALID_UNCLOSED));
|
||||
$this->assertFalse(PatternValidator::isValid('//'));
|
||||
}
|
||||
|
||||
@@ -72,7 +73,7 @@ class PatternValidatorTest extends TestCase
|
||||
public function isValidDetectsNestedQuantifiers(): void
|
||||
{
|
||||
// hasDangerousPattern is private, test via isValid
|
||||
$this->assertFalse(PatternValidator::isValid('/^(a+)+$/'));
|
||||
$this->assertFalse(PatternValidator::isValid(TestConstants::PATTERN_REDOS_VULNERABLE));
|
||||
$this->assertFalse(PatternValidator::isValid('/(a*)*/'));
|
||||
$this->assertFalse(PatternValidator::isValid('/([a-zA-Z]+)*/'));
|
||||
}
|
||||
@@ -82,8 +83,8 @@ class PatternValidatorTest extends TestCase
|
||||
{
|
||||
// hasDangerousPattern is private, test via isValid
|
||||
$this->assertTrue(PatternValidator::isValid(TestConstants::PATTERN_SSN_FORMAT));
|
||||
$this->assertTrue(PatternValidator::isValid('/[a-z]+/'));
|
||||
$this->assertTrue(PatternValidator::isValid('/^test$/'));
|
||||
$this->assertTrue(PatternValidator::isValid(TestConstants::PATTERN_SAFE));
|
||||
$this->assertTrue(PatternValidator::isValid(TestConstants::PATTERN_VALID_SIMPLE));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -91,16 +92,16 @@ class PatternValidatorTest extends TestCase
|
||||
{
|
||||
$patterns = [
|
||||
TestConstants::PATTERN_DIGITS => 'mask1',
|
||||
'/[a-z]+/' => 'mask2',
|
||||
TestConstants::PATTERN_SAFE => 'mask2',
|
||||
];
|
||||
|
||||
PatternValidator::cachePatterns($patterns);
|
||||
$cache = PatternValidator::getCache();
|
||||
|
||||
$this->assertArrayHasKey(TestConstants::PATTERN_DIGITS, $cache);
|
||||
$this->assertArrayHasKey('/[a-z]+/', $cache);
|
||||
$this->assertArrayHasKey(TestConstants::PATTERN_SAFE, $cache);
|
||||
$this->assertTrue($cache[TestConstants::PATTERN_DIGITS]);
|
||||
$this->assertTrue($cache['/[a-z]+/']);
|
||||
$this->assertTrue($cache[TestConstants::PATTERN_SAFE]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -195,7 +196,16 @@ class PatternValidatorTest extends TestCase
|
||||
/**
|
||||
* @return string[][]
|
||||
*
|
||||
* @psalm-return array{'simple digits': array{pattern: TestConstants::PATTERN_DIGITS}, 'email pattern': array{pattern: '/[a-z]+@[a-z]+\.[a-z]+/'}, 'phone pattern': array{pattern: '/\+?\d{1,3}[\s-]?\d{3}[\s-]?\d{4}/'}, 'ssn pattern': array{pattern: TestConstants::PATTERN_SSN_FORMAT}, 'word boundary': array{pattern: '/\b\w+\b/'}, 'case insensitive': array{pattern: '/test/i'}, multiline: array{pattern: '/^test$/m'}, unicode: array{pattern: '/\p{L}+/u'}}
|
||||
* @psalm-return array{
|
||||
* 'simple digits': array{pattern: TestConstants::PATTERN_DIGITS},
|
||||
* 'email pattern': array{pattern: '/[a-z]+@[a-z]+\.[a-z]+/'},
|
||||
* 'phone pattern': array{pattern: '/\+?\d{1,3}[\s-]?\d{3}[\s-]?\d{4}/'},
|
||||
* 'ssn pattern': array{pattern: TestConstants::PATTERN_SSN_FORMAT},
|
||||
* 'word boundary': array{pattern: '/\b\w+\b/'},
|
||||
* 'case insensitive': array{pattern: '/test/i'},
|
||||
* multiline: array{pattern: '/^test$/m'},
|
||||
* unicode: array{pattern: '/\p{L}+/u'}
|
||||
* }
|
||||
*/
|
||||
public static function validPatternProvider(): array
|
||||
{
|
||||
@@ -221,19 +231,208 @@ class PatternValidatorTest extends TestCase
|
||||
/**
|
||||
* @return string[][]
|
||||
*
|
||||
* @psalm-return array{'no delimiters': array{pattern: 'test'}, unclosed: array{pattern: '/unclosed'}, empty: array{pattern: '//'}, 'invalid bracket': array{pattern: '/[invalid/'}, recursive: array{pattern: TestConstants::PATTERN_RECURSIVE}, 'named recursion': array{pattern: TestConstants::PATTERN_NAMED_RECURSION}, 'nested quantifiers': array{pattern: '/^(a+)+$/'}, 'invalid unicode': array{pattern: '/\x{10000000}/'}}
|
||||
* @psalm-return array{
|
||||
* 'no delimiters': array{pattern: 'test'},
|
||||
* unclosed: array{pattern: TestConstants::PATTERN_INVALID_UNCLOSED},
|
||||
* empty: array{pattern: '//'},
|
||||
* 'invalid bracket': array{pattern: '/[invalid/'},
|
||||
* recursive: array{pattern: TestConstants::PATTERN_RECURSIVE},
|
||||
* 'named recursion': array{pattern: TestConstants::PATTERN_NAMED_RECURSION},
|
||||
* 'nested quantifiers': array{pattern: TestConstants::PATTERN_REDOS_VULNERABLE},
|
||||
* 'invalid unicode': array{pattern: '/\x{10000000}/'}
|
||||
* }
|
||||
*/
|
||||
public static function invalidPatternProvider(): array
|
||||
{
|
||||
return [
|
||||
'no delimiters' => ['pattern' => 'test'],
|
||||
'unclosed' => ['pattern' => '/unclosed'],
|
||||
'unclosed' => ['pattern' => TestConstants::PATTERN_INVALID_UNCLOSED],
|
||||
'empty' => ['pattern' => '//'],
|
||||
'invalid bracket' => ['pattern' => '/[invalid/'],
|
||||
'recursive' => ['pattern' => TestConstants::PATTERN_RECURSIVE],
|
||||
'named recursion' => ['pattern' => TestConstants::PATTERN_NAMED_RECURSION],
|
||||
'nested quantifiers' => ['pattern' => '/^(a+)+$/'],
|
||||
'nested quantifiers' => ['pattern' => TestConstants::PATTERN_REDOS_VULNERABLE],
|
||||
'invalid unicode' => ['pattern' => '/\x{10000000}/'],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// INSTANCE METHOD TESTS
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function createReturnsNewInstance(): void
|
||||
{
|
||||
$validator = PatternValidator::create();
|
||||
|
||||
$this->assertInstanceOf(PatternValidator::class, $validator);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validateReturnsTrueForValidPattern(): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
|
||||
$this->assertTrue($validator->validate(TestConstants::PATTERN_DIGITS));
|
||||
$this->assertTrue($validator->validate('/[a-z]+/i'));
|
||||
$this->assertTrue($validator->validate(TestConstants::PATTERN_VALID_SIMPLE));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validateReturnsFalseForInvalidPattern(): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
|
||||
$this->assertFalse($validator->validate('invalid'));
|
||||
$this->assertFalse($validator->validate(TestConstants::PATTERN_INVALID_UNCLOSED));
|
||||
$this->assertFalse($validator->validate('//'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validateReturnsFalseForDangerousPatterns(): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
|
||||
$this->assertFalse($validator->validate(TestConstants::PATTERN_RECURSIVE));
|
||||
$this->assertFalse($validator->validate(TestConstants::PATTERN_NAMED_RECURSION));
|
||||
$this->assertFalse($validator->validate(TestConstants::PATTERN_REDOS_VULNERABLE));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validateUsesCacheOnSecondCall(): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
$pattern = TestConstants::PATTERN_DIGITS;
|
||||
|
||||
// First call should cache
|
||||
$result1 = $validator->validate($pattern);
|
||||
|
||||
// Second call should use cache
|
||||
$result2 = $validator->validate($pattern);
|
||||
|
||||
$this->assertTrue($result1);
|
||||
$this->assertTrue($result2);
|
||||
$this->assertArrayHasKey($pattern, $validator->getInstanceCache());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clearInstanceCacheRemovesAllCachedPatterns(): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
|
||||
$validator->validate(TestConstants::PATTERN_DIGITS);
|
||||
$this->assertNotEmpty($validator->getInstanceCache());
|
||||
|
||||
$validator->clearInstanceCache();
|
||||
$this->assertEmpty($validator->getInstanceCache());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function cacheAllPatternsCachesValidPatterns(): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
$patterns = [
|
||||
TestConstants::PATTERN_DIGITS => 'mask1',
|
||||
TestConstants::PATTERN_SAFE => 'mask2',
|
||||
];
|
||||
|
||||
$validator->cacheAllPatterns($patterns);
|
||||
$cache = $validator->getInstanceCache();
|
||||
|
||||
$this->assertArrayHasKey(TestConstants::PATTERN_DIGITS, $cache);
|
||||
$this->assertArrayHasKey(TestConstants::PATTERN_SAFE, $cache);
|
||||
$this->assertTrue($cache[TestConstants::PATTERN_DIGITS]);
|
||||
$this->assertTrue($cache[TestConstants::PATTERN_SAFE]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function cacheAllPatternsCachesBothValidAndInvalid(): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
$patterns = [
|
||||
'/valid/' => 'mask1',
|
||||
'invalid' => 'mask2',
|
||||
];
|
||||
|
||||
$validator->cacheAllPatterns($patterns);
|
||||
$cache = $validator->getInstanceCache();
|
||||
|
||||
$this->assertTrue($cache['/valid/']);
|
||||
$this->assertFalse($cache['invalid']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validateAllPatternsThrowsForInvalidPattern(): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
|
||||
$this->expectException(InvalidRegexPatternException::class);
|
||||
$this->expectExceptionMessage('Pattern failed validation or is potentially unsafe');
|
||||
|
||||
$validator->validateAllPatterns(['invalid_pattern' => 'mask']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validateAllPatternsPassesForValidPatterns(): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
$patterns = [
|
||||
TestConstants::PATTERN_SSN_FORMAT => 'SSN',
|
||||
'/[a-z]+@[a-z]+\.[a-z]+/' => 'Email',
|
||||
];
|
||||
|
||||
// Should not throw
|
||||
$validator->validateAllPatterns($patterns);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validateAllPatternsThrowsForDangerousPattern(): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
|
||||
$this->expectException(InvalidRegexPatternException::class);
|
||||
|
||||
$validator->validateAllPatterns([TestConstants::PATTERN_RECURSIVE => 'mask']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getInstanceCacheReturnsEmptyArrayInitially(): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
$cache = $validator->getInstanceCache();
|
||||
|
||||
$this->assertIsArray($cache);
|
||||
$this->assertEmpty($cache);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function instanceCachesAreIndependent(): void
|
||||
{
|
||||
$validator1 = new PatternValidator();
|
||||
$validator2 = new PatternValidator();
|
||||
|
||||
$validator1->validate(TestConstants::PATTERN_DIGITS);
|
||||
|
||||
$this->assertNotEmpty($validator1->getInstanceCache());
|
||||
$this->assertEmpty($validator2->getInstanceCache());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('validPatternProvider')]
|
||||
public function validateAcceptsVariousValidPatterns(string $pattern): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
|
||||
$this->assertTrue($validator->validate($pattern));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('invalidPatternProvider')]
|
||||
public function validateRejectsVariousInvalidPatterns(string $pattern): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
|
||||
$this->assertFalse($validator->validate($pattern));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ use Ivuorinen\MonologGdprFilter\PatternValidator;
|
||||
* These tests measure and validate the performance improvements.
|
||||
*
|
||||
* @api
|
||||
* @psalm-suppress DeprecatedMethod - Tests for deprecated PatternValidator API
|
||||
*/
|
||||
class PerformanceBenchmarkTest extends TestCase
|
||||
{
|
||||
@@ -170,7 +171,8 @@ class PerformanceBenchmarkTest extends TestCase
|
||||
// Verify processing worked
|
||||
$this->assertInstanceOf(LogRecord::class, $result);
|
||||
$this->assertCount($size, $result->context);
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, (string) $result->context['item_0'][TestConstants::CONTEXT_EMAIL]);
|
||||
$emailValue = (string) $result->context['item_0'][TestConstants::CONTEXT_EMAIL];
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, $emailValue);
|
||||
|
||||
// Performance should scale reasonably
|
||||
$timePerItem = $duration / (float) $size;
|
||||
@@ -267,7 +269,8 @@ class PerformanceBenchmarkTest extends TestCase
|
||||
// Verify processing worked
|
||||
$this->assertInstanceOf(LogRecord::class, $result);
|
||||
$this->assertCount(2000, $result->context);
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, (string) $result->context['item_0'][TestConstants::CONTEXT_EMAIL]);
|
||||
$emailValue = (string) $result->context['item_0'][TestConstants::CONTEXT_EMAIL];
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, $emailValue);
|
||||
|
||||
// Memory usage should be reasonable even for large datasets
|
||||
$this->assertLessThan(50, $memoryUsed, 'Memory usage should be under 50MB for dataset');
|
||||
|
||||
197
tests/Plugins/AbstractMaskingPluginTest.php
Normal file
197
tests/Plugins/AbstractMaskingPluginTest.php
Normal file
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Plugins;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Contracts\MaskingPluginInterface;
|
||||
use Ivuorinen\MonologGdprFilter\Plugins\AbstractMaskingPlugin;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(AbstractMaskingPlugin::class)]
|
||||
final class AbstractMaskingPluginTest extends TestCase
|
||||
{
|
||||
public function testImplementsInterface(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$this->assertInstanceOf(MaskingPluginInterface::class, $plugin);
|
||||
}
|
||||
|
||||
public function testDefaultPriority(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$this->assertSame(100, $plugin->getPriority());
|
||||
}
|
||||
|
||||
public function testCustomPriority(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(50);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$this->assertSame(50, $plugin->getPriority());
|
||||
}
|
||||
|
||||
public function testPreProcessContextReturnsUnchanged(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$context = ['key' => 'value'];
|
||||
$result = $plugin->preProcessContext($context);
|
||||
|
||||
$this->assertSame($context, $result);
|
||||
}
|
||||
|
||||
public function testPostProcessContextReturnsUnchanged(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$context = ['key' => 'value'];
|
||||
$result = $plugin->postProcessContext($context);
|
||||
|
||||
$this->assertSame($context, $result);
|
||||
}
|
||||
|
||||
public function testPreProcessMessageReturnsUnchanged(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$message = 'test message';
|
||||
$result = $plugin->preProcessMessage($message);
|
||||
|
||||
$this->assertSame($message, $result);
|
||||
}
|
||||
|
||||
public function testPostProcessMessageReturnsUnchanged(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$message = 'test message';
|
||||
$result = $plugin->postProcessMessage($message);
|
||||
|
||||
$this->assertSame($message, $result);
|
||||
}
|
||||
|
||||
public function testGetPatternsReturnsEmptyArray(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$this->assertSame([], $plugin->getPatterns());
|
||||
}
|
||||
|
||||
public function testGetFieldPathsReturnsEmptyArray(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$this->assertSame([], $plugin->getFieldPaths());
|
||||
}
|
||||
|
||||
public function testCanOverridePreProcessContext(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
|
||||
public function preProcessContext(array $context): array
|
||||
{
|
||||
$context['added'] = true;
|
||||
return $context;
|
||||
}
|
||||
};
|
||||
|
||||
$result = $plugin->preProcessContext(['original' => 'value']);
|
||||
|
||||
$this->assertTrue($result['added']);
|
||||
$this->assertSame('value', $result['original']);
|
||||
}
|
||||
|
||||
public function testCanOverridePreProcessMessage(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
|
||||
public function preProcessMessage(string $message): string
|
||||
{
|
||||
return strtoupper($message);
|
||||
}
|
||||
};
|
||||
|
||||
$this->assertSame('HELLO', $plugin->preProcessMessage('hello'));
|
||||
}
|
||||
|
||||
public function testCanOverrideGetPatterns(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
|
||||
public function getPatterns(): array
|
||||
{
|
||||
return ['/secret/' => '[REDACTED]'];
|
||||
}
|
||||
};
|
||||
|
||||
$patterns = $plugin->getPatterns();
|
||||
|
||||
$this->assertArrayHasKey('/secret/', $patterns);
|
||||
$this->assertSame('[REDACTED]', $patterns['/secret/']);
|
||||
}
|
||||
}
|
||||
@@ -236,21 +236,26 @@ class RateLimitedAuditLoggerTest extends TestCase
|
||||
|
||||
// Test that different paths are classified correctly
|
||||
$rateLimitedLogger('json_masked', 'original', TestConstants::DATA_MASKED);
|
||||
$rateLimitedLogger('json_encode_error', 'original', TestConstants::DATA_MASKED); // Should be blocked (same type)
|
||||
// Should be blocked (same type)
|
||||
$rateLimitedLogger('json_encode_error', 'original', TestConstants::DATA_MASKED);
|
||||
|
||||
$this->assertCount(2, $this->logStorage); // 1 successful + 1 rate limit warning
|
||||
// 1 successful + 1 rate limit warning
|
||||
$this->assertCount(2, $this->logStorage);
|
||||
|
||||
$this->logStorage = []; // Reset
|
||||
|
||||
$rateLimitedLogger('conditional_skip', 'original', TestConstants::DATA_MASKED);
|
||||
$rateLimitedLogger('conditional_error', 'original', TestConstants::DATA_MASKED); // Should be blocked (same type)
|
||||
// Should be blocked (same type)
|
||||
$rateLimitedLogger('conditional_error', 'original', TestConstants::DATA_MASKED);
|
||||
|
||||
$this->assertCount(2, $this->logStorage); // 1 successful + 1 rate limit warning
|
||||
// 1 successful + 1 rate limit warning
|
||||
$this->assertCount(2, $this->logStorage);
|
||||
|
||||
$this->logStorage = []; // Reset
|
||||
|
||||
$rateLimitedLogger('regex_error', 'original', TestConstants::DATA_MASKED);
|
||||
$rateLimitedLogger('preg_replace_error', 'original', TestConstants::DATA_MASKED); // Should be blocked (same type)
|
||||
// Should be blocked (same type)
|
||||
$rateLimitedLogger('preg_replace_error', 'original', TestConstants::DATA_MASKED);
|
||||
|
||||
$this->assertCount(2, $this->logStorage); // 1 successful + 1 rate limit warning
|
||||
}
|
||||
|
||||
56
tests/Recovery/FailureModeTest.php
Normal file
56
tests/Recovery/FailureModeTest.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Recovery;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Recovery\FailureMode;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests for FailureMode enum.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class FailureModeTest extends TestCase
|
||||
{
|
||||
public function testEnumValues(): void
|
||||
{
|
||||
$this->assertSame('fail_open', FailureMode::FAIL_OPEN->value);
|
||||
$this->assertSame('fail_closed', FailureMode::FAIL_CLOSED->value);
|
||||
$this->assertSame('fail_safe', FailureMode::FAIL_SAFE->value);
|
||||
}
|
||||
|
||||
public function testGetDescription(): void
|
||||
{
|
||||
$openDesc = FailureMode::FAIL_OPEN->getDescription();
|
||||
$closedDesc = FailureMode::FAIL_CLOSED->getDescription();
|
||||
$safeDesc = FailureMode::FAIL_SAFE->getDescription();
|
||||
|
||||
$this->assertStringContainsString('original', $openDesc);
|
||||
$this->assertStringContainsString('risky', $openDesc);
|
||||
|
||||
$this->assertStringContainsString('redacted', $closedDesc);
|
||||
$this->assertStringContainsString('strict', $closedDesc);
|
||||
|
||||
$this->assertStringContainsString('fallback', $safeDesc);
|
||||
$this->assertStringContainsString('balanced', $safeDesc);
|
||||
}
|
||||
|
||||
public function testRecommended(): void
|
||||
{
|
||||
$recommended = FailureMode::recommended();
|
||||
|
||||
$this->assertSame(FailureMode::FAIL_SAFE, $recommended);
|
||||
}
|
||||
|
||||
public function testAllCasesExist(): void
|
||||
{
|
||||
$cases = FailureMode::cases();
|
||||
|
||||
$this->assertCount(3, $cases);
|
||||
$this->assertContains(FailureMode::FAIL_OPEN, $cases);
|
||||
$this->assertContains(FailureMode::FAIL_CLOSED, $cases);
|
||||
$this->assertContains(FailureMode::FAIL_SAFE, $cases);
|
||||
}
|
||||
}
|
||||
215
tests/Recovery/FallbackMaskStrategyTest.php
Normal file
215
tests/Recovery/FallbackMaskStrategyTest.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Recovery;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants;
|
||||
use Ivuorinen\MonologGdprFilter\Recovery\FailureMode;
|
||||
use Ivuorinen\MonologGdprFilter\Recovery\FallbackMaskStrategy;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use stdClass;
|
||||
use Tests\TestConstants;
|
||||
|
||||
/**
|
||||
* Tests for FallbackMaskStrategy.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class FallbackMaskStrategyTest extends TestCase
|
||||
{
|
||||
public function testDefaultFactory(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
|
||||
$this->assertInstanceOf(FallbackMaskStrategy::class, $strategy);
|
||||
}
|
||||
|
||||
public function testStrictFactory(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::strict();
|
||||
|
||||
$this->assertSame(
|
||||
MaskConstants::MASK_REDACTED,
|
||||
$strategy->getFallback('test', FailureMode::FAIL_SAFE)
|
||||
);
|
||||
}
|
||||
|
||||
public function testStrictFactoryWithCustomMask(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::strict('[REMOVED]');
|
||||
|
||||
$this->assertSame(
|
||||
'[REMOVED]',
|
||||
$strategy->getFallback('test', FailureMode::FAIL_SAFE)
|
||||
);
|
||||
}
|
||||
|
||||
public function testWithMappingsFactory(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::withMappings([
|
||||
'string' => '[CUSTOM_STRING]',
|
||||
'integer' => '[CUSTOM_INT]',
|
||||
]);
|
||||
|
||||
$this->assertSame(
|
||||
'[CUSTOM_STRING]',
|
||||
$strategy->getFallback('test', FailureMode::FAIL_SAFE)
|
||||
);
|
||||
$this->assertSame(
|
||||
'[CUSTOM_INT]',
|
||||
$strategy->getFallback(42, FailureMode::FAIL_SAFE)
|
||||
);
|
||||
}
|
||||
|
||||
public function testFailOpenReturnsOriginal(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
|
||||
$this->assertSame('original', $strategy->getFallback('original', FailureMode::FAIL_OPEN));
|
||||
$this->assertSame(42, $strategy->getFallback(42, FailureMode::FAIL_OPEN));
|
||||
$this->assertSame(['key' => 'value'], $strategy->getFallback(['key' => 'value'], FailureMode::FAIL_OPEN));
|
||||
}
|
||||
|
||||
public function testFailClosedReturnsRedacted(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_REDACTED, $strategy->getFallback('test', FailureMode::FAIL_CLOSED));
|
||||
$this->assertSame(MaskConstants::MASK_REDACTED, $strategy->getFallback(42, FailureMode::FAIL_CLOSED));
|
||||
}
|
||||
|
||||
public function testFailSafeForString(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
|
||||
$shortResult = $strategy->getFallback('short', FailureMode::FAIL_SAFE);
|
||||
$this->assertSame(MaskConstants::MASK_STRING, $shortResult);
|
||||
|
||||
$longString = str_repeat('a', 50);
|
||||
$longResult = $strategy->getFallback($longString, FailureMode::FAIL_SAFE);
|
||||
$this->assertStringContainsString(MaskConstants::MASK_STRING, $longResult);
|
||||
$this->assertStringContainsString('50 chars', $longResult);
|
||||
}
|
||||
|
||||
public function testFailSafeForInteger(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
|
||||
$result = $strategy->getFallback(42, FailureMode::FAIL_SAFE);
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_INT, $result);
|
||||
}
|
||||
|
||||
public function testFailSafeForFloat(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
|
||||
$result = $strategy->getFallback(3.14, FailureMode::FAIL_SAFE);
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_FLOAT, $result);
|
||||
}
|
||||
|
||||
public function testFailSafeForBoolean(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
|
||||
$result = $strategy->getFallback(true, FailureMode::FAIL_SAFE);
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_BOOL, $result);
|
||||
}
|
||||
|
||||
public function testFailSafeForNull(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
|
||||
$result = $strategy->getFallback(null, FailureMode::FAIL_SAFE);
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_NULL, $result);
|
||||
}
|
||||
|
||||
public function testFailSafeForEmptyArray(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
|
||||
$result = $strategy->getFallback([], FailureMode::FAIL_SAFE);
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_ARRAY, $result);
|
||||
}
|
||||
|
||||
public function testFailSafeForNonEmptyArray(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
|
||||
$result = $strategy->getFallback(['a', 'b', 'c'], FailureMode::FAIL_SAFE);
|
||||
|
||||
$this->assertStringContainsString(MaskConstants::MASK_ARRAY, $result);
|
||||
$this->assertStringContainsString('3 items', $result);
|
||||
}
|
||||
|
||||
public function testFailSafeForObject(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
$obj = new stdClass();
|
||||
|
||||
$result = $strategy->getFallback($obj, FailureMode::FAIL_SAFE);
|
||||
|
||||
$this->assertStringContainsString(MaskConstants::MASK_OBJECT, $result);
|
||||
$this->assertStringContainsString('stdClass', $result);
|
||||
}
|
||||
|
||||
public function testFailSafeForResource(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
$resource = fopen('php://memory', 'r');
|
||||
$this->assertNotFalse($resource, 'Failed to open memory stream');
|
||||
|
||||
$result = $strategy->getFallback($resource, FailureMode::FAIL_SAFE);
|
||||
|
||||
fclose($resource);
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_RESOURCE, $result);
|
||||
}
|
||||
|
||||
public function testGetConfiguration(): void
|
||||
{
|
||||
$strategy = new FallbackMaskStrategy(
|
||||
customFallbacks: ['string' => '[CUSTOM]'],
|
||||
defaultFallback: '[DEFAULT]',
|
||||
preserveType: false
|
||||
);
|
||||
|
||||
$config = $strategy->getConfiguration();
|
||||
|
||||
$this->assertArrayHasKey('custom_fallbacks', $config);
|
||||
$this->assertArrayHasKey('default_fallback', $config);
|
||||
$this->assertArrayHasKey('preserve_type', $config);
|
||||
|
||||
$this->assertSame(['string' => '[CUSTOM]'], $config['custom_fallbacks']);
|
||||
$this->assertSame('[DEFAULT]', $config['default_fallback']);
|
||||
$this->assertFalse($config['preserve_type']);
|
||||
}
|
||||
|
||||
public function testPreserveTypeFalseUsesDefault(): void
|
||||
{
|
||||
$strategy = new FallbackMaskStrategy(
|
||||
defaultFallback: TestConstants::MASK_ALWAYS_THIS,
|
||||
preserveType: false
|
||||
);
|
||||
|
||||
$this->assertSame(TestConstants::MASK_ALWAYS_THIS, $strategy->getFallback('string', FailureMode::FAIL_SAFE));
|
||||
$this->assertSame(TestConstants::MASK_ALWAYS_THIS, $strategy->getFallback(42, FailureMode::FAIL_SAFE));
|
||||
$this->assertSame(TestConstants::MASK_ALWAYS_THIS, $strategy->getFallback(['array'], FailureMode::FAIL_SAFE));
|
||||
}
|
||||
|
||||
public function testCustomClosedFallback(): void
|
||||
{
|
||||
$strategy = new FallbackMaskStrategy(
|
||||
customFallbacks: ['closed' => '[CUSTOM_CLOSED]']
|
||||
);
|
||||
|
||||
$result = $strategy->getFallback('test', FailureMode::FAIL_CLOSED);
|
||||
|
||||
$this->assertSame('[CUSTOM_CLOSED]', $result);
|
||||
}
|
||||
}
|
||||
172
tests/Recovery/RecoveryResultTest.php
Normal file
172
tests/Recovery/RecoveryResultTest.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Recovery;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Audit\AuditContext;
|
||||
use Ivuorinen\MonologGdprFilter\Audit\ErrorContext;
|
||||
use Ivuorinen\MonologGdprFilter\Recovery\RecoveryResult;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tests\TestConstants;
|
||||
|
||||
/**
|
||||
* Tests for RecoveryResult value object.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class RecoveryResultTest extends TestCase
|
||||
{
|
||||
public function testSuccessCreation(): void
|
||||
{
|
||||
$result = RecoveryResult::success(TestConstants::MASK_MASKED_BRACKETS, 5.5);
|
||||
|
||||
$this->assertSame(TestConstants::MASK_MASKED_BRACKETS, $result->value);
|
||||
$this->assertSame(RecoveryResult::OUTCOME_SUCCESS, $result->outcome);
|
||||
$this->assertSame(1, $result->attempts);
|
||||
$this->assertSame(5.5, $result->totalDurationMs);
|
||||
$this->assertNull($result->lastError);
|
||||
}
|
||||
|
||||
public function testRecoveredCreation(): void
|
||||
{
|
||||
$result = RecoveryResult::recovered(TestConstants::MASK_MASKED_BRACKETS, 3, 25.0);
|
||||
|
||||
$this->assertSame(TestConstants::MASK_MASKED_BRACKETS, $result->value);
|
||||
$this->assertSame(RecoveryResult::OUTCOME_RECOVERED, $result->outcome);
|
||||
$this->assertSame(3, $result->attempts);
|
||||
$this->assertSame(25.0, $result->totalDurationMs);
|
||||
}
|
||||
|
||||
public function testFallbackCreation(): void
|
||||
{
|
||||
$error = ErrorContext::create('TestError', 'Failed to mask');
|
||||
$result = RecoveryResult::fallback('[REDACTED]', 3, $error, 50.0);
|
||||
|
||||
$this->assertSame('[REDACTED]', $result->value);
|
||||
$this->assertSame(RecoveryResult::OUTCOME_FALLBACK, $result->outcome);
|
||||
$this->assertSame(3, $result->attempts);
|
||||
$this->assertSame($error, $result->lastError);
|
||||
$this->assertSame(50.0, $result->totalDurationMs);
|
||||
}
|
||||
|
||||
public function testFailedCreation(): void
|
||||
{
|
||||
$error = ErrorContext::create('FatalError', 'Cannot recover');
|
||||
$result = RecoveryResult::failed('original', 5, $error, 100.0);
|
||||
|
||||
$this->assertSame('original', $result->value);
|
||||
$this->assertSame(RecoveryResult::OUTCOME_FAILED, $result->outcome);
|
||||
$this->assertSame(5, $result->attempts);
|
||||
$this->assertSame($error, $result->lastError);
|
||||
}
|
||||
|
||||
public function testIsSuccess(): void
|
||||
{
|
||||
$success = RecoveryResult::success(TestConstants::MASK_MASKED_BRACKETS);
|
||||
$recovered = RecoveryResult::recovered(TestConstants::MASK_MASKED_BRACKETS, 2);
|
||||
$error = ErrorContext::create('E', 'M');
|
||||
$fallback = RecoveryResult::fallback('[X]', 3, $error);
|
||||
$failed = RecoveryResult::failed('orig', 3, $error);
|
||||
|
||||
$this->assertTrue($success->isSuccess());
|
||||
$this->assertTrue($recovered->isSuccess());
|
||||
$this->assertFalse($fallback->isSuccess());
|
||||
$this->assertFalse($failed->isSuccess());
|
||||
}
|
||||
|
||||
public function testUsedFallback(): void
|
||||
{
|
||||
$success = RecoveryResult::success(TestConstants::MASK_MASKED_BRACKETS);
|
||||
$error = ErrorContext::create('E', 'M');
|
||||
$fallback = RecoveryResult::fallback('[X]', 3, $error);
|
||||
$failed = RecoveryResult::failed('orig', 3, $error);
|
||||
|
||||
$this->assertFalse($success->usedFallback());
|
||||
$this->assertTrue($fallback->usedFallback());
|
||||
$this->assertFalse($failed->usedFallback());
|
||||
}
|
||||
|
||||
public function testIsFailed(): void
|
||||
{
|
||||
$success = RecoveryResult::success(TestConstants::MASK_MASKED_BRACKETS);
|
||||
$error = ErrorContext::create('E', 'M');
|
||||
$fallback = RecoveryResult::fallback('[X]', 3, $error);
|
||||
$failed = RecoveryResult::failed('orig', 3, $error);
|
||||
|
||||
$this->assertFalse($success->isFailed());
|
||||
$this->assertFalse($fallback->isFailed());
|
||||
$this->assertTrue($failed->isFailed());
|
||||
}
|
||||
|
||||
public function testNeededRetry(): void
|
||||
{
|
||||
$firstTry = RecoveryResult::success(TestConstants::MASK_MASKED_BRACKETS);
|
||||
$secondTry = RecoveryResult::recovered(TestConstants::MASK_MASKED_BRACKETS, 2);
|
||||
|
||||
$this->assertFalse($firstTry->neededRetry());
|
||||
$this->assertTrue($secondTry->neededRetry());
|
||||
}
|
||||
|
||||
public function testToAuditContextSuccess(): void
|
||||
{
|
||||
$result = RecoveryResult::success(TestConstants::MASK_MASKED_BRACKETS, 10.0);
|
||||
$context = $result->toAuditContext(AuditContext::OP_REGEX);
|
||||
|
||||
$this->assertSame(AuditContext::OP_REGEX, $context->operationType);
|
||||
$this->assertSame(AuditContext::STATUS_SUCCESS, $context->status);
|
||||
$this->assertSame(10.0, $context->durationMs);
|
||||
}
|
||||
|
||||
public function testToAuditContextRecovered(): void
|
||||
{
|
||||
$result = RecoveryResult::recovered(TestConstants::MASK_MASKED_BRACKETS, 3, 30.0);
|
||||
$context = $result->toAuditContext(AuditContext::OP_FIELD_PATH);
|
||||
|
||||
$this->assertSame(AuditContext::STATUS_RECOVERED, $context->status);
|
||||
$this->assertSame(3, $context->attemptNumber);
|
||||
}
|
||||
|
||||
public function testToAuditContextFailed(): void
|
||||
{
|
||||
$error = ErrorContext::create('Error', 'Message');
|
||||
$result = RecoveryResult::failed('orig', 3, $error, 50.0);
|
||||
$context = $result->toAuditContext(AuditContext::OP_CALLBACK);
|
||||
|
||||
$this->assertSame(AuditContext::STATUS_FAILED, $context->status);
|
||||
$this->assertSame($error, $context->error);
|
||||
}
|
||||
|
||||
public function testToArray(): void
|
||||
{
|
||||
$result = RecoveryResult::success(TestConstants::MASK_MASKED_BRACKETS, 15.123456);
|
||||
$array = $result->toArray();
|
||||
|
||||
$this->assertArrayHasKey('outcome', $array);
|
||||
$this->assertArrayHasKey('attempts', $array);
|
||||
$this->assertArrayHasKey('duration_ms', $array);
|
||||
$this->assertArrayNotHasKey('error', $array);
|
||||
|
||||
$this->assertSame('success', $array['outcome']);
|
||||
$this->assertSame(1, $array['attempts']);
|
||||
$this->assertSame(15.123, $array['duration_ms']);
|
||||
}
|
||||
|
||||
public function testToArrayWithError(): void
|
||||
{
|
||||
$error = ErrorContext::create('TestError', 'Message');
|
||||
$result = RecoveryResult::failed('orig', 3, $error);
|
||||
$array = $result->toArray();
|
||||
|
||||
$this->assertArrayHasKey('error', $array);
|
||||
$this->assertIsArray($array['error']);
|
||||
}
|
||||
|
||||
public function testOutcomeConstants(): void
|
||||
{
|
||||
$this->assertSame('success', RecoveryResult::OUTCOME_SUCCESS);
|
||||
$this->assertSame('recovered', RecoveryResult::OUTCOME_RECOVERED);
|
||||
$this->assertSame('fallback', RecoveryResult::OUTCOME_FALLBACK);
|
||||
$this->assertSame('failed', RecoveryResult::OUTCOME_FAILED);
|
||||
}
|
||||
}
|
||||
308
tests/Recovery/RetryStrategyTest.php
Normal file
308
tests/Recovery/RetryStrategyTest.php
Normal file
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Recovery;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\MaskingOperationFailedException;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\RecursionDepthExceededException;
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants;
|
||||
use Ivuorinen\MonologGdprFilter\Recovery\FailureMode;
|
||||
use Ivuorinen\MonologGdprFilter\Recovery\RecoveryResult;
|
||||
use Ivuorinen\MonologGdprFilter\Recovery\RetryStrategy;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tests\TestConstants;
|
||||
|
||||
/**
|
||||
* Tests for RetryStrategy.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class RetryStrategyTest extends TestCase
|
||||
{
|
||||
public function testDefaultFactory(): void
|
||||
{
|
||||
$strategy = RetryStrategy::default();
|
||||
|
||||
$this->assertSame(3, $strategy->getMaxAttempts());
|
||||
$this->assertSame(FailureMode::FAIL_SAFE, $strategy->getFailureMode());
|
||||
}
|
||||
|
||||
public function testNoRetryFactory(): void
|
||||
{
|
||||
$strategy = RetryStrategy::noRetry();
|
||||
|
||||
$this->assertSame(1, $strategy->getMaxAttempts());
|
||||
}
|
||||
|
||||
public function testFastFactory(): void
|
||||
{
|
||||
$strategy = RetryStrategy::fast();
|
||||
|
||||
$this->assertSame(2, $strategy->getMaxAttempts());
|
||||
$this->assertSame(FailureMode::FAIL_SAFE, $strategy->getFailureMode());
|
||||
}
|
||||
|
||||
public function testThoroughFactory(): void
|
||||
{
|
||||
$strategy = RetryStrategy::thorough();
|
||||
|
||||
$this->assertSame(5, $strategy->getMaxAttempts());
|
||||
$this->assertSame(FailureMode::FAIL_CLOSED, $strategy->getFailureMode());
|
||||
}
|
||||
|
||||
public function testSuccessfulExecution(): void
|
||||
{
|
||||
$strategy = new RetryStrategy(maxAttempts: 3);
|
||||
$operation = fn(): string => TestConstants::MASK_MASKED_BRACKETS;
|
||||
|
||||
$result = $strategy->execute($operation, 'original', 'test.path');
|
||||
|
||||
$this->assertTrue($result->isSuccess());
|
||||
$this->assertSame(TestConstants::MASK_MASKED_BRACKETS, $result->value);
|
||||
$this->assertSame(1, $result->attempts);
|
||||
$this->assertSame(RecoveryResult::OUTCOME_SUCCESS, $result->outcome);
|
||||
}
|
||||
|
||||
public function testRecoveryAfterRetry(): void
|
||||
{
|
||||
$strategy = new RetryStrategy(
|
||||
maxAttempts: 3,
|
||||
baseDelayMs: 1,
|
||||
maxDelayMs: 5
|
||||
);
|
||||
|
||||
$attemptCount = 0;
|
||||
$operation = function () use (&$attemptCount): string {
|
||||
$attemptCount++;
|
||||
if ($attemptCount < 3) {
|
||||
throw MaskingOperationFailedException::regexMaskingFailed('Temporary failure', TestConstants::PATTERN_TEST, 'test');
|
||||
}
|
||||
return TestConstants::MASK_MASKED_BRACKETS;
|
||||
};
|
||||
|
||||
$result = $strategy->execute($operation, 'original', 'test.path');
|
||||
|
||||
$this->assertTrue($result->isSuccess());
|
||||
$this->assertSame(TestConstants::MASK_MASKED_BRACKETS, $result->value);
|
||||
$this->assertSame(3, $result->attempts);
|
||||
$this->assertSame(RecoveryResult::OUTCOME_RECOVERED, $result->outcome);
|
||||
}
|
||||
|
||||
public function testFallbackAfterAllFailures(): void
|
||||
{
|
||||
$strategy = new RetryStrategy(
|
||||
maxAttempts: 2,
|
||||
baseDelayMs: 1,
|
||||
maxDelayMs: 5,
|
||||
failureMode: FailureMode::FAIL_SAFE
|
||||
);
|
||||
|
||||
$operation = function (): never {
|
||||
throw MaskingOperationFailedException::regexMaskingFailed('Permanent failure', TestConstants::PATTERN_TEST, 'test');
|
||||
};
|
||||
|
||||
$result = $strategy->execute($operation, 'original string', 'test.path');
|
||||
|
||||
$this->assertTrue($result->usedFallback());
|
||||
$this->assertSame(2, $result->attempts);
|
||||
$this->assertNotNull($result->lastError);
|
||||
$this->assertSame(MaskConstants::MASK_STRING, $result->value);
|
||||
}
|
||||
|
||||
public function testFailOpenMode(): void
|
||||
{
|
||||
$strategy = new RetryStrategy(
|
||||
maxAttempts: 1,
|
||||
failureMode: FailureMode::FAIL_OPEN
|
||||
);
|
||||
|
||||
$operation = function (): never {
|
||||
throw MaskingOperationFailedException::regexMaskingFailed('Failure', TestConstants::PATTERN_TEST, 'test');
|
||||
};
|
||||
|
||||
$result = $strategy->execute($operation, 'original', 'test.path');
|
||||
|
||||
$this->assertSame('original', $result->value);
|
||||
}
|
||||
|
||||
public function testFailClosedMode(): void
|
||||
{
|
||||
$strategy = new RetryStrategy(
|
||||
maxAttempts: 1,
|
||||
failureMode: FailureMode::FAIL_CLOSED
|
||||
);
|
||||
|
||||
$operation = function (): never {
|
||||
throw MaskingOperationFailedException::regexMaskingFailed('Failure', TestConstants::PATTERN_TEST, 'test');
|
||||
};
|
||||
|
||||
$result = $strategy->execute($operation, 'original', 'test.path');
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_REDACTED, $result->value);
|
||||
}
|
||||
|
||||
public function testCustomFallbackMask(): void
|
||||
{
|
||||
$strategy = new RetryStrategy(
|
||||
maxAttempts: 1,
|
||||
fallbackMask: '[CUSTOM_FALLBACK]'
|
||||
);
|
||||
|
||||
$operation = function (): never {
|
||||
throw MaskingOperationFailedException::regexMaskingFailed('Failure', TestConstants::PATTERN_TEST, 'test');
|
||||
};
|
||||
|
||||
$result = $strategy->execute($operation, 'original', 'test.path');
|
||||
|
||||
$this->assertSame('[CUSTOM_FALLBACK]', $result->value);
|
||||
}
|
||||
|
||||
public function testNonRecoverableErrorSkipsRetry(): void
|
||||
{
|
||||
$strategy = new RetryStrategy(
|
||||
maxAttempts: 5,
|
||||
baseDelayMs: 1,
|
||||
maxDelayMs: 5
|
||||
);
|
||||
|
||||
$attemptCount = 0;
|
||||
$operation = function () use (&$attemptCount): never {
|
||||
$attemptCount++;
|
||||
throw RecursionDepthExceededException::depthExceeded(100, 50, 'path');
|
||||
};
|
||||
|
||||
$result = $strategy->execute($operation, 'original', 'test.path');
|
||||
|
||||
$this->assertSame(1, $attemptCount);
|
||||
$this->assertTrue($result->usedFallback());
|
||||
}
|
||||
|
||||
public function testIsRecoverableWithRecursionDepthException(): void
|
||||
{
|
||||
$strategy = new RetryStrategy();
|
||||
$exception = RecursionDepthExceededException::depthExceeded(100, 50, 'path');
|
||||
|
||||
$this->assertFalse($strategy->isRecoverable($exception));
|
||||
}
|
||||
|
||||
public function testIsRecoverableWithPatternCompilationError(): void
|
||||
{
|
||||
$strategy = new RetryStrategy();
|
||||
$exception = MaskingOperationFailedException::regexMaskingFailed(
|
||||
TestConstants::PATTERN_TEST,
|
||||
'input',
|
||||
'Pattern compilation failed'
|
||||
);
|
||||
|
||||
$this->assertFalse($strategy->isRecoverable($exception));
|
||||
}
|
||||
|
||||
public function testIsRecoverableWithReDoSError(): void
|
||||
{
|
||||
$strategy = new RetryStrategy();
|
||||
$exception = MaskingOperationFailedException::regexMaskingFailed(
|
||||
TestConstants::PATTERN_TEST,
|
||||
'input',
|
||||
'Potential ReDoS vulnerability detected'
|
||||
);
|
||||
|
||||
$this->assertFalse($strategy->isRecoverable($exception));
|
||||
}
|
||||
|
||||
public function testIsRecoverableWithTransientError(): void
|
||||
{
|
||||
$strategy = new RetryStrategy();
|
||||
$exception = MaskingOperationFailedException::regexMaskingFailed('Temporary failure', TestConstants::PATTERN_TEST, 'test');
|
||||
|
||||
$this->assertTrue($strategy->isRecoverable($exception));
|
||||
}
|
||||
|
||||
public function testGetConfiguration(): void
|
||||
{
|
||||
$strategy = new RetryStrategy(
|
||||
maxAttempts: 5,
|
||||
baseDelayMs: 20,
|
||||
maxDelayMs: 200,
|
||||
failureMode: FailureMode::FAIL_CLOSED,
|
||||
fallbackMask: '[CUSTOM]'
|
||||
);
|
||||
|
||||
$config = $strategy->getConfiguration();
|
||||
|
||||
$this->assertArrayHasKey('max_attempts', $config);
|
||||
$this->assertArrayHasKey('base_delay_ms', $config);
|
||||
$this->assertArrayHasKey('max_delay_ms', $config);
|
||||
$this->assertArrayHasKey('failure_mode', $config);
|
||||
$this->assertArrayHasKey('fallback_mask', $config);
|
||||
|
||||
$this->assertSame(5, $config['max_attempts']);
|
||||
$this->assertSame(20, $config['base_delay_ms']);
|
||||
$this->assertSame(200, $config['max_delay_ms']);
|
||||
$this->assertSame('fail_closed', $config['failure_mode']);
|
||||
$this->assertSame('[CUSTOM]', $config['fallback_mask']);
|
||||
}
|
||||
|
||||
public function testTypeFallbackForInteger(): void
|
||||
{
|
||||
$strategy = new RetryStrategy(
|
||||
maxAttempts: 1,
|
||||
failureMode: FailureMode::FAIL_SAFE
|
||||
);
|
||||
|
||||
$operation = function (): never {
|
||||
throw MaskingOperationFailedException::regexMaskingFailed('Failure', TestConstants::PATTERN_TEST, 'test');
|
||||
};
|
||||
|
||||
$result = $strategy->execute($operation, 42, 'test.path');
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_INT, $result->value);
|
||||
}
|
||||
|
||||
public function testTypeFallbackForArray(): void
|
||||
{
|
||||
$strategy = new RetryStrategy(
|
||||
maxAttempts: 1,
|
||||
failureMode: FailureMode::FAIL_SAFE
|
||||
);
|
||||
|
||||
$operation = function (): never {
|
||||
throw MaskingOperationFailedException::regexMaskingFailed('Failure', TestConstants::PATTERN_TEST, 'test');
|
||||
};
|
||||
|
||||
$result = $strategy->execute($operation, ['key' => 'value'], 'test.path');
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_ARRAY, $result->value);
|
||||
}
|
||||
|
||||
public function testAuditLoggerCalledOnRetry(): void
|
||||
{
|
||||
$auditLogs = [];
|
||||
$auditLogger = function (
|
||||
string $path,
|
||||
mixed $original,
|
||||
mixed $masked
|
||||
) use (&$auditLogs): void {
|
||||
$auditLogs[] = [
|
||||
'path' => $path,
|
||||
'original' => $original,
|
||||
'masked' => $masked
|
||||
];
|
||||
};
|
||||
|
||||
$strategy = new RetryStrategy(
|
||||
maxAttempts: 2,
|
||||
baseDelayMs: 1,
|
||||
maxDelayMs: 2
|
||||
);
|
||||
|
||||
$operation = function (): never {
|
||||
throw MaskingOperationFailedException::regexMaskingFailed('Failure', TestConstants::PATTERN_TEST, 'test');
|
||||
};
|
||||
|
||||
$strategy->execute($operation, 'original', 'test.path', $auditLogger);
|
||||
|
||||
$this->assertNotEmpty($auditLogs);
|
||||
$this->assertStringContainsString('recovery', $auditLogs[0]['path']);
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,8 @@ class RegexMaskProcessorTest extends TestCase
|
||||
);
|
||||
$processor($record);
|
||||
$this->assertNotEmpty($auditCalls);
|
||||
$this->assertSame([TestConstants::FIELD_USER_EMAIL, TestConstants::EMAIL_JOHN_DOE, Mask::MASK_EMAIL], $auditCalls[0]);
|
||||
$expected = [TestConstants::FIELD_USER_EMAIL, TestConstants::EMAIL_JOHN_DOE, Mask::MASK_EMAIL];
|
||||
$this->assertSame($expected, $auditCalls[0]);
|
||||
}
|
||||
|
||||
public function testInvalidRegexPatternThrowsExceptionOnConstruction(): void
|
||||
|
||||
@@ -38,6 +38,7 @@ use Tests\TestConstants;
|
||||
* 5. Laravel Integration - Fixed undefined variables and imports
|
||||
*
|
||||
* @psalm-api
|
||||
* @psalm-suppress DeprecatedMethod - Tests for deprecated validation methods
|
||||
*/
|
||||
#[CoversClass(GdprProcessor::class)]
|
||||
#[CoversClass(RateLimiter::class)]
|
||||
@@ -296,7 +297,11 @@ class ComprehensiveValidationTest extends TestCase
|
||||
fieldPaths: [],
|
||||
customCallbacks: [],
|
||||
auditLogger: function (string $path, mixed $original, mixed $masked): void {
|
||||
$this->auditLog[] = ['path' => $path, 'original' => $original, TestConstants::DATA_MASKED => $masked];
|
||||
$this->auditLog[] = [
|
||||
'path' => $path,
|
||||
'original' => $original,
|
||||
TestConstants::DATA_MASKED => $masked
|
||||
];
|
||||
},
|
||||
maxDepth: 100,
|
||||
dataTypeMasks: [],
|
||||
|
||||
@@ -32,6 +32,7 @@ use stdClass;
|
||||
* Each test method corresponds to a specific bug that was identified and fixed.
|
||||
*
|
||||
* @psalm-api
|
||||
* @psalm-suppress DeprecatedMethod - Tests for deprecated PatternValidator API
|
||||
*/
|
||||
#[CoversClass(GdprProcessor::class)]
|
||||
#[CoversClass(RateLimiter::class)]
|
||||
@@ -124,7 +125,10 @@ class CriticalBugRegressionTest extends TestCase
|
||||
/**
|
||||
* Data provider for PHP type testing
|
||||
*
|
||||
* @psalm-return Generator<string, list{'hello world'|123|bool|float|list{'a', 'b', 'c'}|null|resource|stdClass, string}, mixed, void>
|
||||
* @psalm-return Generator<string, list{
|
||||
* 'hello world'|123|bool|float|list{'a', 'b', 'c'}|null|resource|stdClass,
|
||||
* string
|
||||
* }, mixed, void>
|
||||
*/
|
||||
public static function phpTypesDataProvider(): Generator
|
||||
{
|
||||
|
||||
@@ -39,6 +39,7 @@ use InvalidArgumentException;
|
||||
* - Concurrent access safety
|
||||
*
|
||||
* @psalm-api
|
||||
* @psalm-suppress DeprecatedMethod - Tests for deprecated PatternValidator API
|
||||
*/
|
||||
#[CoversClass(GdprProcessor::class)]
|
||||
#[CoversClass(RateLimiter::class)]
|
||||
|
||||
136
tests/Retention/RetentionPolicyTest.php
Normal file
136
tests/Retention/RetentionPolicyTest.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Retention;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Retention\RetentionPolicy;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(RetentionPolicy::class)]
|
||||
final class RetentionPolicyTest extends TestCase
|
||||
{
|
||||
public function testGetName(): void
|
||||
{
|
||||
$policy = new RetentionPolicy('test_policy', 30);
|
||||
|
||||
$this->assertSame('test_policy', $policy->getName());
|
||||
}
|
||||
|
||||
public function testGetRetentionDays(): void
|
||||
{
|
||||
$policy = new RetentionPolicy('test', 90);
|
||||
|
||||
$this->assertSame(90, $policy->getRetentionDays());
|
||||
}
|
||||
|
||||
public function testGetActionDefaultsToDelete(): void
|
||||
{
|
||||
$policy = new RetentionPolicy('test', 30);
|
||||
|
||||
$this->assertSame(RetentionPolicy::ACTION_DELETE, $policy->getAction());
|
||||
}
|
||||
|
||||
public function testGetActionCustom(): void
|
||||
{
|
||||
$policy = new RetentionPolicy('test', 30, RetentionPolicy::ACTION_ANONYMIZE);
|
||||
|
||||
$this->assertSame(RetentionPolicy::ACTION_ANONYMIZE, $policy->getAction());
|
||||
}
|
||||
|
||||
public function testGetFieldsDefaultsToEmpty(): void
|
||||
{
|
||||
$policy = new RetentionPolicy('test', 30);
|
||||
|
||||
$this->assertSame([], $policy->getFields());
|
||||
}
|
||||
|
||||
public function testGetFieldsCustom(): void
|
||||
{
|
||||
$policy = new RetentionPolicy('test', 30, RetentionPolicy::ACTION_DELETE, ['email', 'phone']);
|
||||
|
||||
$this->assertSame(['email', 'phone'], $policy->getFields());
|
||||
}
|
||||
|
||||
public function testIsWithinRetentionRecent(): void
|
||||
{
|
||||
$policy = new RetentionPolicy('test', 30);
|
||||
$recentDate = new \DateTimeImmutable('-10 days');
|
||||
|
||||
$this->assertTrue($policy->isWithinRetention($recentDate));
|
||||
}
|
||||
|
||||
public function testIsWithinRetentionExpired(): void
|
||||
{
|
||||
$policy = new RetentionPolicy('test', 30);
|
||||
$oldDate = new \DateTimeImmutable('-60 days');
|
||||
|
||||
$this->assertFalse($policy->isWithinRetention($oldDate));
|
||||
}
|
||||
|
||||
public function testIsWithinRetentionBoundary(): void
|
||||
{
|
||||
$policy = new RetentionPolicy('test', 30);
|
||||
$boundaryDate = new \DateTimeImmutable('-29 days');
|
||||
|
||||
$this->assertTrue($policy->isWithinRetention($boundaryDate));
|
||||
}
|
||||
|
||||
public function testGetCutoffDate(): void
|
||||
{
|
||||
$policy = new RetentionPolicy('test', 30);
|
||||
$cutoff = $policy->getCutoffDate();
|
||||
|
||||
$expected = (new \DateTimeImmutable())->modify('-30 days');
|
||||
|
||||
// Allow 1 second tolerance for test execution time
|
||||
$this->assertEqualsWithDelta(
|
||||
$expected->getTimestamp(),
|
||||
$cutoff->getTimestamp(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
public function testGdpr30DaysFactory(): void
|
||||
{
|
||||
$policy = RetentionPolicy::gdpr30Days();
|
||||
|
||||
$this->assertSame('gdpr_standard', $policy->getName());
|
||||
$this->assertSame(30, $policy->getRetentionDays());
|
||||
$this->assertSame(RetentionPolicy::ACTION_DELETE, $policy->getAction());
|
||||
}
|
||||
|
||||
public function testGdpr30DaysFactoryCustomName(): void
|
||||
{
|
||||
$policy = RetentionPolicy::gdpr30Days('custom_gdpr');
|
||||
|
||||
$this->assertSame('custom_gdpr', $policy->getName());
|
||||
}
|
||||
|
||||
public function testArchivalFactory(): void
|
||||
{
|
||||
$policy = RetentionPolicy::archival();
|
||||
|
||||
$this->assertSame('archival', $policy->getName());
|
||||
$this->assertSame(2555, $policy->getRetentionDays()); // ~7 years
|
||||
$this->assertSame(RetentionPolicy::ACTION_ARCHIVE, $policy->getAction());
|
||||
}
|
||||
|
||||
public function testAnonymizeFactory(): void
|
||||
{
|
||||
$policy = RetentionPolicy::anonymize('user_data', 90, ['email', 'name']);
|
||||
|
||||
$this->assertSame('user_data', $policy->getName());
|
||||
$this->assertSame(90, $policy->getRetentionDays());
|
||||
$this->assertSame(RetentionPolicy::ACTION_ANONYMIZE, $policy->getAction());
|
||||
$this->assertSame(['email', 'name'], $policy->getFields());
|
||||
}
|
||||
|
||||
public function testActionConstants(): void
|
||||
{
|
||||
$this->assertSame('delete', RetentionPolicy::ACTION_DELETE);
|
||||
$this->assertSame('anonymize', RetentionPolicy::ACTION_ANONYMIZE);
|
||||
$this->assertSame('archive', RetentionPolicy::ACTION_ARCHIVE);
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,36 @@ class SecuritySanitizerTest extends TestCase
|
||||
/**
|
||||
* @return string[][]
|
||||
*
|
||||
* @psalm-return array{'password with equals': array{input: 'Error: password=secretpass', shouldNotContain: 'secretpass'}, 'api key with underscore': array{input: 'Failed: api_key=key123456', shouldNotContain: 'key123456'}, 'api key with dash': array{input: 'Failed: api-key: key123456', shouldNotContain: 'key123456'}, 'token in header': array{input: 'Request failed: Authorization: Bearer token123abc', shouldNotContain: 'token123abc'}, 'mysql connection string': array{input: 'DB error: mysql://user:pass@localhost:3306', shouldNotContain: 'user:pass'}, 'secret key': array{input: 'Config: secret_key=my-secret-123', shouldNotContain: 'my-secret-123'}, 'private key': array{input: 'Error: private_key=pk_test_12345', shouldNotContain: 'pk_test_12345'}}
|
||||
* @psalm-return array{
|
||||
* 'password with equals': array{
|
||||
* input: 'Error: password=secretpass',
|
||||
* shouldNotContain: 'secretpass'
|
||||
* },
|
||||
* 'api key with underscore': array{
|
||||
* input: 'Failed: api_key=key123456',
|
||||
* shouldNotContain: 'key123456'
|
||||
* },
|
||||
* 'api key with dash': array{
|
||||
* input: 'Failed: api-key: key123456',
|
||||
* shouldNotContain: 'key123456'
|
||||
* },
|
||||
* 'token in header': array{
|
||||
* input: 'Request failed: Authorization: Bearer token123abc',
|
||||
* shouldNotContain: 'token123abc'
|
||||
* },
|
||||
* 'mysql connection string': array{
|
||||
* input: 'DB error: mysql://user:pass@localhost:3306',
|
||||
* shouldNotContain: 'user:pass'
|
||||
* },
|
||||
* 'secret key': array{
|
||||
* input: 'Config: secret_key=my-secret-123',
|
||||
* shouldNotContain: 'my-secret-123'
|
||||
* },
|
||||
* 'private key': array{
|
||||
* input: 'Error: private_key=pk_test_12345',
|
||||
* shouldNotContain: 'pk_test_12345'
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public static function sensitivePatternProvider(): array
|
||||
{
|
||||
@@ -167,4 +196,192 @@ class SecuritySanitizerTest extends TestCase
|
||||
$this->assertStringContainsString(TestConstants::ERROR_TRUNCATED_SECURITY, $sanitized);
|
||||
$this->assertLessThanOrEqual(550, strlen($sanitized)); // 500 + truncation message
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesPwdAndPassVariations(): void
|
||||
{
|
||||
$message = 'Connection failed: pwd=mypassword pass=anotherpass';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringContainsString('pwd=***', $sanitized);
|
||||
$this->assertStringContainsString('pass=***', $sanitized);
|
||||
$this->assertStringNotContainsString('mypassword', $sanitized);
|
||||
$this->assertStringNotContainsString('anotherpass', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesHostServerHostnamePatterns(): void
|
||||
{
|
||||
$message = 'Error: host=db.example.com server=192.168.1.1 hostname=internal.local';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringContainsString('host=***', $sanitized);
|
||||
$this->assertStringContainsString('server=***', $sanitized);
|
||||
$this->assertStringContainsString('hostname=***', $sanitized);
|
||||
$this->assertStringNotContainsString('db.example.com', $sanitized);
|
||||
$this->assertStringNotContainsString('internal.local', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesUsernameAndUidPatterns(): void
|
||||
{
|
||||
$message = 'Auth error: username=admin uid=12345';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringContainsString('username=***', $sanitized);
|
||||
$this->assertStringContainsString('uid=***', $sanitized);
|
||||
$this->assertStringNotContainsString('=admin', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesStripeStyleKeys(): void
|
||||
{
|
||||
$message = 'Stripe error: sk_live_abc123def456 pk_test_xyz789ghi012';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringContainsString('sk_***', $sanitized);
|
||||
$this->assertStringContainsString('pk_***', $sanitized);
|
||||
$this->assertStringNotContainsString('sk_live_abc123def456', $sanitized);
|
||||
$this->assertStringNotContainsString('pk_test_xyz789ghi012', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesRedisConnectionString(): void
|
||||
{
|
||||
$message = 'Redis error: redis://user:pass@localhost:6379';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringContainsString('redis://***:***@***:***', $sanitized);
|
||||
$this->assertStringNotContainsString('user:pass@', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesPostgresqlConnectionString(): void
|
||||
{
|
||||
$message = 'Connection error: postgresql://admin:password@pg.server:5432';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringContainsString('postgresql://***:***@***:***', $sanitized);
|
||||
$this->assertStringNotContainsString('admin:password@', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesJwtSecretPatterns(): void
|
||||
{
|
||||
$message = 'JWT error: jwt_secret=mysupersecret123';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringContainsString('jwt_secret=***', $sanitized);
|
||||
$this->assertStringNotContainsString('mysupersecret123', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesSuperSecretPattern(): void
|
||||
{
|
||||
$message = 'Config contains super_secret_value';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringContainsString(MaskConstants::MASK_SECRET, $sanitized);
|
||||
$this->assertStringNotContainsString('super_secret_value', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('internalIpRangesProvider')]
|
||||
public function sanitizesInternalIpAddresses(string $ip, string $range): void
|
||||
{
|
||||
$message = 'Cannot reach server at ' . $ip;
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringNotContainsString($ip, $sanitized, "Internal IP in $range range should be masked");
|
||||
$this->assertStringContainsString('***.***.***', $sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{ip: string, range: string}>
|
||||
*/
|
||||
public static function internalIpRangesProvider(): array
|
||||
{
|
||||
return [
|
||||
'10.0.0.1' => ['ip' => '10.0.0.1', 'range' => '10.x.x.x'],
|
||||
'10.255.255.255' => ['ip' => '10.255.255.255', 'range' => '10.x.x.x'],
|
||||
'172.16.0.1' => ['ip' => '172.16.0.1', 'range' => '172.16-31.x.x'],
|
||||
'172.31.255.255' => ['ip' => '172.31.255.255', 'range' => '172.16-31.x.x'],
|
||||
'192.168.0.1' => ['ip' => '192.168.0.1', 'range' => '192.168.x.x'],
|
||||
'192.168.255.255' => ['ip' => '192.168.255.255', 'range' => '192.168.x.x'],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function preservesPublicIpAddresses(): void
|
||||
{
|
||||
$message = 'External server at 8.8.8.8 responded';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringContainsString('8.8.8.8', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesSensitiveFilePaths(): void
|
||||
{
|
||||
$message = 'Error reading /var/www/config/secrets.json';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringNotContainsString('/var/www/config', $sanitized);
|
||||
$this->assertStringContainsString('/***/', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesWindowsSensitiveFilePaths(): void
|
||||
{
|
||||
$message = 'Error reading C:\\Users\\admin\\config\\secrets.txt';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
// Windows paths with sensitive keywords are masked
|
||||
$this->assertStringNotContainsString('admin', $sanitized);
|
||||
$this->assertStringNotContainsString('secrets.txt', $sanitized);
|
||||
$this->assertStringContainsString('C:\\***', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesGenericSecretPatterns(): void
|
||||
{
|
||||
$message = 'Config: app_secret=abcd1234567890 encryption_key: xyz9876543210abc';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringNotContainsString('abcd1234567890', $sanitized);
|
||||
$this->assertStringNotContainsString('xyz9876543210abc', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function classCannotBeInstantiated(): void
|
||||
{
|
||||
$reflection = new \ReflectionClass(SecuritySanitizer::class);
|
||||
$constructor = $reflection->getConstructor();
|
||||
|
||||
$this->assertNotNull($constructor);
|
||||
$this->assertTrue($constructor->isPrivate());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesUserPattern(): void
|
||||
{
|
||||
$message = 'Connection failed: user=dbadmin';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringContainsString('user=***', $sanitized);
|
||||
$this->assertStringNotContainsString('dbadmin', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function caseInsensitivePatternMatching(): void
|
||||
{
|
||||
$message = 'Error: PASSWORD=secret HOST=server.local TOKEN=abc123token';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
// Case-insensitive matching means uppercase is matched but replaced with lowercase pattern
|
||||
$this->assertStringNotContainsString('secret', $sanitized);
|
||||
$this->assertStringNotContainsString('server.local', $sanitized);
|
||||
$this->assertStringNotContainsString('abc123token', $sanitized);
|
||||
$this->assertStringContainsString('=***', $sanitized);
|
||||
}
|
||||
}
|
||||
|
||||
270
tests/SerializedDataProcessorTest.php
Normal file
270
tests/SerializedDataProcessorTest.php
Normal file
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants;
|
||||
use Ivuorinen\MonologGdprFilter\SerializedDataProcessor;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tests\TestConstants;
|
||||
|
||||
#[CoversClass(SerializedDataProcessor::class)]
|
||||
final class SerializedDataProcessorTest extends TestCase
|
||||
{
|
||||
private function createProcessor(?callable $auditLogger = null): SerializedDataProcessor
|
||||
{
|
||||
$stringMasker = fn(string $value): string => preg_replace(
|
||||
'/\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b/',
|
||||
MaskConstants::MASK_EMAIL,
|
||||
$value
|
||||
) ?? $value;
|
||||
|
||||
return new SerializedDataProcessor($stringMasker, $auditLogger);
|
||||
}
|
||||
|
||||
public function testProcessEmptyMessage(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$this->assertSame('', $processor->process(''));
|
||||
}
|
||||
|
||||
public function testProcessPlainTextUnchanged(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$message = 'This is a plain text message without serialized data';
|
||||
$this->assertSame($message, $processor->process($message));
|
||||
}
|
||||
|
||||
public function testProcessEmbeddedJsonMasksEmail(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$message = 'User data: {"email":"' . TestConstants::EMAIL_JOHN . '","name":"John"}';
|
||||
$result = $processor->process($message);
|
||||
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, $result);
|
||||
$this->assertStringNotContainsString(TestConstants::EMAIL_JOHN, $result);
|
||||
}
|
||||
|
||||
public function testProcessEmbeddedJsonPreservesStructure(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$message = 'Data: {"id":123,"email":"' . TestConstants::EMAIL_TEST . '"}';
|
||||
$result = $processor->process($message);
|
||||
|
||||
// Should still be valid JSON in the message
|
||||
preg_match('/\{[^}]+\}/', $result, $matches);
|
||||
$this->assertNotEmpty($matches);
|
||||
|
||||
$decoded = json_decode($matches[0], true);
|
||||
$this->assertNotNull($decoded);
|
||||
$this->assertSame(123, $decoded['id']);
|
||||
}
|
||||
|
||||
public function testProcessNestedJson(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$message = 'User: {"user":{"contact":{"email":"' . TestConstants::EMAIL_TEST . '"}}}';
|
||||
$result = $processor->process($message);
|
||||
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, $result);
|
||||
$this->assertStringNotContainsString(TestConstants::EMAIL_TEST, $result);
|
||||
}
|
||||
|
||||
public function testProcessPrintROutput(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$printROutput = 'Array
|
||||
(
|
||||
[name] => John Doe
|
||||
[email] => ' . TestConstants::EMAIL_JOHN . '
|
||||
[age] => 30
|
||||
)';
|
||||
|
||||
$result = $processor->process($printROutput);
|
||||
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, $result);
|
||||
$this->assertStringNotContainsString(TestConstants::EMAIL_JOHN, $result);
|
||||
$this->assertStringContainsString('John Doe', $result); // Name not masked
|
||||
}
|
||||
|
||||
public function testProcessPrintROutputWithNestedArrays(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$printROutput = <<<'PRINT_R'
|
||||
Array
|
||||
(
|
||||
[user] => Array
|
||||
(
|
||||
[email] => user@example.com
|
||||
)
|
||||
)
|
||||
PRINT_R;
|
||||
|
||||
$result = $processor->process($printROutput);
|
||||
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, $result);
|
||||
}
|
||||
|
||||
public function testProcessVarExportOutput(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$varExportOutput = "array (
|
||||
'name' => 'John Doe',
|
||||
'email' => '" . TestConstants::EMAIL_JOHN . "',
|
||||
'active' => true,
|
||||
)";
|
||||
|
||||
$result = $processor->process($varExportOutput);
|
||||
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, $result);
|
||||
$this->assertStringNotContainsString(TestConstants::EMAIL_JOHN, $result);
|
||||
}
|
||||
|
||||
public function testProcessSerializeOutput(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = ['email' => TestConstants::EMAIL_TEST, 'name' => 'Test'];
|
||||
$serialized = serialize($data);
|
||||
|
||||
$result = $processor->process($serialized);
|
||||
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, $result);
|
||||
$this->assertStringNotContainsString(TestConstants::EMAIL_TEST, $result);
|
||||
}
|
||||
|
||||
public function testProcessSerializeOutputUpdatesLength(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
// test@example.com is 16 characters, so s:16:"test@example.com";
|
||||
$email = TestConstants::EMAIL_TEST;
|
||||
$serialized = 's:' . strlen($email) . ':"' . $email . '";';
|
||||
$result = $processor->process($serialized);
|
||||
|
||||
// Should update the length prefix to match the mask
|
||||
$maskLength = strlen(MaskConstants::MASK_EMAIL);
|
||||
$this->assertStringContainsString("s:{$maskLength}:", $result);
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, $result);
|
||||
}
|
||||
|
||||
public function testProcessMixedContent(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$message = 'Log entry: User {"email":"' . TestConstants::EMAIL_TEST . '"} performed action';
|
||||
$result = $processor->process($message);
|
||||
|
||||
$this->assertStringContainsString('Log entry: User', $result);
|
||||
$this->assertStringContainsString('performed action', $result);
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, $result);
|
||||
}
|
||||
|
||||
public function testProcessWithAuditLogger(): void
|
||||
{
|
||||
$logs = [];
|
||||
$auditLogger = function (string $path, mixed $original, mixed $masked) use (&$logs): void {
|
||||
$logs[] = ['path' => $path, 'original' => $original, TestConstants::DATA_MASKED => $masked];
|
||||
};
|
||||
|
||||
$processor = $this->createProcessor($auditLogger);
|
||||
|
||||
$processor->process('{"email":"' . TestConstants::EMAIL_TEST . '"}');
|
||||
|
||||
$this->assertNotEmpty($logs);
|
||||
$this->assertStringContainsString('json', $logs[0]['path']);
|
||||
}
|
||||
|
||||
public function testSetAuditLogger(): void
|
||||
{
|
||||
$logs = [];
|
||||
$auditLogger = function (string $path) use (&$logs): void {
|
||||
$logs[] = ['path' => $path];
|
||||
};
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
$processor->setAuditLogger($auditLogger);
|
||||
|
||||
$processor->process('{"email":"' . TestConstants::EMAIL_TEST . '"}');
|
||||
|
||||
$this->assertNotEmpty($logs);
|
||||
}
|
||||
|
||||
public function testProcessInvalidJsonNotModified(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$message = '{invalid json here}';
|
||||
$result = $processor->process($message);
|
||||
|
||||
$this->assertSame($message, $result);
|
||||
}
|
||||
|
||||
public function testProcessJsonArray(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$message = 'Users: [{"email":"a@example.com"},{"email":"b@example.com"}]';
|
||||
$result = $processor->process($message);
|
||||
|
||||
$this->assertStringNotContainsString('a@example.com', $result);
|
||||
$this->assertStringNotContainsString('b@example.com', $result);
|
||||
}
|
||||
|
||||
public function testProcessDoesNotMaskNonSensitiveData(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$message = '{"status":"ok","count":42}';
|
||||
$result = $processor->process($message);
|
||||
|
||||
// Should remain unchanged since no sensitive data
|
||||
$this->assertSame($message, $result);
|
||||
}
|
||||
|
||||
public function testProcessWithDoubleQuotesInVarExport(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$varExportOutput = 'array (
|
||||
"email" => "' . TestConstants::EMAIL_JOHN . '",
|
||||
)';
|
||||
|
||||
$result = $processor->process($varExportOutput);
|
||||
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, $result);
|
||||
}
|
||||
|
||||
public function testProcessMultipleFormatsInSameMessage(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$message = 'JSON: {"email":"a@example.com"} and serialized: s:16:"b@example.com";';
|
||||
$result = $processor->process($message);
|
||||
|
||||
$this->assertStringNotContainsString('a@example.com', $result);
|
||||
// Note: b@example.com length is 13, not 16, so serialize won't match
|
||||
}
|
||||
|
||||
public function testProcessWithCustomMasker(): void
|
||||
{
|
||||
$customMasker = fn(string $value): string => str_replace('secret', '[REDACTED]', $value);
|
||||
$processor = new SerializedDataProcessor($customMasker);
|
||||
|
||||
$message = '{"data":"this is secret information"}';
|
||||
$result = $processor->process($message);
|
||||
|
||||
$this->assertStringContainsString('[REDACTED]', $result);
|
||||
$this->assertStringNotContainsString('secret', $result);
|
||||
}
|
||||
}
|
||||
@@ -196,20 +196,32 @@ final class AbstractMaskingStrategyTest extends TestCase
|
||||
#[Test]
|
||||
public function pathMatchesReturnsTrueForExactMatch(): void
|
||||
{
|
||||
$this->assertTrue($this->strategy->testPathMatches(TestConstants::FIELD_USER_EMAIL, TestConstants::FIELD_USER_EMAIL));
|
||||
$this->assertTrue($this->strategy->testPathMatches(
|
||||
TestConstants::FIELD_USER_EMAIL,
|
||||
TestConstants::FIELD_USER_EMAIL
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function pathMatchesReturnsFalseForNonMatch(): void
|
||||
{
|
||||
$this->assertFalse($this->strategy->testPathMatches(TestConstants::FIELD_USER_EMAIL, TestConstants::FIELD_USER_PASSWORD));
|
||||
$this->assertFalse($this->strategy->testPathMatches(
|
||||
TestConstants::FIELD_USER_EMAIL,
|
||||
TestConstants::FIELD_USER_PASSWORD
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function pathMatchesSupportsWildcardAtEnd(): void
|
||||
{
|
||||
$this->assertTrue($this->strategy->testPathMatches(TestConstants::FIELD_USER_EMAIL, TestConstants::PATH_USER_WILDCARD));
|
||||
$this->assertTrue($this->strategy->testPathMatches(TestConstants::FIELD_USER_PASSWORD, TestConstants::PATH_USER_WILDCARD));
|
||||
$this->assertTrue($this->strategy->testPathMatches(
|
||||
TestConstants::FIELD_USER_EMAIL,
|
||||
TestConstants::PATH_USER_WILDCARD
|
||||
));
|
||||
$this->assertTrue($this->strategy->testPathMatches(
|
||||
TestConstants::FIELD_USER_PASSWORD,
|
||||
TestConstants::PATH_USER_WILDCARD
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user