From 8866daaf33acf0e66619f0d09d51e71a67e13d73 Mon Sep 17 00:00:00 2001 From: Ismo Vuorinen Date: Mon, 22 Dec 2025 13:38:18 +0200 Subject: [PATCH] feat: add advanced architecture, documentation, and coverage improvements (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .github/workflows/codeql.yml | 6 +- .github/workflows/pr-lint.yml | 5 +- .github/workflows/stale.yml | 2 +- .github/workflows/sync-labels.yml | 5 +- .markdownlint.json | 1 + .markdownlintignore | 4 + TODO.md | 168 ++--- composer.json | 10 +- composer.lock | 355 ++++++----- demo/PatternTester.php | 293 +++++++++ demo/index.php | 270 ++++++++ demo/templates/playground.html | 478 ++++++++++++++ docker/Dockerfile | 37 ++ docker/docker-compose.yml | 30 + docs/docker-development.md | 315 +++++++++ docs/framework-examples.md | 372 +++++++++++ docs/logging-integrations.md | 595 +++++++++++++++++ docs/performance-tuning.md | 453 +++++++++++++ docs/plugin-development.md | 599 ++++++++++++++++++ docs/psr3-decorator.md | 334 ++++++++++ docs/symfony-integration.md | 264 ++++++++ docs/troubleshooting.md | 530 ++++++++++++++++ examples/rate-limiting.php | 2 + src/Anonymization/GeneralizationStrategy.php | 48 ++ src/Anonymization/KAnonymizer.php | 212 +++++++ src/ArrayAccessor/ArrayAccessorFactory.php | 75 +++ src/ArrayAccessor/DotArrayAccessor.php | 80 +++ src/Audit/AuditContext.php | 216 +++++++ src/Audit/ErrorContext.php | 147 +++++ src/Audit/StructuredAuditLogger.php | 232 +++++++ src/Builder/GdprProcessorBuilder.php | 118 ++++ src/Builder/PluginAwareProcessor.php | 121 ++++ .../Traits/CallbackConfigurationTrait.php | 100 +++ .../Traits/FieldPathConfigurationTrait.php | 65 ++ .../Traits/PatternConfigurationTrait.php | 74 +++ .../Traits/PluginConfigurationTrait.php | 67 ++ src/ConditionalRuleFactory.php | 56 +- src/ContextProcessor.php | 10 +- src/Contracts/ArrayAccessorInterface.php | 54 ++ src/Contracts/MaskingPluginInterface.php | 72 +++ .../StreamingOperationFailedException.php | 50 ++ src/Factory/AuditLoggerFactory.php | 126 ++++ src/GdprProcessor.php | 181 ++---- src/InputValidator.php | 1 + src/MaskingOrchestrator.php | 291 +++++++++ src/PatternValidator.php | 303 ++++++--- src/Plugins/AbstractMaskingPlugin.php | 82 +++ src/RateLimitedAuditLogger.php | 28 +- src/RateLimiter.php | 14 +- src/Recovery/FailureMode.php | 57 ++ src/Recovery/FallbackMaskStrategy.php | 178 ++++++ src/Recovery/RecoveryResult.php | 202 ++++++ src/Recovery/RecoveryStrategy.php | 60 ++ src/Recovery/RetryStrategy.php | 307 +++++++++ src/Retention/RetentionPolicy.php | 113 ++++ src/SecuritySanitizer.php | 6 +- src/SerializedDataProcessor.php | 264 ++++++++ src/Strategies/CallbackMaskingStrategy.php | 210 ++++++ src/Strategies/ConditionalMaskingStrategy.php | 9 +- src/Strategies/StrategyManager.php | 22 +- src/Streaming/StreamingProcessor.php | 207 ++++++ tests/Anonymization/KAnonymizerTest.php | 279 ++++++++ .../ArrayAccessorFactoryTest.php | 132 ++++ .../ArrayAccessorInterfaceTest.php | 188 ++++++ tests/Audit/AuditContextTest.php | 190 ++++++ tests/Audit/ErrorContextTest.php | 194 ++++++ tests/Audit/StructuredAuditLoggerTest.php | 207 ++++++ .../GdprProcessorBuilderEdgeCasesTest.php | 427 +++++++++++++ tests/Builder/GdprProcessorBuilderTest.php | 365 +++++++++++ tests/Builder/PluginAwareProcessorTest.php | 370 +++++++++++ tests/ConditionalMaskingTest.php | 15 +- tests/ConditionalRuleFactoryInstanceTest.php | 237 +++++++ tests/ContextProcessorTest.php | 30 +- tests/Exceptions/CustomExceptionsTest.php | 10 +- tests/Factory/AuditLoggerFactoryTest.php | 161 +++++ tests/GdprProcessorComprehensiveTest.php | 3 + tests/GdprProcessorExtendedTest.php | 3 + ...prProcessorRateLimitingIntegrationTest.php | 20 +- tests/GdprProcessorTest.php | 142 ++++- .../InputValidation/ConfigValidationTest.php | 55 +- .../GdprProcessorValidationTest.php | 10 +- tests/InputValidatorTest.php | 7 +- tests/JsonMaskingTest.php | 9 +- tests/MaskingOrchestratorTest.php | 258 ++++++++ tests/PatternValidatorTest.php | 223 ++++++- tests/PerformanceBenchmarkTest.php | 7 +- tests/Plugins/AbstractMaskingPluginTest.php | 197 ++++++ tests/RateLimitedAuditLoggerTest.php | 15 +- tests/Recovery/FailureModeTest.php | 56 ++ tests/Recovery/FallbackMaskStrategyTest.php | 215 +++++++ tests/Recovery/RecoveryResultTest.php | 172 +++++ tests/Recovery/RetryStrategyTest.php | 308 +++++++++ tests/RegexMaskProcessorTest.php | 3 +- .../ComprehensiveValidationTest.php | 7 +- .../CriticalBugRegressionTest.php | 6 +- .../SecurityRegressionTest.php | 1 + tests/Retention/RetentionPolicyTest.php | 136 ++++ tests/SecuritySanitizerTest.php | 219 ++++++- tests/SerializedDataProcessorTest.php | 270 ++++++++ .../AbstractMaskingStrategyTest.php | 20 +- .../CallbackMaskingStrategyTest.php | 253 ++++++++ ...ConditionalMaskingStrategyEnhancedTest.php | 33 +- .../FieldPathMaskingStrategyTest.php | 51 +- tests/Strategies/MaskingStrategiesTest.php | 10 +- .../RegexMaskingStrategyComprehensiveTest.php | 21 +- .../RegexMaskingStrategyEnhancedTest.php | 46 +- tests/Strategies/RegexMaskingStrategyTest.php | 37 +- tests/Strategies/StrategyEdgeCasesTest.php | 484 ++++++++++++++ .../StrategyManagerEnhancedTest.php | 35 +- tests/Streaming/StreamingProcessorTest.php | 249 ++++++++ tests/TestConstants.php | 25 + tests/TestHelpers.php | 1 + 112 files changed, 15391 insertions(+), 607 deletions(-) create mode 100644 .markdownlintignore create mode 100644 demo/PatternTester.php create mode 100644 demo/index.php create mode 100644 demo/templates/playground.html create mode 100644 docker/Dockerfile create mode 100644 docker/docker-compose.yml create mode 100644 docs/docker-development.md create mode 100644 docs/framework-examples.md create mode 100644 docs/logging-integrations.md create mode 100644 docs/performance-tuning.md create mode 100644 docs/plugin-development.md create mode 100644 docs/psr3-decorator.md create mode 100644 docs/symfony-integration.md create mode 100644 docs/troubleshooting.md create mode 100644 src/Anonymization/GeneralizationStrategy.php create mode 100644 src/Anonymization/KAnonymizer.php create mode 100644 src/ArrayAccessor/ArrayAccessorFactory.php create mode 100644 src/ArrayAccessor/DotArrayAccessor.php create mode 100644 src/Audit/AuditContext.php create mode 100644 src/Audit/ErrorContext.php create mode 100644 src/Audit/StructuredAuditLogger.php create mode 100644 src/Builder/GdprProcessorBuilder.php create mode 100644 src/Builder/PluginAwareProcessor.php create mode 100644 src/Builder/Traits/CallbackConfigurationTrait.php create mode 100644 src/Builder/Traits/FieldPathConfigurationTrait.php create mode 100644 src/Builder/Traits/PatternConfigurationTrait.php create mode 100644 src/Builder/Traits/PluginConfigurationTrait.php create mode 100644 src/Contracts/ArrayAccessorInterface.php create mode 100644 src/Contracts/MaskingPluginInterface.php create mode 100644 src/Exceptions/StreamingOperationFailedException.php create mode 100644 src/Factory/AuditLoggerFactory.php create mode 100644 src/MaskingOrchestrator.php create mode 100644 src/Plugins/AbstractMaskingPlugin.php create mode 100644 src/Recovery/FailureMode.php create mode 100644 src/Recovery/FallbackMaskStrategy.php create mode 100644 src/Recovery/RecoveryResult.php create mode 100644 src/Recovery/RecoveryStrategy.php create mode 100644 src/Recovery/RetryStrategy.php create mode 100644 src/Retention/RetentionPolicy.php create mode 100644 src/SerializedDataProcessor.php create mode 100644 src/Strategies/CallbackMaskingStrategy.php create mode 100644 src/Streaming/StreamingProcessor.php create mode 100644 tests/Anonymization/KAnonymizerTest.php create mode 100644 tests/ArrayAccessor/ArrayAccessorFactoryTest.php create mode 100644 tests/ArrayAccessor/ArrayAccessorInterfaceTest.php create mode 100644 tests/Audit/AuditContextTest.php create mode 100644 tests/Audit/ErrorContextTest.php create mode 100644 tests/Audit/StructuredAuditLoggerTest.php create mode 100644 tests/Builder/GdprProcessorBuilderEdgeCasesTest.php create mode 100644 tests/Builder/GdprProcessorBuilderTest.php create mode 100644 tests/Builder/PluginAwareProcessorTest.php create mode 100644 tests/ConditionalRuleFactoryInstanceTest.php create mode 100644 tests/Factory/AuditLoggerFactoryTest.php create mode 100644 tests/MaskingOrchestratorTest.php create mode 100644 tests/Plugins/AbstractMaskingPluginTest.php create mode 100644 tests/Recovery/FailureModeTest.php create mode 100644 tests/Recovery/FallbackMaskStrategyTest.php create mode 100644 tests/Recovery/RecoveryResultTest.php create mode 100644 tests/Recovery/RetryStrategyTest.php create mode 100644 tests/Retention/RetentionPolicyTest.php create mode 100644 tests/SerializedDataProcessorTest.php create mode 100644 tests/Strategies/CallbackMaskingStrategyTest.php create mode 100644 tests/Strategies/StrategyEdgeCasesTest.php create mode 100644 tests/Streaming/StreamingProcessorTest.php diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 63dda92..517638e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -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}}" diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml index aea88c0..da0f1b9 100644 --- a/.github/workflows/pr-lint.yml +++ b/.github/workflows/pr-lint.yml @@ -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 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index d4f07e4..3582455 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -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 diff --git a/.github/workflows/sync-labels.yml b/.github/workflows/sync-labels.yml index 9f1c1c4..d01f325 100644 --- a/.github/workflows/sync-labels.yml +++ b/.github/workflows/sync-labels.yml @@ -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 diff --git a/.markdownlint.json b/.markdownlint.json index 3de10f3..3f2970d 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -8,6 +8,7 @@ "MD024": { "siblings_only": true }, + "MD029": false, "MD033": false, "MD041": false } diff --git a/.markdownlintignore b/.markdownlintignore new file mode 100644 index 0000000..41a5af9 --- /dev/null +++ b/.markdownlintignore @@ -0,0 +1,4 @@ +vendor/ +node_modules/ +coverage/ +.git/ diff --git a/TODO.md b/TODO.md index 427fe6c..9be8bab 100644 --- a/TODO.md +++ b/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 diff --git a/composer.json b/composer.json index ff13074..b83a5ef 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 48b8d27..3318044 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/demo/PatternTester.php b/demo/PatternTester.php new file mode 100644 index 0000000..88f9874 --- /dev/null +++ b/demo/PatternTester.php @@ -0,0 +1,293 @@ + */ + private array $auditLog = []; + + /** + * Test regex patterns against sample text. + * + * @param string $text Sample text to test + * @param array $patterns Regex patterns to apply + * @return array{masked: string, matches: array>, errors: array} + */ + 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 $context Log context to test + * @param array $patterns Custom patterns (or empty for defaults) + * @param array $fieldPaths Field path configurations + * @return array{ + * original_message: string, + * masked_message: string, + * original_context: array, + * masked_context: array, + * audit_log: array, + * errors: array + * } + */ + 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 $context Log context + * @param array $patterns Regex patterns + * @param array $includePaths Paths to include + * @param array $excludePaths Paths to exclude + * @return array + */ + 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 + */ + 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 $fieldPaths + * @return array + */ + 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 $context + * @return array + */ + 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; + } +} diff --git a/demo/index.php b/demo/index.php new file mode 100644 index 0000000..093a02d --- /dev/null +++ b/demo/index.php @@ -0,0 +1,270 @@ + '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 + ?> + + + + + + GDPR Pattern Tester + + + +

GDPR Pattern Tester

+

Test regex patterns for masking sensitive data in log messages.

+ +
+
+

Sample Text

+ + +
+ +
+

Custom Patterns

+ + + +
+ +
+ + +
+ +
+

Results

+
Results will appear here...
+
+ +
+

Default Patterns

+
Loading...
+
+ +
+

Audit Log

+
Audit log will appear here...
+
+
+ + + + + + + + + + GDPR Pattern Tester - Monolog GDPR Filter + + + +
+
+

GDPR Pattern Tester

+

Test and validate regex patterns for masking sensitive data in log messages

+
+ +
+
+

Sample Input

+ + +
+ +
+

Custom Patterns

+ + +
+ + +
+
+ +
+

Actions

+
+ + + + +
+
+ +
+

Masked Output

+
Masked output will appear here...
+
+ +
+

Pattern Matches

+
Matches will appear here...
+
+ +
+

Default Patterns

+
Loading default patterns...
+
+ +
+

Audit Log

+
Audit log entries will appear here...
+
+ +
+

Full Results

+
Complete results will appear here...
+
+
+ + +
+ + + + diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..5d18bc9 --- /dev/null +++ b/docker/Dockerfile @@ -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"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..e373964 --- /dev/null +++ b/docker/docker-compose.yml @@ -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: diff --git a/docs/docker-development.md b/docs/docker-development.md new file mode 100644 index 0000000..7a47d33 --- /dev/null +++ b/docs/docker-development.md @@ -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) diff --git a/docs/framework-examples.md b/docs/framework-examples.md new file mode 100644 index 0000000..868d207 --- /dev/null +++ b/docs/framework-examples.md @@ -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 + '[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 +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 + [ + '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 + [ + '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 +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 +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) diff --git a/docs/logging-integrations.md b/docs/logging-integrations.md new file mode 100644 index 0000000..3f14aee --- /dev/null +++ b/docs/logging-integrations.md @@ -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 +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 +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 +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 +pushProcessor(function ($record) { + $record['extra']['gdpr_processed'] = true; + $record['extra']['app_version'] = '1.0.0'; + return $record; +}); +``` + +## Datadog + +### Datadog Handler Integration + +```php +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 +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 +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 +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 + '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 +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 +setFormatter($formatter); + +$logger = new Logger('my-app'); +$logger->pushHandler($handler); +$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all())); +``` + +## Loggly + +### Loggly Handler Integration + +```php +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 + '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 + [ + 'cloudwatch' => [ + 'driver' => 'custom', + 'via' => App\Logging\CloudWatchLoggerFactory::class, + 'retention' => 14, + 'group' => env('CLOUDWATCH_LOG_GROUP', 'laravel'), + 'stream' => env('CLOUDWATCH_LOG_STREAM', 'app'), + ], + ], +]; +``` + +```php + 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 + '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 +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 + + @type forward + port 24224 + + + + @type elasticsearch + host elasticsearch + port 9200 + index_name app-logs + +``` + +### Fluent Bit with File Tail + +```php +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 +pushHandler($externalHandler); +$logger->pushProcessor(new GdprProcessor($patterns)); // Runs before handlers +``` + +### 2. Add Compliance Metadata + +```php +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 diff --git a/docs/performance-tuning.md b/docs/performance-tuning.md new file mode 100644 index 0000000..bb77f43 --- /dev/null +++ b/docs/performance-tuning.md @@ -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 + '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 + 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 +cacheAllPatterns($patterns); +``` + +## Memory Management + +### 1. Limit Recursion Depth + +```php + [ + '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 +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 + error_log("Masked: $path"), + rateLimiter: $rateLimiter +); +``` + +### 2. Sampling for High-Volume Logging + +```php +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 + ['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 +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 + '[INTERNAL-ID]', // Internal ID format + '/EMP-[A-Z]{2}\d{4}/' => '[EMPLOYEE-ID]', // Employee IDs + ]; + } +} +``` + +### Step 2: Register the Plugin + +```php +withDefaultPatterns() + ->addPlugin(new MyCompanyPlugin()) + ->buildWithPlugins(); +``` + +### Step 3: Use with Monolog + +```php +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 +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 +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); + } +} +``` diff --git a/docs/psr3-decorator.md b/docs/psr3-decorator.md new file mode 100644 index 0000000..dda8d7f --- /dev/null +++ b/docs/psr3-decorator.md @@ -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 +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 +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 +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 +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 +app->extend(LoggerInterface::class, function ($logger) { + $processor = new GdprProcessor( + config('gdpr.patterns', []) + ); + + return new GdprLoggerDecorator($logger, $processor); + }); + } +} +``` + +### Slim Framework + +```php + 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 +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) diff --git a/docs/symfony-integration.md b/docs/symfony-integration.md new file mode 100644 index 0000000..698a5dd --- /dev/null +++ b/docs/symfony-integration.md @@ -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 +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 + + $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 + '[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) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..a315543 --- /dev/null +++ b/docs/troubleshooting.md @@ -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 +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 + ['message' => $line, 'context' => []]; +foreach ($streaming->processFile($largefile, $lineParser) as $record) { + // Process one record at a time +} +``` + +2. Reduce recursion depth: + +```php + [ + Ivuorinen\MonologGdprFilter\Laravel\GdprServiceProvider::class, +], +``` + +2. Check logging configuration: + +```php + [ + '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 +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 + '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 +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 + $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 diff --git a/examples/rate-limiting.php b/examples/rate-limiting.php index f635d86..264d321 100644 --- a/examples/rate-limiting.php +++ b/examples/rate-limiting.php @@ -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' diff --git a/src/Anonymization/GeneralizationStrategy.php b/src/Anonymization/GeneralizationStrategy.php new file mode 100644 index 0000000..af023de --- /dev/null +++ b/src/Anonymization/GeneralizationStrategy.php @@ -0,0 +1,48 @@ +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; + } +} diff --git a/src/Anonymization/KAnonymizer.php b/src/Anonymization/KAnonymizer.php new file mode 100644 index 0000000..4fa9f2c --- /dev/null +++ b/src/Anonymization/KAnonymizer.php @@ -0,0 +1,212 @@ + "20-29") + * - Location generalization (specific address -> region) + * - Date generalization (specific date -> month/year) + * + * @api + */ +final class KAnonymizer +{ + /** + * @var array + */ + 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 $record The record to anonymize + * @return array 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> $records + * @return list> + */ + public function anonymizeBatch(array $records): array + { + return array_map($this->anonymize(...), $records); + } + + /** + * Get registered strategies. + * + * @return array + */ + 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); + } +} diff --git a/src/ArrayAccessor/ArrayAccessorFactory.php b/src/ArrayAccessor/ArrayAccessorFactory.php new file mode 100644 index 0000000..319359e --- /dev/null +++ b/src/ArrayAccessor/ArrayAccessorFactory.php @@ -0,0 +1,75 @@ +|callable(array): ArrayAccessorInterface + */ + private $accessorClass; + + /** + * @param class-string|callable(array): 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 $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 $accessorClass + */ + public static function withClass(string $accessorClass): self + { + return new self($accessorClass); + } + + /** + * Create a factory with a custom callable. + * + * @param callable(array): ArrayAccessorInterface $factory + */ + public static function withCallable(callable $factory): self + { + return new self($factory); + } +} diff --git a/src/ArrayAccessor/DotArrayAccessor.php b/src/ArrayAccessor/DotArrayAccessor.php new file mode 100644 index 0000000..bcb1f7d --- /dev/null +++ b/src/ArrayAccessor/DotArrayAccessor.php @@ -0,0 +1,80 @@ + */ + private readonly Dot $dot; + + /** + * @param array $data Initial data array + */ + public function __construct(array $data = []) + { + $this->dot = new Dot($data); + } + + /** + * Create accessor from an existing array. + * + * @param array $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 + */ + public function getDot(): Dot + { + return $this->dot; + } +} diff --git a/src/Audit/AuditContext.php b/src/Audit/AuditContext.php new file mode 100644 index 0000000..04d61d4 --- /dev/null +++ b/src/Audit/AuditContext.php @@ -0,0 +1,216 @@ + $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 $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 $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 $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 $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 $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 + */ + 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)); + } +} diff --git a/src/Audit/ErrorContext.php b/src/Audit/ErrorContext.php new file mode 100644 index 0000000..6667f11 --- /dev/null +++ b/src/Audit/ErrorContext.php @@ -0,0 +1,147 @@ + $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 $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 + */ + 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; + } +} diff --git a/src/Audit/StructuredAuditLogger.php b/src/Audit/StructuredAuditLogger.php new file mode 100644 index 0000000..7419bc3 --- /dev/null +++ b/src/Audit/StructuredAuditLogger.php @@ -0,0 +1,232 @@ +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; + } +} diff --git a/src/Builder/GdprProcessorBuilder.php b/src/Builder/GdprProcessorBuilder.php new file mode 100644 index 0000000..04a4eb4 --- /dev/null +++ b/src/Builder/GdprProcessorBuilder.php @@ -0,0 +1,118 @@ +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); + } +} diff --git a/src/Builder/PluginAwareProcessor.php b/src/Builder/PluginAwareProcessor.php new file mode 100644 index 0000000..f574581 --- /dev/null +++ b/src/Builder/PluginAwareProcessor.php @@ -0,0 +1,121 @@ + $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 + */ + 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|string $data + * @param int $currentDepth + * @return array|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); + } +} diff --git a/src/Builder/Traits/CallbackConfigurationTrait.php b/src/Builder/Traits/CallbackConfigurationTrait.php new file mode 100644 index 0000000..b551c82 --- /dev/null +++ b/src/Builder/Traits/CallbackConfigurationTrait.php @@ -0,0 +1,100 @@ + + */ + private array $customCallbacks = []; + + /** + * @var array + */ + private array $dataTypeMasks = []; + + /** + * @var array + */ + 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 $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 $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 $rules Name => condition + */ + public function addConditionalRules(array $rules): self + { + $this->conditionalRules = array_merge($this->conditionalRules, $rules); + return $this; + } +} diff --git a/src/Builder/Traits/FieldPathConfigurationTrait.php b/src/Builder/Traits/FieldPathConfigurationTrait.php new file mode 100644 index 0000000..0a9fdfa --- /dev/null +++ b/src/Builder/Traits/FieldPathConfigurationTrait.php @@ -0,0 +1,65 @@ + + */ + 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 $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 $fieldPaths Path => config + */ + public function setFieldPaths(array $fieldPaths): self + { + $this->fieldPaths = $fieldPaths; + return $this; + } + + /** + * Get the current field paths configuration. + * + * @return array + */ + public function getFieldPaths(): array + { + return $this->fieldPaths; + } +} diff --git a/src/Builder/Traits/PatternConfigurationTrait.php b/src/Builder/Traits/PatternConfigurationTrait.php new file mode 100644 index 0000000..a0b072e --- /dev/null +++ b/src/Builder/Traits/PatternConfigurationTrait.php @@ -0,0 +1,74 @@ + + */ + 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 $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 $patterns Regex pattern => replacement + */ + public function setPatterns(array $patterns): self + { + $this->patterns = $patterns; + return $this; + } + + /** + * Get the current patterns configuration. + * + * @return array + */ + 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; + } +} diff --git a/src/Builder/Traits/PluginConfigurationTrait.php b/src/Builder/Traits/PluginConfigurationTrait.php new file mode 100644 index 0000000..d6541f8 --- /dev/null +++ b/src/Builder/Traits/PluginConfigurationTrait.php @@ -0,0 +1,67 @@ + + */ + private array $plugins = []; + + /** + * Register a masking plugin. + */ + public function addPlugin(MaskingPluginInterface $plugin): self + { + $this->plugins[] = $plugin; + return $this; + } + + /** + * Register multiple masking plugins. + * + * @param list $plugins + */ + public function addPlugins(array $plugins): self + { + foreach ($plugins as $plugin) { + $this->plugins[] = $plugin; + } + return $this; + } + + /** + * Get registered plugins. + * + * @return list + */ + 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()); + } + } +} diff --git a/src/ConditionalRuleFactory.php b/src/ConditionalRuleFactory.php index 9b7f0b5..9e024d7 100644 --- a/src/ConditionalRuleFactory.php +++ b/src/ConditionalRuleFactory.php @@ -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; + }; + } } diff --git a/src/ContextProcessor.php b/src/ContextProcessor.php index 8380413..c61731c 100644 --- a/src/ContextProcessor.php +++ b/src/ContextProcessor.php @@ -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 $accessor + * @param ArrayAccessorInterface $accessor * @return string[] Array of processed field paths * @psalm-return list */ - 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 $accessor + * @param ArrayAccessorInterface $accessor * @return string[] Array of processed field paths * @psalm-return list */ - public function processCustomCallbacks(Dot $accessor): array + public function processCustomCallbacks(ArrayAccessorInterface $accessor): array { $processedFields = []; foreach ($this->customCallbacks as $path => $callback) { diff --git a/src/Contracts/ArrayAccessorInterface.php b/src/Contracts/ArrayAccessorInterface.php new file mode 100644 index 0000000..29ef976 --- /dev/null +++ b/src/Contracts/ArrayAccessorInterface.php @@ -0,0 +1,54 @@ + The complete data array + */ + public function all(): array; +} diff --git a/src/Contracts/MaskingPluginInterface.php b/src/Contracts/MaskingPluginInterface.php new file mode 100644 index 0000000..ebcf8e9 --- /dev/null +++ b/src/Contracts/MaskingPluginInterface.php @@ -0,0 +1,72 @@ + $context The context data + * @return array The modified context data + */ + public function preProcessContext(array $context): array; + + /** + * Process context data after standard masking is applied. + * + * @param array $context The masked context data + * @return array 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 Regex pattern => replacement + */ + public function getPatterns(): array; + + /** + * Get additional field paths to mask. + * + * @return array + */ + public function getFieldPaths(): array; + + /** + * Get the plugin's priority (lower = earlier execution). + */ + public function getPriority(): int; +} diff --git a/src/Exceptions/StreamingOperationFailedException.php b/src/Exceptions/StreamingOperationFailedException.php new file mode 100644 index 0000000..5d612db --- /dev/null +++ b/src/Exceptions/StreamingOperationFailedException.php @@ -0,0 +1,50 @@ + '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 + ); + } +} diff --git a/src/Factory/AuditLoggerFactory.php b/src/Factory/AuditLoggerFactory.php new file mode 100644 index 0000000..72c0ce3 --- /dev/null +++ b/src/Factory/AuditLoggerFactory.php @@ -0,0 +1,126 @@ + $logStorage Reference to array for storing logs + * @psalm-param array $logStorage + * @psalm-param-out array}> $logStorage + * @phpstan-param-out array $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 $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); + } +} diff --git a/src/GdprProcessor.php b/src/GdprProcessor.php index cb3599f..7c73e40 100644 --- a/src/GdprProcessor.php +++ b/src/GdprProcessor.php @@ -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 $patterns Regex pattern => replacement @@ -34,18 +38,22 @@ class GdprProcessor implements ProcessorInterface * @param array $dataTypeMasks Type-based masking: type => mask pattern * @param array $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 $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( diff --git a/src/InputValidator.php b/src/InputValidator.php index 1afe47a..2bdb35f 100644 --- a/src/InputValidator.php +++ b/src/InputValidator.php @@ -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, diff --git a/src/MaskingOrchestrator.php b/src/MaskingOrchestrator.php new file mode 100644 index 0000000..eb6ad04 --- /dev/null +++ b/src/MaskingOrchestrator.php @@ -0,0 +1,291 @@ + $patterns Regex pattern => replacement + * @param array $fieldPaths Dot-notation path => FieldMaskConfig + * @param array $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 $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 $patterns Regex pattern => replacement + * @param array $fieldPaths Dot-notation path => FieldMaskConfig + * @param array $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 $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 $context The context data to mask + * @return array{message: string, context: array} + */ + 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 $context The context data to mask + * @return array + */ + 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|string $data + * @param int $currentDepth Current recursion depth + * @return array|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); + } +} diff --git a/src/PatternValidator.php b/src/PatternValidator.php index e6468d4..63eb0e4 100644 --- a/src/PatternValidator.php +++ b/src/PatternValidator.php @@ -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 + */ + private array $instanceCache = []; + + /** + * Static cache for compiled regex patterns (for backward compatibility). * @var array */ private static array $validPatternCache = []; /** - * Clear the pattern validation cache (useful for testing). + * Dangerous pattern checks. + * @var list */ - 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 $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 $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 + */ + 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 $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 $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 + * @deprecated Use instance method getInstanceCache() instead */ public static function getCache(): array { diff --git a/src/Plugins/AbstractMaskingPlugin.php b/src/Plugins/AbstractMaskingPlugin.php new file mode 100644 index 0000000..b5b7e8f --- /dev/null +++ b/src/Plugins/AbstractMaskingPlugin.php @@ -0,0 +1,82 @@ +priority; + } +} diff --git a/src/RateLimitedAuditLogger.php b/src/RateLimitedAuditLogger.php index 20c95bc..a22c242 100644 --- a/src/RateLimitedAuditLogger.php +++ b/src/RateLimitedAuditLogger.php @@ -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 { diff --git a/src/RateLimiter.php b/src/RateLimiter.php index 1db546f..63f2cb8 100644 --- a/src/RateLimiter.php +++ b/src/RateLimiter.php @@ -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, last_cleanup: int, cleanup_interval: int} + * @psalm-return array{ + * total_keys: int<0, max>, + * total_timestamps: int, + * estimated_memory_bytes: int, + * last_cleanup: int, + * cleanup_interval: int + * } */ public static function getMemoryStats(): array { diff --git a/src/Recovery/FailureMode.php b/src/Recovery/FailureMode.php new file mode 100644 index 0000000..75666c6 --- /dev/null +++ b/src/Recovery/FailureMode.php @@ -0,0 +1,57 @@ + '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; + } +} diff --git a/src/Recovery/FallbackMaskStrategy.php b/src/Recovery/FallbackMaskStrategy.php new file mode 100644 index 0000000..5ce8718 --- /dev/null +++ b/src/Recovery/FallbackMaskStrategy.php @@ -0,0 +1,178 @@ + $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 $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 $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 + */ + public function getConfiguration(): array + { + return [ + 'custom_fallbacks' => $this->customFallbacks, + 'default_fallback' => $this->defaultFallback, + 'preserve_type' => $this->preserveType, + ]; + } +} diff --git a/src/Recovery/RecoveryResult.php b/src/Recovery/RecoveryResult.php new file mode 100644 index 0000000..37c81ff --- /dev/null +++ b/src/Recovery/RecoveryResult.php @@ -0,0 +1,202 @@ +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 + */ + 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; + } +} diff --git a/src/Recovery/RecoveryStrategy.php b/src/Recovery/RecoveryStrategy.php new file mode 100644 index 0000000..8c94548 --- /dev/null +++ b/src/Recovery/RecoveryStrategy.php @@ -0,0 +1,60 @@ + + */ + public function getConfiguration(): array; +} diff --git a/src/Recovery/RetryStrategy.php b/src/Recovery/RetryStrategy.php new file mode 100644 index 0000000..df8a348 --- /dev/null +++ b/src/Recovery/RetryStrategy.php @@ -0,0 +1,307 @@ +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); + } +} diff --git a/src/Retention/RetentionPolicy.php b/src/Retention/RetentionPolicy.php new file mode 100644 index 0000000..7ff6283 --- /dev/null +++ b/src/Retention/RetentionPolicy.php @@ -0,0 +1,113 @@ + $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 + */ + 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 $fields Fields to anonymize + */ + public static function anonymize(string $name, int $days, array $fields = []): self + { + return new self($name, $days, self::ACTION_ANONYMIZE, $fields); + } +} diff --git a/src/SecuritySanitizer.php b/src/SecuritySanitizer.php index 15f8093..7225151 100644 --- a/src/SecuritySanitizer.php +++ b/src/SecuritySanitizer.php @@ -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; diff --git a/src/SerializedDataProcessor.php b/src/SerializedDataProcessor.php new file mode 100644 index 0000000..11c2402 --- /dev/null +++ b/src/SerializedDataProcessor.php @@ -0,0 +1,264 @@ + 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; + } +} diff --git a/src/Strategies/CallbackMaskingStrategy.php b/src/Strategies/CallbackMaskingStrategy.php new file mode 100644 index 0000000..9e1816f --- /dev/null +++ b/src/Strategies/CallbackMaskingStrategy.php @@ -0,0 +1,210 @@ + $fieldPath, + 'exact_match' => $exactMatch, + ]); + + $this->callback = $callback; + } + + /** + * Create a strategy for multiple field paths with the same callback. + * + * @param array $fieldPaths Array of field paths + * @param callable(mixed): mixed $callback The masking callback + * @param int $priority Strategy priority + * @return array 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, + ]; + } +} diff --git a/src/Strategies/ConditionalMaskingStrategy.php b/src/Strategies/ConditionalMaskingStrategy.php index db804f8..9d3a7be 100644 --- a/src/Strategies/ConditionalMaskingStrategy.php +++ b/src/Strategies/ConditionalMaskingStrategy.php @@ -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 + ); } /** diff --git a/src/Strategies/StrategyManager.php b/src/Strategies/StrategyManager.php index c15dd7d..1881b44 100644 --- a/src/Strategies/StrategyManager.php +++ b/src/Strategies/StrategyManager.php @@ -243,7 +243,27 @@ class StrategyManager * * @return (((array|int|string)[]|int)[]|int)[] * - * @psalm-return array{total_strategies: int<0, max>, strategy_types: array, 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, configuration: array},...}} + * @psalm-return array{ + * total_strategies: int<0, max>, + * strategy_types: array, + * 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, + * configuration: array + * }, + * ... + * } + * } */ public function getStatistics(): array { diff --git a/src/Streaming/StreamingProcessor.php b/src/Streaming/StreamingProcessor.php new file mode 100644 index 0000000..79ff697 --- /dev/null +++ b/src/Streaming/StreamingProcessor.php @@ -0,0 +1,207 @@ +auditLogger = $auditLogger; + } + + /** + * Process a generator of records. + * + * @param iterable}> $records + * @return \Generator}> + */ + 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} $lineParser + * @return \Generator}> + */ + 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}> $records + * @param string $outputPath Path to output file + * @param callable(array{message: string, context: array}):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}> $chunk + * @return \Generator}> + */ + 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}> $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; + } +} diff --git a/tests/Anonymization/KAnonymizerTest.php b/tests/Anonymization/KAnonymizerTest.php new file mode 100644 index 0000000..30b10d0 --- /dev/null +++ b/tests/Anonymization/KAnonymizerTest.php @@ -0,0 +1,279 @@ +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()); + } +} diff --git a/tests/ArrayAccessor/ArrayAccessorFactoryTest.php b/tests/ArrayAccessor/ArrayAccessorFactoryTest.php new file mode 100644 index 0000000..3f3b8e2 --- /dev/null +++ b/tests/ArrayAccessor/ArrayAccessorFactoryTest.php @@ -0,0 +1,132 @@ +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')); + } +} diff --git a/tests/ArrayAccessor/ArrayAccessorInterfaceTest.php b/tests/ArrayAccessor/ArrayAccessorInterfaceTest.php new file mode 100644 index 0000000..3c49f20 --- /dev/null +++ b/tests/ArrayAccessor/ArrayAccessorInterfaceTest.php @@ -0,0 +1,188 @@ +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')); + } +} diff --git a/tests/Audit/AuditContextTest.php b/tests/Audit/AuditContextTest.php new file mode 100644 index 0000000..36ccba1 --- /dev/null +++ b/tests/Audit/AuditContextTest.php @@ -0,0 +1,190 @@ + '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); + } +} diff --git a/tests/Audit/ErrorContextTest.php b/tests/Audit/ErrorContextTest.php new file mode 100644 index 0000000..f0cabb2 --- /dev/null +++ b/tests/Audit/ErrorContextTest.php @@ -0,0 +1,194 @@ + '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); + } +} diff --git a/tests/Audit/StructuredAuditLoggerTest.php b/tests/Audit/StructuredAuditLoggerTest.php new file mode 100644 index 0000000..b6c8d9a --- /dev/null +++ b/tests/Audit/StructuredAuditLoggerTest.php @@ -0,0 +1,207 @@ + */ + 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); + } +} diff --git a/tests/Builder/GdprProcessorBuilderEdgeCasesTest.php b/tests/Builder/GdprProcessorBuilderEdgeCasesTest.php new file mode 100644 index 0000000..023a293 --- /dev/null +++ b/tests/Builder/GdprProcessorBuilderEdgeCasesTest.php @@ -0,0 +1,427 @@ + $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); + } +} diff --git a/tests/Builder/GdprProcessorBuilderTest.php b/tests/Builder/GdprProcessorBuilderTest.php new file mode 100644 index 0000000..84c3fbf --- /dev/null +++ b/tests/Builder/GdprProcessorBuilderTest.php @@ -0,0 +1,365 @@ +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); + } +} diff --git a/tests/Builder/PluginAwareProcessorTest.php b/tests/Builder/PluginAwareProcessorTest.php new file mode 100644 index 0000000..42e383e --- /dev/null +++ b/tests/Builder/PluginAwareProcessorTest.php @@ -0,0 +1,370 @@ +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); + } +} diff --git a/tests/ConditionalMaskingTest.php b/tests/ConditionalMaskingTest.php index 1f6ae37..fa55d9e 100644 --- a/tests/ConditionalMaskingTest.php +++ b/tests/ConditionalMaskingTest.php @@ -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( diff --git a/tests/ConditionalRuleFactoryInstanceTest.php b/tests/ConditionalRuleFactoryInstanceTest.php new file mode 100644 index 0000000..e3915f7 --- /dev/null +++ b/tests/ConditionalRuleFactoryInstanceTest.php @@ -0,0 +1,237 @@ + $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)); + } +} diff --git a/tests/ContextProcessorTest.php b/tests/ContextProcessorTest.php index 816d8a4..ba2e1f9 100644 --- a/tests/ContextProcessorTest.php +++ b/tests/ContextProcessorTest.php @@ -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 diff --git a/tests/Exceptions/CustomExceptionsTest.php b/tests/Exceptions/CustomExceptionsTest.php index d3d5166..bcbec42 100644 --- a/tests/Exceptions/CustomExceptionsTest.php +++ b/tests/Exceptions/CustomExceptionsTest.php @@ -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 diff --git a/tests/Factory/AuditLoggerFactoryTest.php b/tests/Factory/AuditLoggerFactoryTest.php new file mode 100644 index 0000000..0c36358 --- /dev/null +++ b/tests/Factory/AuditLoggerFactoryTest.php @@ -0,0 +1,161 @@ +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']); + } +} diff --git a/tests/GdprProcessorComprehensiveTest.php b/tests/GdprProcessorComprehensiveTest.php index b4c5076..5c33ef7 100644 --- a/tests/GdprProcessorComprehensiveTest.php +++ b/tests/GdprProcessorComprehensiveTest.php @@ -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 { diff --git a/tests/GdprProcessorExtendedTest.php b/tests/GdprProcessorExtendedTest.php index 099dad1..4cbfc7d 100644 --- a/tests/GdprProcessorExtendedTest.php +++ b/tests/GdprProcessorExtendedTest.php @@ -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 { diff --git a/tests/GdprProcessorRateLimitingIntegrationTest.php b/tests/GdprProcessorRateLimitingIntegrationTest.php index 12578af..9af5c73 100644 --- a/tests/GdprProcessorRateLimitingIntegrationTest.php +++ b/tests/GdprProcessorRateLimitingIntegrationTest.php @@ -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 ); diff --git a/tests/GdprProcessorTest.php b/tests/GdprProcessorTest.php index 0f7b0e7..47e73a7 100644 --- a/tests/GdprProcessorTest.php +++ b/tests/GdprProcessorTest.php @@ -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); + } } diff --git a/tests/InputValidation/ConfigValidationTest.php b/tests/InputValidation/ConfigValidationTest.php index dfa58be..cfa1c66 100644 --- a/tests/InputValidation/ConfigValidationTest.php +++ b/tests/InputValidation/ConfigValidationTest.php @@ -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, field_paths: array, custom_callbacks: array, 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, + * field_paths: array, + * custom_callbacks: array, + * 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) { diff --git a/tests/InputValidation/GdprProcessorValidationTest.php b/tests/InputValidation/GdprProcessorValidationTest.php index 1365628..092abe0 100644 --- a/tests/InputValidation/GdprProcessorValidationTest.php +++ b/tests/InputValidation/GdprProcessorValidationTest.php @@ -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 ]; diff --git a/tests/InputValidatorTest.php b/tests/InputValidatorTest.php index 1bb2ca6..abd0802 100644 --- a/tests/InputValidatorTest.php +++ b/tests/InputValidatorTest.php @@ -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); diff --git a/tests/JsonMaskingTest.php b/tests/JsonMaskingTest.php index 8bd2b1d..d52a6d0 100644 --- a/tests/JsonMaskingTest.php +++ b/tests/JsonMaskingTest.php @@ -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); } diff --git a/tests/MaskingOrchestratorTest.php b/tests/MaskingOrchestratorTest.php new file mode 100644 index 0000000..f261d45 --- /dev/null +++ b/tests/MaskingOrchestratorTest.php @@ -0,0 +1,258 @@ + 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']); + } +} diff --git a/tests/PatternValidatorTest.php b/tests/PatternValidatorTest.php index e238fd2..e9b6852 100644 --- a/tests/PatternValidatorTest.php +++ b/tests/PatternValidatorTest.php @@ -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)); + } } diff --git a/tests/PerformanceBenchmarkTest.php b/tests/PerformanceBenchmarkTest.php index 8a170c0..56c8cc3 100644 --- a/tests/PerformanceBenchmarkTest.php +++ b/tests/PerformanceBenchmarkTest.php @@ -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'); diff --git a/tests/Plugins/AbstractMaskingPluginTest.php b/tests/Plugins/AbstractMaskingPluginTest.php new file mode 100644 index 0000000..1f36fb9 --- /dev/null +++ b/tests/Plugins/AbstractMaskingPluginTest.php @@ -0,0 +1,197 @@ +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/']); + } +} diff --git a/tests/RateLimitedAuditLoggerTest.php b/tests/RateLimitedAuditLoggerTest.php index f41415b..0080986 100644 --- a/tests/RateLimitedAuditLoggerTest.php +++ b/tests/RateLimitedAuditLoggerTest.php @@ -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 } diff --git a/tests/Recovery/FailureModeTest.php b/tests/Recovery/FailureModeTest.php new file mode 100644 index 0000000..8553c0e --- /dev/null +++ b/tests/Recovery/FailureModeTest.php @@ -0,0 +1,56 @@ +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); + } +} diff --git a/tests/Recovery/FallbackMaskStrategyTest.php b/tests/Recovery/FallbackMaskStrategyTest.php new file mode 100644 index 0000000..83e7ac6 --- /dev/null +++ b/tests/Recovery/FallbackMaskStrategyTest.php @@ -0,0 +1,215 @@ +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); + } +} diff --git a/tests/Recovery/RecoveryResultTest.php b/tests/Recovery/RecoveryResultTest.php new file mode 100644 index 0000000..90dd901 --- /dev/null +++ b/tests/Recovery/RecoveryResultTest.php @@ -0,0 +1,172 @@ +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); + } +} diff --git a/tests/Recovery/RetryStrategyTest.php b/tests/Recovery/RetryStrategyTest.php new file mode 100644 index 0000000..d5d72f8 --- /dev/null +++ b/tests/Recovery/RetryStrategyTest.php @@ -0,0 +1,308 @@ +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']); + } +} diff --git a/tests/RegexMaskProcessorTest.php b/tests/RegexMaskProcessorTest.php index 735f43b..99fddfb 100644 --- a/tests/RegexMaskProcessorTest.php +++ b/tests/RegexMaskProcessorTest.php @@ -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 diff --git a/tests/RegressionTests/ComprehensiveValidationTest.php b/tests/RegressionTests/ComprehensiveValidationTest.php index 626a553..870845e 100644 --- a/tests/RegressionTests/ComprehensiveValidationTest.php +++ b/tests/RegressionTests/ComprehensiveValidationTest.php @@ -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: [], diff --git a/tests/RegressionTests/CriticalBugRegressionTest.php b/tests/RegressionTests/CriticalBugRegressionTest.php index 32b7836..910d7e7 100644 --- a/tests/RegressionTests/CriticalBugRegressionTest.php +++ b/tests/RegressionTests/CriticalBugRegressionTest.php @@ -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 + * @psalm-return Generator */ public static function phpTypesDataProvider(): Generator { diff --git a/tests/RegressionTests/SecurityRegressionTest.php b/tests/RegressionTests/SecurityRegressionTest.php index fa1b495..79f8a21 100644 --- a/tests/RegressionTests/SecurityRegressionTest.php +++ b/tests/RegressionTests/SecurityRegressionTest.php @@ -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)] diff --git a/tests/Retention/RetentionPolicyTest.php b/tests/Retention/RetentionPolicyTest.php new file mode 100644 index 0000000..71f6ec0 --- /dev/null +++ b/tests/Retention/RetentionPolicyTest.php @@ -0,0 +1,136 @@ +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); + } +} diff --git a/tests/SecuritySanitizerTest.php b/tests/SecuritySanitizerTest.php index 70674c4..f162513 100644 --- a/tests/SecuritySanitizerTest.php +++ b/tests/SecuritySanitizerTest.php @@ -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 + */ + 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); + } } diff --git a/tests/SerializedDataProcessorTest.php b/tests/SerializedDataProcessorTest.php new file mode 100644 index 0000000..f2d3d40 --- /dev/null +++ b/tests/SerializedDataProcessorTest.php @@ -0,0 +1,270 @@ + 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); + } +} diff --git a/tests/Strategies/AbstractMaskingStrategyTest.php b/tests/Strategies/AbstractMaskingStrategyTest.php index 27f3fff..a9bc5d1 100644 --- a/tests/Strategies/AbstractMaskingStrategyTest.php +++ b/tests/Strategies/AbstractMaskingStrategyTest.php @@ -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] diff --git a/tests/Strategies/CallbackMaskingStrategyTest.php b/tests/Strategies/CallbackMaskingStrategyTest.php new file mode 100644 index 0000000..6a03cba --- /dev/null +++ b/tests/Strategies/CallbackMaskingStrategyTest.php @@ -0,0 +1,253 @@ + TestConstants::MASK_MASKED_BRACKETS; + $strategy = new CallbackMaskingStrategy('user.email', $callback); + + $this->assertSame('user.email', $strategy->getFieldPath()); + $this->assertTrue($strategy->isExactMatch()); + $this->assertSame(50, $strategy->getPriority()); + } + + public function testMaskWithSimpleCallback(): void + { + $callback = fn(mixed $value): string => TestConstants::MASK_MASKED_BRACKETS; + $strategy = new CallbackMaskingStrategy('user.email', $callback); + $record = $this->createLogRecord(); + + $result = $strategy->mask('john@example.com', 'user.email', $record); + + $this->assertSame(TestConstants::MASK_MASKED_BRACKETS, $result); + } + + public function testMaskWithTransformingCallback(): void + { + $callback = fn(mixed $value): string => strtoupper((string) $value); + $strategy = new CallbackMaskingStrategy('user.name', $callback); + $record = $this->createLogRecord(); + + $result = $strategy->mask('john', 'user.name', $record); + + $this->assertSame('JOHN', $result); + } + + public function testMaskThrowsOnCallbackException(): void + { + $callback = function (): never { + throw new RuleExecutionException('Callback failed'); + }; + $strategy = new CallbackMaskingStrategy('user.data', $callback); + $record = $this->createLogRecord(); + + $this->expectException(MaskingOperationFailedException::class); + $this->expectExceptionMessage('Callback threw exception'); + + $strategy->mask('value', 'user.data', $record); + } + + public function testShouldApplyWithExactMatch(): void + { + $callback = fn(mixed $value): string => TestConstants::MASK_MASKED_BRACKETS; + $strategy = new CallbackMaskingStrategy( + 'user.email', + $callback, + exactMatch: true + ); + $record = $this->createLogRecord(); + + $this->assertTrue($strategy->shouldApply('value', 'user.email', $record)); + $this->assertFalse($strategy->shouldApply('value', 'user.name', $record)); + $this->assertFalse($strategy->shouldApply('value', 'user.email.work', $record)); + } + + public function testShouldApplyWithWildcardMatch(): void + { + $callback = fn(mixed $value): string => TestConstants::MASK_MASKED_BRACKETS; + $strategy = new CallbackMaskingStrategy( + 'user.*', + $callback, + exactMatch: false + ); + $record = $this->createLogRecord(); + + $this->assertTrue($strategy->shouldApply('value', 'user.email', $record)); + $this->assertTrue($strategy->shouldApply('value', 'user.name', $record)); + $this->assertFalse($strategy->shouldApply('value', 'admin.email', $record)); + } + + public function testGetName(): void + { + $callback = fn(mixed $value): string => TestConstants::MASK_MASKED_BRACKETS; + $strategy = new CallbackMaskingStrategy('user.email', $callback); + + $name = $strategy->getName(); + + $this->assertStringContainsString('Callback Masking', $name); + $this->assertStringContainsString('user.email', $name); + } + + public function testValidate(): void + { + $callback = fn(mixed $value): string => TestConstants::MASK_MASKED_BRACKETS; + $strategy = new CallbackMaskingStrategy('user.email', $callback); + + $this->assertTrue($strategy->validate()); + } + + public function testGetConfiguration(): void + { + $callback = fn(mixed $value): string => TestConstants::MASK_MASKED_BRACKETS; + $strategy = new CallbackMaskingStrategy( + 'user.email', + $callback, + 75, + false + ); + + $config = $strategy->getConfiguration(); + + $this->assertArrayHasKey('field_path', $config); + $this->assertArrayHasKey('exact_match', $config); + $this->assertArrayHasKey('priority', $config); + $this->assertSame('user.email', $config['field_path']); + $this->assertFalse($config['exact_match']); + $this->assertSame(75, $config['priority']); + } + + public function testForPathsFactoryMethod(): void + { + $callback = fn(mixed $value): string => TestConstants::MASK_MASKED_BRACKETS; + $paths = ['user.email', 'admin.email', 'contact.email']; + + $strategies = CallbackMaskingStrategy::forPaths($paths, $callback); + + $this->assertCount(3, $strategies); + + foreach ($strategies as $index => $strategy) { + $this->assertInstanceOf(CallbackMaskingStrategy::class, $strategy); + $this->assertSame($paths[$index], $strategy->getFieldPath()); + } + } + + public function testConstantFactoryMethod(): void + { + $strategy = CallbackMaskingStrategy::constant('user.ssn', '***-**-****'); + $record = $this->createLogRecord(); + + $result = $strategy->mask('123-45-6789', 'user.ssn', $record); + + $this->assertSame('***-**-****', $result); + } + + public function testHashFactoryMethod(): void + { + $strategy = CallbackMaskingStrategy::hash('user.password', 'sha256', 8); + $record = $this->createLogRecord(); + + $result = $strategy->mask('secret123', 'user.password', $record); + + $this->assertIsString($result); + $this->assertSame(11, strlen($result)); + $this->assertStringEndsWith('...', $result); + } + + public function testHashWithNoTruncation(): void + { + $strategy = CallbackMaskingStrategy::hash('user.password', 'md5', 0); + $record = $this->createLogRecord(); + + $result = $strategy->mask('test', 'user.password', $record); + + $this->assertSame(32, strlen((string) $result)); + } + + public function testPartialFactoryMethod(): void + { + $strategy = CallbackMaskingStrategy::partial('user.email', 2, 4); + $record = $this->createLogRecord(); + + $result = $strategy->mask('john@example.com', 'user.email', $record); + + $this->assertStringStartsWith('jo', $result); + $this->assertStringEndsWith('.com', $result); + $this->assertStringContainsString('***', $result); + } + + public function testPartialWithShortString(): void + { + $strategy = CallbackMaskingStrategy::partial('user.code', 2, 2); + $record = $this->createLogRecord(); + + $result = $strategy->mask('abc', 'user.code', $record); + + $this->assertSame('***', $result); + } + + public function testPartialWithCustomMaskChar(): void + { + $strategy = CallbackMaskingStrategy::partial('user.card', 4, 4, '#'); + $record = $this->createLogRecord(); + + $result = $strategy->mask('1234567890123456', 'user.card', $record); + + $this->assertStringStartsWith('1234', $result); + $this->assertStringEndsWith('3456', $result); + $this->assertStringContainsString('########', $result); + } + + public function testCallbackReceivesOriginalValue(): void + { + $receivedValue = null; + $callback = function (mixed $value) use (&$receivedValue): string { + $receivedValue = $value; + return TestConstants::MASK_MASKED_BRACKETS; + }; + + $strategy = new CallbackMaskingStrategy('user.data', $callback); + $record = $this->createLogRecord(); + + $strategy->mask(['key' => 'value'], 'user.data', $record); + + $this->assertSame($receivedValue, ['key' => 'value']); + } + + public function testCallbackCanReturnNonString(): void + { + $callback = fn(mixed $value): array => ['masked' => true]; + $strategy = new CallbackMaskingStrategy('user.data', $callback); + $record = $this->createLogRecord(); + + $result = $strategy->mask(['key' => 'value'], 'user.data', $record); + + $this->assertSame(['masked' => true], $result); + } + + public function testCustomPriority(): void + { + $callback = fn(mixed $value): string => TestConstants::MASK_MASKED_BRACKETS; + $strategy = new CallbackMaskingStrategy('user.email', $callback, 100); + + $this->assertSame(100, $strategy->getPriority()); + } +} diff --git a/tests/Strategies/ConditionalMaskingStrategyEnhancedTest.php b/tests/Strategies/ConditionalMaskingStrategyEnhancedTest.php index 3f4d863..919db63 100644 --- a/tests/Strategies/ConditionalMaskingStrategyEnhancedTest.php +++ b/tests/Strategies/ConditionalMaskingStrategyEnhancedTest.php @@ -170,9 +170,24 @@ final class ConditionalMaskingStrategyEnhancedTest extends TestCase [TestConstants::CHANNEL_SECURITY, TestConstants::CHANNEL_AUDIT, 'admin'] ); - $securityRecord = $this->createLogRecord(TestConstants::MESSAGE_DEFAULT, [], Level::Info, TestConstants::CHANNEL_SECURITY); - $auditRecord = $this->createLogRecord(TestConstants::MESSAGE_DEFAULT, [], Level::Info, TestConstants::CHANNEL_AUDIT); - $testRecord = $this->createLogRecord(TestConstants::MESSAGE_DEFAULT, [], Level::Info, 'test'); + $securityRecord = $this->createLogRecord( + TestConstants::MESSAGE_DEFAULT, + [], + Level::Info, + TestConstants::CHANNEL_SECURITY + ); + $auditRecord = $this->createLogRecord( + TestConstants::MESSAGE_DEFAULT, + [], + Level::Info, + TestConstants::CHANNEL_AUDIT + ); + $testRecord = $this->createLogRecord( + TestConstants::MESSAGE_DEFAULT, + [], + Level::Info, + 'test' + ); $this->assertTrue($strategy->shouldApply('secret', TestConstants::FIELD_MESSAGE, $securityRecord)); $this->assertTrue($strategy->shouldApply('secret', TestConstants::FIELD_MESSAGE, $auditRecord)); @@ -188,8 +203,16 @@ final class ConditionalMaskingStrategyEnhancedTest extends TestCase ['env' => 'production', 'sensitive' => true] ); - $prodRecord = $this->createLogRecord(TestConstants::MESSAGE_DEFAULT, ['env' => 'production', 'sensitive' => true]); - $devRecord = $this->createLogRecord(TestConstants::MESSAGE_DEFAULT, ['env' => 'development', 'sensitive' => true]); + $prodContext = ['env' => 'production', 'sensitive' => true]; + $prodRecord = $this->createLogRecord( + TestConstants::MESSAGE_DEFAULT, + $prodContext + ); + $devContext = ['env' => 'development', 'sensitive' => true]; + $devRecord = $this->createLogRecord( + TestConstants::MESSAGE_DEFAULT, + $devContext + ); $noContextRecord = $this->createLogRecord(TestConstants::MESSAGE_DEFAULT); $this->assertTrue($strategy->shouldApply('secret', TestConstants::FIELD_MESSAGE, $prodRecord)); diff --git a/tests/Strategies/FieldPathMaskingStrategyTest.php b/tests/Strategies/FieldPathMaskingStrategyTest.php index a82630c..ac4638e 100644 --- a/tests/Strategies/FieldPathMaskingStrategyTest.php +++ b/tests/Strategies/FieldPathMaskingStrategyTest.php @@ -69,32 +69,52 @@ final class FieldPathMaskingStrategyTest extends TestCase #[Test] public function shouldApplyReturnsTrueForExactPathMatch(): void { - $strategy = new FieldPathMaskingStrategy([TestConstants::FIELD_USER_EMAIL => MaskConstants::MASK_GENERIC]); + $config = [TestConstants::FIELD_USER_EMAIL => MaskConstants::MASK_GENERIC]; + $strategy = new FieldPathMaskingStrategy($config); - $this->assertTrue($strategy->shouldApply(TestConstants::EMAIL_TEST, TestConstants::FIELD_USER_EMAIL, $this->logRecord)); + $this->assertTrue($strategy->shouldApply( + TestConstants::EMAIL_TEST, + TestConstants::FIELD_USER_EMAIL, + $this->logRecord + )); } #[Test] public function shouldApplyReturnsFalseForNonMatchingPath(): void { - $strategy = new FieldPathMaskingStrategy([TestConstants::FIELD_USER_EMAIL => MaskConstants::MASK_GENERIC]); + $config = [TestConstants::FIELD_USER_EMAIL => MaskConstants::MASK_GENERIC]; + $strategy = new FieldPathMaskingStrategy($config); - $this->assertFalse($strategy->shouldApply(TestConstants::CONTEXT_PASSWORD, TestConstants::FIELD_USER_PASSWORD, $this->logRecord)); + $this->assertFalse($strategy->shouldApply( + TestConstants::CONTEXT_PASSWORD, + TestConstants::FIELD_USER_PASSWORD, + $this->logRecord + )); } #[Test] public function shouldApplySupportsWildcardPatterns(): void { - $strategy = new FieldPathMaskingStrategy([TestConstants::PATH_USER_WILDCARD => MaskConstants::MASK_GENERIC]); + $config = [TestConstants::PATH_USER_WILDCARD => MaskConstants::MASK_GENERIC]; + $strategy = new FieldPathMaskingStrategy($config); - $this->assertTrue($strategy->shouldApply(TestConstants::EMAIL_TEST, TestConstants::FIELD_USER_EMAIL, $this->logRecord)); - $this->assertTrue($strategy->shouldApply(TestConstants::CONTEXT_PASSWORD, TestConstants::FIELD_USER_PASSWORD, $this->logRecord)); + $this->assertTrue($strategy->shouldApply( + TestConstants::EMAIL_TEST, + TestConstants::FIELD_USER_EMAIL, + $this->logRecord + )); + $this->assertTrue($strategy->shouldApply( + TestConstants::CONTEXT_PASSWORD, + TestConstants::FIELD_USER_PASSWORD, + $this->logRecord + )); } #[Test] public function maskAppliesStringReplacement(): void { - $strategy = new FieldPathMaskingStrategy([TestConstants::FIELD_USER_EMAIL => MaskConstants::MASK_EMAIL_PATTERN]); + $config = [TestConstants::FIELD_USER_EMAIL => MaskConstants::MASK_EMAIL_PATTERN]; + $strategy = new FieldPathMaskingStrategy($config); $result = $strategy->mask(TestConstants::EMAIL_TEST, TestConstants::FIELD_USER_EMAIL, $this->logRecord); @@ -114,9 +134,11 @@ final class FieldPathMaskingStrategyTest extends TestCase #[Test] public function maskAppliesRegexReplacement(): void { - $strategy = new FieldPathMaskingStrategy([ - 'user.ssn' => FieldMaskConfig::regexMask(TestConstants::PATTERN_SSN_FORMAT, MaskConstants::MASK_SSN_PATTERN), - ]); + $ssnConfig = FieldMaskConfig::regexMask( + TestConstants::PATTERN_SSN_FORMAT, + MaskConstants::MASK_SSN_PATTERN + ); + $strategy = new FieldPathMaskingStrategy(['user.ssn' => $ssnConfig]); $result = $strategy->mask(TestConstants::SSN_US, 'user.ssn', $this->logRecord); @@ -208,10 +230,15 @@ final class FieldPathMaskingStrategyTest extends TestCase #[Test] public function validateReturnsTrueForValidConfiguration(): void { + $ssnConfig = FieldMaskConfig::regexMask( + TestConstants::PATTERN_SSN_FORMAT, + MaskConstants::MASK_SSN_PATTERN + ); + $strategy = new FieldPathMaskingStrategy([ 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($strategy->validate()); diff --git a/tests/Strategies/MaskingStrategiesTest.php b/tests/Strategies/MaskingStrategiesTest.php index 3889e09..be592ae 100644 --- a/tests/Strategies/MaskingStrategiesTest.php +++ b/tests/Strategies/MaskingStrategiesTest.php @@ -69,7 +69,10 @@ class MaskingStrategiesTest extends TestCase public function testRegexMaskingStrategyWithInvalidPattern(): void { $this->expectException(InvalidRegexPatternException::class); - $strategy = new RegexMaskingStrategy([TestConstants::PATTERN_INVALID_UNCLOSED_BRACKET => TestConstants::DATA_MASKED]); + $patterns = [ + TestConstants::PATTERN_INVALID_UNCLOSED_BRACKET => TestConstants::DATA_MASKED + ]; + $strategy = new RegexMaskingStrategy($patterns); unset($strategy); // Satisfy SonarQube - this line won't be reached if exception is thrown $this->fail(TestConstants::ERROR_EXCEPTION_NOT_THROWN); } @@ -390,7 +393,10 @@ class MaskingStrategiesTest extends TestCase $this->assertFalse($strategy->testRecordMatches($logRecord, ['level' => 'Info'])); // Test preserveValueType - $this->assertEquals(TestConstants::DATA_MASKED, $strategy->testPreserveValueType('original', TestConstants::DATA_MASKED)); + $this->assertEquals( + TestConstants::DATA_MASKED, + $strategy->testPreserveValueType('original', TestConstants::DATA_MASKED) + ); $this->assertEquals(123, $strategy->testPreserveValueType(456, '123')); $this->assertEqualsWithDelta(12.5, $strategy->testPreserveValueType(45.6, '12.5'), PHP_FLOAT_EPSILON); $this->assertTrue($strategy->testPreserveValueType(false, 'true')); diff --git a/tests/Strategies/RegexMaskingStrategyComprehensiveTest.php b/tests/Strategies/RegexMaskingStrategyComprehensiveTest.php index dad5eca..ab5d90f 100644 --- a/tests/Strategies/RegexMaskingStrategyComprehensiveTest.php +++ b/tests/Strategies/RegexMaskingStrategyComprehensiveTest.php @@ -205,7 +205,12 @@ final class RegexMaskingStrategyComprehensiveTest extends TestCase $record = $this->createLogRecord('Test'); // Should apply to included path - $this->assertTrue($strategy->shouldApply(TestConstants::MESSAGE_SECRET_DATA, TestConstants::FIELD_USER_PASSWORD, $record)); + $shouldApply = $strategy->shouldApply( + TestConstants::MESSAGE_SECRET_DATA, + TestConstants::FIELD_USER_PASSWORD, + $record + ); + $this->assertTrue($shouldApply); // Should not apply to non-included path $this->assertFalse($strategy->shouldApply(TestConstants::MESSAGE_SECRET_DATA, 'other.field', $record)); @@ -238,10 +243,20 @@ final class RegexMaskingStrategyComprehensiveTest extends TestCase $record = $this->createLogRecord('Test'); // Should not apply to excluded path even if in include list - $this->assertFalse($strategy->shouldApply(TestConstants::MESSAGE_SECRET_DATA, TestConstants::FIELD_USER_PUBLIC, $record)); + $shouldNotApply = $strategy->shouldApply( + TestConstants::MESSAGE_SECRET_DATA, + TestConstants::FIELD_USER_PUBLIC, + $record + ); + $this->assertFalse($shouldNotApply); // Should apply to included path not in exclude list - $this->assertTrue($strategy->shouldApply(TestConstants::MESSAGE_SECRET_DATA, TestConstants::FIELD_USER_PASSWORD, $record)); + $shouldApply = $strategy->shouldApply( + TestConstants::MESSAGE_SECRET_DATA, + TestConstants::FIELD_USER_PASSWORD, + $record + ); + $this->assertTrue($shouldApply); } public function testShouldApplyCatchesMaskingException(): void diff --git a/tests/Strategies/RegexMaskingStrategyEnhancedTest.php b/tests/Strategies/RegexMaskingStrategyEnhancedTest.php index 45ad839..775ee65 100644 --- a/tests/Strategies/RegexMaskingStrategyEnhancedTest.php +++ b/tests/Strategies/RegexMaskingStrategyEnhancedTest.php @@ -83,7 +83,8 @@ final class RegexMaskingStrategyEnhancedTest extends TestCase $logRecord = $this->createLogRecord(); // Should successfully apply all valid patterns - $result = $strategy->mask('SSN: 123-45-6789, Email: emailtest@example.com', TestConstants::FIELD_MESSAGE, $logRecord); + $input = 'SSN: 123-45-6789, Email: emailtest@example.com'; + $result = $strategy->mask($input, TestConstants::FIELD_MESSAGE, $logRecord); $this->assertStringContainsString(MaskConstants::MASK_SSN, $result); $this->assertStringContainsString(MaskConstants::MASK_EMAIL, $result); } @@ -126,25 +127,44 @@ final class RegexMaskingStrategyEnhancedTest extends TestCase public function testShouldApplyWithIncludePathsOnly(): void { $patterns = [TestConstants::PATTERN_TEST => MaskConstants::MASK_MASKED]; - $strategy = new RegexMaskingStrategy($patterns, [TestConstants::PATH_USER_WILDCARD, 'admin.log']); + $includePaths = [TestConstants::PATH_USER_WILDCARD, 'admin.log']; + $strategy = new RegexMaskingStrategy($patterns, $includePaths); $logRecord = $this->createLogRecord(); // Should apply to matching content in included paths - $this->assertTrue($strategy->shouldApply(TestConstants::DATA_TEST_DATA, TestConstants::FIELD_USER_EMAIL, $logRecord)); - $this->assertTrue($strategy->shouldApply(TestConstants::DATA_TEST_DATA, 'admin.log', $logRecord)); + $this->assertTrue($strategy->shouldApply( + TestConstants::DATA_TEST_DATA, + TestConstants::FIELD_USER_EMAIL, + $logRecord + )); + $this->assertTrue($strategy->shouldApply( + TestConstants::DATA_TEST_DATA, + 'admin.log', + $logRecord + )); // Should not apply to non-included paths even if content matches - $this->assertFalse($strategy->shouldApply(TestConstants::DATA_TEST_DATA, 'system.info', $logRecord)); + $this->assertFalse($strategy->shouldApply( + TestConstants::DATA_TEST_DATA, + 'system.info', + $logRecord + )); } public function testShouldApplyWithExcludePathsPrecedence(): void { $patterns = [TestConstants::PATTERN_TEST => MaskConstants::MASK_MASKED]; - $strategy = new RegexMaskingStrategy($patterns, [TestConstants::PATH_USER_WILDCARD], ['user.id', 'user.created_at']); + $includePaths = [TestConstants::PATH_USER_WILDCARD]; + $excludePaths = ['user.id', 'user.created_at']; + $strategy = new RegexMaskingStrategy($patterns, $includePaths, $excludePaths); $logRecord = $this->createLogRecord(); // Should apply to included but not excluded - $this->assertTrue($strategy->shouldApply(TestConstants::DATA_TEST_DATA, TestConstants::FIELD_USER_EMAIL, $logRecord)); + $this->assertTrue($strategy->shouldApply( + TestConstants::DATA_TEST_DATA, + TestConstants::FIELD_USER_EMAIL, + $logRecord + )); // Should not apply to excluded paths $this->assertFalse($strategy->shouldApply(TestConstants::DATA_TEST_DATA, 'user.id', $logRecord)); @@ -158,8 +178,16 @@ final class RegexMaskingStrategyEnhancedTest extends TestCase $logRecord = $this->createLogRecord(); // Should return false when content doesn't match patterns - $this->assertFalse($strategy->shouldApply(TestConstants::DATA_PUBLIC, TestConstants::FIELD_MESSAGE, $logRecord)); - $this->assertFalse($strategy->shouldApply('no sensitive info', 'context.field', $logRecord)); + $this->assertFalse($strategy->shouldApply( + TestConstants::DATA_PUBLIC, + TestConstants::FIELD_MESSAGE, + $logRecord + )); + $this->assertFalse($strategy->shouldApply( + 'no sensitive info', + 'context.field', + $logRecord + )); } public function testShouldApplyForNonStringValuesWhenPatternMatches(): void diff --git a/tests/Strategies/RegexMaskingStrategyTest.php b/tests/Strategies/RegexMaskingStrategyTest.php index 967b91a..09a1ea8 100644 --- a/tests/Strategies/RegexMaskingStrategyTest.php +++ b/tests/Strategies/RegexMaskingStrategyTest.php @@ -127,7 +127,8 @@ final class RegexMaskingStrategyTest extends TestCase '/"email":"[^"]+"/' => '"email":"' . MaskConstants::MASK_EMAIL_PATTERN . '"', ]); - $result = $strategy->mask([TestConstants::CONTEXT_EMAIL => TestConstants::EMAIL_TEST], 'field', $this->logRecord); + $input = [TestConstants::CONTEXT_EMAIL => TestConstants::EMAIL_TEST]; + $result = $strategy->mask($input, 'field', $this->logRecord); $this->assertIsArray($result); $this->assertSame(MaskConstants::MASK_EMAIL_PATTERN, $result[TestConstants::CONTEXT_EMAIL]); @@ -180,7 +181,11 @@ final class RegexMaskingStrategyTest extends TestCase excludePaths: ['excluded.field'] ); - $this->assertFalse($strategy->shouldApply(TestConstants::DATA_NUMBER_STRING, 'excluded.field', $this->logRecord)); + $this->assertFalse($strategy->shouldApply( + TestConstants::DATA_NUMBER_STRING, + 'excluded.field', + $this->logRecord + )); } #[Test] @@ -191,7 +196,11 @@ final class RegexMaskingStrategyTest extends TestCase excludePaths: ['excluded.field'] ); - $this->assertTrue($strategy->shouldApply(TestConstants::DATA_NUMBER_STRING, 'included.field', $this->logRecord)); + $this->assertTrue($strategy->shouldApply( + TestConstants::DATA_NUMBER_STRING, + 'included.field', + $this->logRecord + )); } #[Test] @@ -202,9 +211,21 @@ final class RegexMaskingStrategyTest extends TestCase includePaths: ['user.ssn', 'user.phone'] ); - $this->assertTrue($strategy->shouldApply(TestConstants::DATA_NUMBER_STRING, 'user.ssn', $this->logRecord)); - $this->assertTrue($strategy->shouldApply(TestConstants::DATA_NUMBER_STRING, 'user.phone', $this->logRecord)); - $this->assertFalse($strategy->shouldApply(TestConstants::DATA_NUMBER_STRING, TestConstants::FIELD_USER_EMAIL, $this->logRecord)); + $this->assertTrue($strategy->shouldApply( + TestConstants::DATA_NUMBER_STRING, + 'user.ssn', + $this->logRecord + )); + $this->assertTrue($strategy->shouldApply( + TestConstants::DATA_NUMBER_STRING, + 'user.phone', + $this->logRecord + )); + $this->assertFalse($strategy->shouldApply( + TestConstants::DATA_NUMBER_STRING, + TestConstants::FIELD_USER_EMAIL, + $this->logRecord + )); } #[Test] @@ -337,7 +358,9 @@ final class RegexMaskingStrategyTest extends TestCase '/password/i' => MaskConstants::MASK_GENERIC, ]); - $this->assertSame(MaskConstants::MASK_GENERIC . ' ' . MaskConstants::MASK_GENERIC, $strategy->mask('password PASSWORD', 'field', $this->logRecord)); + $expected = MaskConstants::MASK_GENERIC . ' ' . MaskConstants::MASK_GENERIC; + $result = $strategy->mask('password PASSWORD', 'field', $this->logRecord); + $this->assertSame($expected, $result); } #[Test] diff --git a/tests/Strategies/StrategyEdgeCasesTest.php b/tests/Strategies/StrategyEdgeCasesTest.php new file mode 100644 index 0000000..111d12f --- /dev/null +++ b/tests/Strategies/StrategyEdgeCasesTest.php @@ -0,0 +1,484 @@ +logRecord = new LogRecord( + datetime: new DateTimeImmutable(), + channel: 'test', + level: Level::Info, + message: 'Test message', + context: [], + ); + } + + // ======================================== + // RegexMaskingStrategy ReDoS Detection + // ======================================== + + #[Test] + #[DataProvider('redosPatternProvider')] + public function regexStrategyDetectsReDoSPatterns(string $pattern): void + { + $this->expectException(InvalidRegexPatternException::class); + $this->expectExceptionMessage('catastrophic backtracking'); + + $strategy = new RegexMaskingStrategy([$pattern => MaskConstants::MASK_GENERIC]); + $this->assertInstanceOf(RegexMaskingStrategy::class, $strategy); + } + + /** + * @return array + */ + public static function redosPatternProvider(): array + { + return [ + 'nested plus quantifier' => ['/^(a+)+$/'], + 'nested star quantifier' => ['/^(a*)*$/'], + 'plus with repetition' => ['/^(a+){1,10}$/'], + 'star with repetition' => ['/^(a*){1,10}$/'], + 'identical alternation with star' => ['/(.*|.*)x/'], + 'identical alternation with plus' => ['/(.+|.+)x/'], + 'multiple overlapping alternations with star' => ['/(ab|bc|cd)*y/'], + 'multiple overlapping alternations with plus' => ['/(ab|bc|cd)+y/'], + ]; + } + + #[Test] + public function regexStrategySafePatternsPasses(): void + { + $strategy = new RegexMaskingStrategy([ + '/\d{3}-\d{2}-\d{4}/' => '[SSN]', + '/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => '[EMAIL]', + '/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/' => '[CARD]', + ]); + + $this->assertInstanceOf(RegexMaskingStrategy::class, $strategy); + $this->assertTrue($strategy->validate()); + } + + #[Test] + public function regexStrategyHandlesErrorInHasPatternMatches(): void + { + $strategy = new RegexMaskingStrategy([ + '/simple/' => MaskConstants::MASK_GENERIC, + ]); + + $result = $strategy->shouldApply('no match here', 'field', $this->logRecord); + $this->assertFalse($result); + } + + // ======================================== + // DataTypeMaskingStrategy Edge Cases + // ======================================== + + #[Test] + public function dataTypeStrategyParseArrayMaskWithEmptyString(): void + { + $strategy = new DataTypeMaskingStrategy(['array' => '']); + + $result = $strategy->mask(['original'], 'field', $this->logRecord); + + $this->assertSame([], $result); + } + + #[Test] + public function dataTypeStrategyParseArrayMaskWithInvalidJson(): void + { + $strategy = new DataTypeMaskingStrategy(['array' => '[invalid json']); + + $result = $strategy->mask(['original'], 'field', $this->logRecord); + + $this->assertIsArray($result); + $this->assertSame(['invalid json'], $result); + } + + #[Test] + public function dataTypeStrategyParseArrayMaskWithNonArrayJson(): void + { + $strategy = new DataTypeMaskingStrategy(['array' => '["test"]']); + + $result = $strategy->mask(['original'], 'field', $this->logRecord); + + $this->assertIsArray($result); + $this->assertSame(['test'], $result); + } + + #[Test] + public function dataTypeStrategyParseObjectMaskWithEmptyString(): void + { + $strategy = new DataTypeMaskingStrategy(['object' => '']); + + $obj = (object) ['key' => 'value']; + $result = $strategy->mask($obj, 'field', $this->logRecord); + + $this->assertIsObject($result); + $this->assertEquals((object) [], $result); + } + + #[Test] + public function dataTypeStrategyParseObjectMaskWithInvalidJson(): void + { + $strategy = new DataTypeMaskingStrategy(['object' => '{invalid json']); + + $obj = (object) ['key' => 'value']; + $result = $strategy->mask($obj, 'field', $this->logRecord); + + $this->assertIsObject($result); + $this->assertEquals((object) ['masked' => '{invalid json'], $result); + } + + #[Test] + public function dataTypeStrategyParseObjectMaskWithNonObjectJson(): void + { + $strategy = new DataTypeMaskingStrategy(['object' => '["array"]']); + + $obj = (object) ['key' => 'value']; + $result = $strategy->mask($obj, 'field', $this->logRecord); + + $this->assertIsObject($result); + $this->assertEquals((object) ['masked' => '["array"]'], $result); + } + + #[Test] + public function dataTypeStrategyHandlesResourceTypeUnmapped(): void + { + $strategy = new DataTypeMaskingStrategy(['string' => 'MASKED']); + + $resource = fopen('php://memory', 'r'); + $this->assertIsResource($resource); + + $result = $strategy->shouldApply($resource, 'field', $this->logRecord); + $this->assertFalse($result); + + fclose($resource); + } + + #[Test] + public function dataTypeStrategyHandlesResourceTypeMapped(): void + { + $strategy = new DataTypeMaskingStrategy(['resource' => 'RESOURCE_MASKED']); + + $resource = fopen('php://memory', 'r'); + $this->assertIsResource($resource); + + $result = $strategy->shouldApply($resource, 'field', $this->logRecord); + $this->assertTrue($result); + + fclose($resource); + } + + #[Test] + public function dataTypeStrategyValidateWithResourceType(): void + { + $strategy = new DataTypeMaskingStrategy(['resource' => 'MASKED']); + + $this->assertTrue($strategy->validate()); + } + + #[Test] + public function dataTypeStrategyHandlesDoubleNonNumericMask(): void + { + $strategy = new DataTypeMaskingStrategy(['double' => 'NOT_A_NUMBER']); + + $result = $strategy->mask(123.45, 'field', $this->logRecord); + + $this->assertSame('NOT_A_NUMBER', $result); + } + + #[Test] + public function dataTypeStrategyHandlesIntegerNonNumericMask(): void + { + $strategy = new DataTypeMaskingStrategy(['integer' => 'NOT_A_NUMBER']); + + $result = $strategy->mask(123, 'field', $this->logRecord); + + $this->assertSame('NOT_A_NUMBER', $result); + } + + // ======================================== + // FieldPathMaskingStrategy Edge Cases + // ======================================== + + #[Test] + public function fieldPathStrategyValidateWithEmptyConfigs(): void + { + $strategy = new FieldPathMaskingStrategy([]); + + $this->assertFalse($strategy->validate()); + } + + #[Test] + public function fieldPathStrategyValidateWithEmptyPath(): void + { + $strategy = new FieldPathMaskingStrategy(['' => MaskConstants::MASK_GENERIC]); + + $this->assertFalse($strategy->validate()); + } + + #[Test] + public function fieldPathStrategyValidateWithZeroPath(): void + { + $strategy = new FieldPathMaskingStrategy(['0' => MaskConstants::MASK_GENERIC]); + + $this->assertFalse($strategy->validate()); + } + + #[Test] + public function fieldPathStrategyValidateWithValidStringConfig(): void + { + $strategy = new FieldPathMaskingStrategy([ + 'user.email' => MaskConstants::MASK_EMAIL_PATTERN, + ]); + + $this->assertTrue($strategy->validate()); + } + + #[Test] + public function fieldPathStrategyValidateWithFieldMaskConfig(): void + { + $strategy = new FieldPathMaskingStrategy([ + 'user.email' => FieldMaskConfig::replace(MaskConstants::MASK_EMAIL_PATTERN), + 'user.ssn' => FieldMaskConfig::remove(), + ]); + + $this->assertTrue($strategy->validate()); + } + + #[Test] + public function fieldPathStrategyValidateWithValidRegexConfig(): void + { + $strategy = new FieldPathMaskingStrategy([ + 'user.data' => FieldMaskConfig::regexMask('/\d+/', '[MASKED]'), + ]); + + $this->assertTrue($strategy->validate()); + } + + #[Test] + public function fieldPathStrategyApplyStaticReplacementPreservesIntType(): void + { + $strategy = new FieldPathMaskingStrategy([ + 'user.age' => FieldMaskConfig::replace('999'), + ]); + + $result = $strategy->mask(25, 'user.age', $this->logRecord); + + $this->assertSame(999, $result); + $this->assertIsInt($result); + } + + #[Test] + public function fieldPathStrategyApplyStaticReplacementPreservesFloatType(): void + { + $strategy = new FieldPathMaskingStrategy([ + 'price' => FieldMaskConfig::replace('99.99'), + ]); + + $result = $strategy->mask(123.45, 'price', $this->logRecord); + + $this->assertSame(99.99, $result); + $this->assertIsFloat($result); + } + + #[Test] + public function fieldPathStrategyApplyStaticReplacementPreservesBoolType(): void + { + $strategy = new FieldPathMaskingStrategy([ + 'active' => FieldMaskConfig::replace('false'), + ]); + + $result = $strategy->mask(true, 'active', $this->logRecord); + + $this->assertFalse($result); + $this->assertIsBool($result); + } + + #[Test] + public function fieldPathStrategyApplyStaticReplacementWithNonNumericForInt(): void + { + $strategy = new FieldPathMaskingStrategy([ + 'user.count' => FieldMaskConfig::replace('NOT_NUMERIC'), + ]); + + $result = $strategy->mask(42, 'user.count', $this->logRecord); + + $this->assertSame('NOT_NUMERIC', $result); + } + + #[Test] + public function fieldPathStrategyShouldApplyReturnsFalseForMissingPath(): void + { + $strategy = new FieldPathMaskingStrategy([ + 'user.email' => MaskConstants::MASK_EMAIL_PATTERN, + ]); + + $this->assertFalse($strategy->shouldApply('value', 'other.path', $this->logRecord)); + } + + #[Test] + public function fieldPathStrategyShouldApplyReturnsTrueForWildcardMatch(): void + { + $strategy = new FieldPathMaskingStrategy([ + 'user.*' => MaskConstants::MASK_GENERIC, + ]); + + $this->assertTrue($strategy->shouldApply('value', 'user.email', $this->logRecord)); + $this->assertTrue($strategy->shouldApply('value', 'user.name', $this->logRecord)); + } + + #[Test] + public function fieldPathStrategyMaskAppliesRemoveConfig(): void + { + $strategy = new FieldPathMaskingStrategy([ + 'secret.key' => FieldMaskConfig::remove(), + ]); + + $result = $strategy->mask('sensitive', 'secret.key', $this->logRecord); + + $this->assertNull($result); + } + + #[Test] + public function fieldPathStrategyMaskAppliesRegexConfig(): void + { + $strategy = new FieldPathMaskingStrategy([ + 'user.ssn' => FieldMaskConfig::regexMask('/\d{3}-\d{2}-\d{4}/', '[SSN]'), + ]); + + $result = $strategy->mask('SSN: 123-45-6789', 'user.ssn', $this->logRecord); + + $this->assertSame('SSN: [SSN]', $result); + } + + #[Test] + public function fieldPathStrategyMaskHandlesArrayValue(): void + { + $strategy = new FieldPathMaskingStrategy([ + 'data' => FieldMaskConfig::regexMask('/\d+/', '[NUM]'), + ]); + + $result = $strategy->mask(['count' => '123 items'], 'data', $this->logRecord); + + $this->assertIsArray($result); + $this->assertSame('[NUM] items', $result['count']); + } + + #[Test] + public function fieldPathStrategyMaskReturnsValueWhenNoConfigMatch(): void + { + $strategy = new FieldPathMaskingStrategy([ + 'user.email' => MaskConstants::MASK_EMAIL_PATTERN, + ]); + + $result = $strategy->mask('original', 'other.field', $this->logRecord); + + $this->assertSame('original', $result); + } + + #[Test] + public function fieldPathStrategyGetNameReturnsCorrectFormat(): void + { + $strategy = new FieldPathMaskingStrategy([ + 'user.email' => MaskConstants::MASK_EMAIL_PATTERN, + 'user.phone' => MaskConstants::MASK_PHONE, + ]); + + $name = $strategy->getName(); + + $this->assertSame('Field Path Masking (2 fields)', $name); + } + + #[Test] + public function fieldPathStrategyGetConfigurationReturnsAllSettings(): void + { + $config = [ + 'user.email' => FieldMaskConfig::replace('[EMAIL]'), + ]; + $strategy = new FieldPathMaskingStrategy($config); + + $result = $strategy->getConfiguration(); + + $this->assertArrayHasKey('field_configs', $result); + } + + // ======================================== + // Integration Edge Cases + // ======================================== + + #[Test] + public function regexStrategyMaskHandlesBooleanValue(): void + { + $strategy = new RegexMaskingStrategy([ + '/true/' => 'MASKED', + ]); + + $result = $strategy->mask(true, 'field', $this->logRecord); + + $this->assertTrue($result); + } + + #[Test] + public function regexStrategyMaskHandlesNullValue(): void + { + $strategy = new RegexMaskingStrategy([ + '/.*/' => 'MASKED', + ]); + + $result = $strategy->mask(null, 'field', $this->logRecord); + + // Null converts to empty string, which matches .* and gets masked + // preserveValueType doesn't specifically handle null, so returns masked string + $this->assertSame('MASKED', $result); + } + + #[Test] + public function regexStrategyMaskHandlesEmptyString(): void + { + $strategy = new RegexMaskingStrategy([ + '/.+/' => 'MASKED', + ]); + + $result = $strategy->mask('', 'field', $this->logRecord); + + $this->assertSame('', $result); + } + + #[Test] + public function dataTypeStrategyMaskHandlesDefaultCase(): void + { + $strategy = new DataTypeMaskingStrategy(['string' => 'MASKED']); + + $result = $strategy->mask('test', 'field', $this->logRecord); + + $this->assertSame('MASKED', $result); + } +} diff --git a/tests/Strategies/StrategyManagerEnhancedTest.php b/tests/Strategies/StrategyManagerEnhancedTest.php index 4d86fd4..a92390b 100644 --- a/tests/Strategies/StrategyManagerEnhancedTest.php +++ b/tests/Strategies/StrategyManagerEnhancedTest.php @@ -81,7 +81,11 @@ final class StrategyManagerEnhancedTest extends TestCase $logRecord = $this->createLogRecord(); // Value doesn't match pattern - $applicable = $manager->getApplicableStrategies(TestConstants::DATA_PUBLIC, TestConstants::FIELD_MESSAGE, $logRecord); + $applicable = $manager->getApplicableStrategies( + TestConstants::DATA_PUBLIC, + TestConstants::FIELD_MESSAGE, + $logRecord + ); $this->assertEmpty($applicable); } @@ -90,19 +94,22 @@ final class StrategyManagerEnhancedTest extends TestCase { $manager = new StrategyManager(); - // Add strategies with edge priority values - $manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 0)); // Lowest - $manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 19)); // High edge - $manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 20)); // Medium-high boundary - $manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 39)); // Medium-high edge - $manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 40)); // Medium boundary - $manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 59)); // Medium edge - $manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 60)); // Medium-low boundary - $manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 79)); // Medium-low edge - $manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 80)); // Low boundary - $manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 89)); // Low edge - $manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 90)); // Lowest boundary - $manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 100)); // Highest + // Common pattern for all strategies + $patterns = [TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC]; + + // Add strategies with edge priority values across all priority ranges + $manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 0)); // Lowest + $manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 19)); // High edge + $manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 20)); // Medium-high boundary + $manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 39)); // Medium-high edge + $manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 40)); // Medium boundary + $manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 59)); // Medium edge + $manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 60)); // Medium-low boundary + $manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 79)); // Medium-low edge + $manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 80)); // Low boundary + $manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 89)); // Low edge + $manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 90)); // Lowest boundary + $manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 100)); // Highest $stats = $manager->getStatistics(); diff --git a/tests/Streaming/StreamingProcessorTest.php b/tests/Streaming/StreamingProcessorTest.php new file mode 100644 index 0000000..f3d6e78 --- /dev/null +++ b/tests/Streaming/StreamingProcessorTest.php @@ -0,0 +1,249 @@ + MaskConstants::MASK_GENERIC]); + } + + public function testProcessStreamSingleRecord(): void + { + $processor = new StreamingProcessor($this->createOrchestrator(), 10); + + $records = [ + ['message' => 'test message', 'context' => []], + ]; + + $results = iterator_to_array($processor->processStream($records)); + + $this->assertCount(1, $results); + $this->assertSame(MaskConstants::MASK_GENERIC . ' message', $results[0]['message']); + } + + public function testProcessStreamMultipleRecords(): void + { + $processor = new StreamingProcessor($this->createOrchestrator(), 10); + + $records = [ + ['message' => 'test one', 'context' => []], + ['message' => 'test two', 'context' => []], + ['message' => 'test three', 'context' => []], + ]; + + $results = iterator_to_array($processor->processStream($records)); + + $this->assertCount(3, $results); + } + + public function testProcessStreamChunking(): void + { + $processor = new StreamingProcessor($this->createOrchestrator(), 2); + + $records = [ + ['message' => 'test 1', 'context' => []], + ['message' => 'test 2', 'context' => []], + ['message' => 'test 3', 'context' => []], + ['message' => 'test 4', 'context' => []], + ['message' => 'test 5', 'context' => []], + ]; + + $results = iterator_to_array($processor->processStream($records)); + + $this->assertCount(5, $results); + } + + public function testProcessStreamWithContext(): void + { + $processor = new StreamingProcessor($this->createOrchestrator(), 10); + + $records = [ + ['message' => 'message', 'context' => ['key' => 'test value']], + ]; + + $results = iterator_to_array($processor->processStream($records)); + + $this->assertSame(MaskConstants::MASK_GENERIC . ' value', $results[0]['context']['key']); + } + + public function testProcessStreamWithGenerator(): void + { + $processor = new StreamingProcessor($this->createOrchestrator(), 2); + + $generator = (function () { + yield ['message' => 'test a', 'context' => []]; + yield ['message' => 'test b', 'context' => []]; + yield ['message' => 'test c', 'context' => []]; + })(); + + $results = iterator_to_array($processor->processStream($generator)); + + $this->assertCount(3, $results); + } + + public function testProcessFileWithTempFile(): void + { + $processor = new StreamingProcessor($this->createOrchestrator(), 2); + + // Create temp file with test data containing 'test' to be masked + $tempFile = tempnam(sys_get_temp_dir(), 'gdpr_test_'); + $this->assertIsString($tempFile, 'Failed to create temp file'); + file_put_contents($tempFile, "test line 1\ntest line 2\ntest line 3\n"); + + try { + $lineParser = fn(string $line): array => ['message' => $line, 'context' => []]; + + $results = []; + foreach ($processor->processFile($tempFile, $lineParser) as $result) { + $results[] = $result; + } + + $this->assertCount(3, $results); + $this->assertStringContainsString(MaskConstants::MASK_GENERIC, $results[0]['message']); + } finally { + unlink($tempFile); + } + } + + public function testProcessFileSkipsEmptyLines(): void + { + $processor = new StreamingProcessor($this->createOrchestrator(), 10); + + $tempFile = tempnam(sys_get_temp_dir(), 'gdpr_test_'); + $this->assertIsString($tempFile, 'Failed to create temp file'); + file_put_contents($tempFile, "test line 1\n\n\ntest line 2\n"); + + try { + $lineParser = fn(string $line): array => ['message' => $line, 'context' => []]; + + $results = iterator_to_array($processor->processFile($tempFile, $lineParser)); + + $this->assertCount(2, $results); + } finally { + unlink($tempFile); + } + } + + public function testProcessFileThrowsOnInvalidPath(): void + { + $processor = new StreamingProcessor($this->createOrchestrator(), 10); + + $this->expectException(StreamingOperationFailedException::class); + $this->expectExceptionMessage('Cannot open input file for streaming:'); + + iterator_to_array($processor->processFile('/nonexistent/path/file.log', fn(string $l): array => ['message' => $l, 'context' => []])); + } + + public function testProcessToFile(): void + { + $processor = new StreamingProcessor($this->createOrchestrator(), 10); + + $records = [ + ['message' => 'test line 1', 'context' => []], + ['message' => 'test line 2', 'context' => []], + ]; + + $outputFile = tempnam(sys_get_temp_dir(), 'gdpr_output_'); + $this->assertIsString($outputFile, 'Failed to create temp file'); + + try { + $formatter = fn(array $record): string => $record['message']; + $count = $processor->processToFile($records, $outputFile, $formatter); + + $this->assertSame(2, $count); + + $output = file_get_contents($outputFile); + $this->assertNotFalse($output); + $this->assertStringContainsString(MaskConstants::MASK_GENERIC, $output); + } finally { + unlink($outputFile); + } + } + + public function testProcessToFileThrowsOnInvalidPath(): void + { + $processor = new StreamingProcessor($this->createOrchestrator(), 10); + + $this->expectException(StreamingOperationFailedException::class); + $this->expectExceptionMessage('Cannot open output file for streaming:'); + + $processor->processToFile([], '/nonexistent/path/output.log', fn(array $r): string => ''); + } + + public function testGetStatistics(): void + { + $processor = new StreamingProcessor($this->createOrchestrator(), 10); + + $records = [ + ['message' => 'test masked', 'context' => []], + ['message' => 'no sensitive data', 'context' => []], + ['message' => 'another test here', 'context' => []], + ]; + + $stats = $processor->getStatistics($records); + + $this->assertSame(3, $stats['processed']); + $this->assertGreaterThan(0, $stats['masked']); // At least some should be masked + } + + public function testSetAuditLogger(): void + { + $logs = []; + $auditLogger = function (string $path) use (&$logs): void { + $logs[] = ['path' => $path]; + }; + + $processor = new StreamingProcessor($this->createOrchestrator(), 1); + $processor->setAuditLogger($auditLogger); + + $records = [['message' => 'test', 'context' => []]]; + iterator_to_array($processor->processStream($records)); + + $this->assertNotEmpty($logs); + } + + public function testGetChunkSize(): void + { + $processor = new StreamingProcessor($this->createOrchestrator(), 500); + + $this->assertSame(500, $processor->getChunkSize()); + } + + public function testDefaultChunkSize(): void + { + $processor = new StreamingProcessor($this->createOrchestrator()); + + $this->assertSame(1000, $processor->getChunkSize()); + } + + public function testLargeDataSet(): void + { + $processor = new StreamingProcessor($this->createOrchestrator(), 100); + + $records = []; + for ($i = 1; $i <= 500; $i++) { + $records[] = ['message' => "test record {$i}", 'context' => []]; + } + + $count = 0; + foreach ($processor->processStream($records) as $record) { + $count++; + $this->assertIsArray($record); + } + + $this->assertSame(500, $count); + } +} diff --git a/tests/TestConstants.php b/tests/TestConstants.php index ae51dd4..90949cf 100644 --- a/tests/TestConstants.php +++ b/tests/TestConstants.php @@ -158,6 +158,31 @@ final class TestConstants // Replacement Values public const REPLACEMENT_TEST = '[TEST]'; + // Age range values + public const AGE_RANGE_20_29 = '20-29'; + + // Additional email variations + public const EMAIL_NEW = 'new@example.com'; + public const EMAIL_JOHN = 'john@example.com'; + + // Mask placeholders used in tests (bracketed format) + public const MASK_REDACTED_BRACKETS = '[REDACTED]'; + public const MASK_MASKED_BRACKETS = '[MASKED]'; + public const MASK_EMAIL_BRACKETS = '[EMAIL]'; + public const MASK_DIGITS_BRACKETS = '[DIGITS]'; + public const MASK_INT_BRACKETS = '[INT]'; + public const MASK_ALWAYS_THIS = '[ALWAYS_THIS]'; + + // Test values + public const VALUE_TEST = 'test value'; + public const VALUE_SUFFIX = ' value'; + + // Additional pattern constants + public const PATTERN_VALID_SIMPLE = '/^test$/'; + public const PATTERN_INVALID_UNCLOSED = '/unclosed'; + public const PATTERN_REDOS_VULNERABLE = '/^(a+)+$/'; + public const PATTERN_SAFE = '/[a-z]+/'; + /** * Prevent instantiation. * diff --git a/tests/TestHelpers.php b/tests/TestHelpers.php index 71d3c94..b940eb5 100644 --- a/tests/TestHelpers.php +++ b/tests/TestHelpers.php @@ -199,6 +199,7 @@ trait TestHelpers */ protected function clearPatternCache(): void { + /** @psalm-suppress DeprecatedMethod - Test helper for deprecated cache API */ PatternValidator::clearCache(); }