86 Commits

Author SHA1 Message Date
renovate[bot]
7b2ad9630a chore(actions): update shivammathur/setup-php action (2.36.0 → 2.37.0)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-15 21:55:46 +00:00
renovate[bot]
4d9f925e89 chore(actions): update ivuorinen/actions action (v2026.03.06 → v2026.03.11) (#104)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-13 08:44:56 +02:00
renovate[bot]
d43de372e3 chore(deps): update pre-commit hook igorshubovych/markdownlint-cli (v0.47.0 → v0.48.0) (#103)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-10 08:04:55 +02:00
renovate[bot]
16a986001c chore(deps): update pre-commit hook bridgecrewio/checkov (3.2.506 → 3.2.508) (#102)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-09 22:50:51 +02:00
renovate[bot]
8425411b6c chore(deps)!: update dependency phpunit/phpunit (12.5.14 → 13.0.5) (#88)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-08 19:28:33 +02:00
renovate[bot]
57acc7847f chore(deps)!: update dependency phpunit/phpunit (11.5.55 → 12.5.14) (#87)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-08 19:21:40 +02:00
d1cbf50c5e fix: switch from xdebug to pcov for code coverage (#101)
* fix: remove xdebug-specific settings from phpunit.xml and composer.json

Remove `<ini name="xdebug.mode" value="coverage"/>` from phpunit.xml and
`XDEBUG_MODE=coverage` prefix from composer scripts. These fail when xdebug
is not installed and are unnecessary when using PCOV for code coverage.

* ci: switch from xdebug to pcov for code coverage

Both test matrix and coverage jobs now use `coverage: pcov`, matching
the existing test-coverage.yaml workflow. PCOV is faster for
coverage-only use and avoids the xdebug dependency conflict.

* build: replace xdebug with pcov in Docker setup

Switch Docker development environment from xdebug to pcov for code
coverage and remove the XDEBUG_MODE environment variable from
docker-compose.yml. Standardizes on pcov across all environments.
2026-03-08 19:16:07 +02:00
renovate[bot]
e26312a6ee chore(deps): update image php to v8.5 (#68)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-08 14:36:49 +02:00
b0925ce489 refactor: replace hardcoded strings with constant references (#100)
* fix(tests): remove error_log calls and clean up ComprehensiveValidationTest

* refactor: replace hardcoded strings with MaskConstants and TestConstants references

* fix(streaming): replace overcounting '[' heuristic with proper mask detection

StreamingProcessor::getStatistics() was counting any message containing '['
as masked, causing false positives. Now checks for specific mask constants
(MASK_GENERIC, MASK_BRACKETS, MASK_REDACTED_BRACKETS) instead.

Also adds MASK_REDACTED_BRACKETS constant to MaskConstants and removes
the now-unnecessary UnusedFunctionCall psalm suppression.

* refactor(tests): replace remaining hardcoded literals with constant references

Add new constants to TestConstants (MASK_REDACTED_PLAIN, MASK_SECRET_BRACKETS,
MASK_SSN_BRACKETS, PATTERN_REDOS_NESTED_STAR, FIELD_USER_SSN, FIELD_USER_DATA)
and replace all matching literals across 21 test files.

Also removes dead memory_get_usage() call and uses existing
TestConstants::IP_ADDRESS_PUBLIC for hardcoded IP.

* fix(streaming): replace mask-token heuristic with accurate record comparison in getStatistics()

The previous implementation only detected masking when specific mask tokens
appeared in the message, missing cases where context was masked or different
mask values were used. Compare original vs processed records instead.

* refactor(tests): add PATTERN_EMAIL_SIMPLE, MASK_CARD_BRACKETS, EXPECTED_SSN_MASKED constants

Replace cross-file duplicate literals with TestConstants references:
- Email regex (4 files), '[CARD]' (2 files), 'SSN: [SSN]' (2 files)

* fix(streaming): bypass audit logger in getStatistics() by calling orchestrator directly

getStatistics() previously routed through processStream()/processChunk() which
triggered the audit logger for each record. A read-only statistics method should
not produce audit side-effects. Now calls orchestrator.process() directly and
processes records one at a time without materializing the entire iterable.

* refactor(tests): fix test quality issues and add PATTERN_CREDIT_CARD constant

- Replace fail() message that leaked sensitive terms with count-only message
- Replace bare 'EMAIL' string with MaskConstants::MASK_EMAIL for consistency
- Remove error_log() debug output from CriticalBugRegressionTest
- Add TestConstants::PATTERN_CREDIT_CARD and replace inline regex in 3 files
2026-03-08 13:50:17 +02:00
e58397a75d ci: harden workflow permissions and fix shellcheck warnings (#99)
* ci: add least-privilege permissions and quote shell variables in CI workflow

* ci: restrict root permissions and quote shell variables in test-coverage workflow

* ci: quote shell variables and group redirects in release workflow
2026-03-08 03:45:56 +02:00
f6b0f864b4 fix: workflows now use .php-version, other fixes (#98)
* ci: use .php-version file in CI coverage and security jobs

* ci: use .php-version file in release workflow

* ci: use .php-version file in phpcs workflow

* ci: use .php-version file in test-coverage workflow

* ci: remove master branch from pr-lint workflow triggers
2026-03-07 23:30:32 +02:00
renovate[bot]
0fd7cd099f chore(deps): update ivuorinen/actions action (v2026.02.24 → v2026.03.06) (#97)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-07 20:56:38 +02:00
38946574a4 ci: migrate CodeQL to ivuorinen/actions/codeql-analysis (#96)
* ci: migrate codeql to composable workflow

* fix: correct codeql workflow language, queries, permissions, and action ref

- Use 'javascript' instead of 'javascript-typescript' for CodeQL language
- Add queries: security-and-quality parameter
- Set root-level permissions to {}
- Add job-level permissions (actions, contents, packages, security-events)
- Pin action ref to commit hash with version comment
- Fix mangled cron schedule
2026-03-07 18:44:29 +02:00
renovate[bot]
1be44fff9d chore(deps): lock file maintenance (#95)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 09:09:46 +02:00
renovate[bot]
3be9c07d6c chore(deps)!: update actions/upload-artifact (v6.0.0 → v7.0.0) (#94)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 03:45:27 +02:00
renovate[bot]
8ec91aad35 chore(deps): update ivuorinen/actions action (v2026.01.21 → v2026.02.24) (#93)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-26 21:04:01 +02:00
renovate[bot]
5eb01578d2 chore(deps): update github/codeql-action action (v4.32.0 → v4.32.4) (#90)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 10:09:09 +00:00
renovate[bot]
110598e921 chore(deps): update pre-commit hook rhysd/actionlint (v1.7.10 → v1.7.11) (#92)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 07:57:48 +00:00
renovate[bot]
9af85cb9b1 chore(deps): update pre-commit hook bridgecrewio/checkov (3.2.499 → 3.2.506) (#91)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 07:58:30 +02:00
renovate[bot]
1a60d2b573 chore(deps): lock file maintenance (#89) 2026-02-23 21:41:03 +02:00
renovate[bot]
97ac6b1eae chore(deps): update actions/cache action (v5.0.2 → v5.0.3) (#84)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-01 10:25:28 +02:00
47564c5cd6 feat!: upgrade min. php version to 8.4 (#86)
* feat: upgrade min php to 7.4, upgrade packages

* chore: update ci/cd, docs, supporting config to php 8.4

* chore: update rest of the docs, supporting config to php 8.4
2026-02-01 10:20:40 +02:00
renovate[bot]
3d3448dcf0 chore(deps): update phpunit/phpunit (11.5.46 → 11.5.50) [security] (#82)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 11:26:11 +02:00
renovate[bot]
f16eb2a095 chore(deps): update github/codeql-action action (v4.31.9 → v4.32.0) (#81)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-28 16:44:13 +02:00
renovate[bot]
451726a365 chore(deps): update pre-commit hook bridgecrewio/checkov (3.2.497 → 3.2.499) (#79)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 12:15:38 +00:00
renovate[bot]
966618ec5a chore(deps): update ivuorinen/actions action (v2026.01.13 → v2026.01.21) (#78)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 11:49:02 +00:00
renovate[bot]
c3f5ddcc45 chore(deps): update actions/cache action (v5.0.1 → v5.0.2) (#76)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 11:32:05 +00:00
renovate[bot]
e499663b5d chore(deps): update actions/checkout action (v6.0.1 → v6.0.2) (#77)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 10:32:44 +00:00
renovate[bot]
c89bc1ae72 chore(deps): update pre-commit hook adrienverge/yamllint (v1.37.1 → v1.38.0) (#80)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 08:51:37 +02:00
renovate[bot]
74ec52721e chore(deps): update pre-commit hook renovatebot/pre-commit-hooks (42.69.2 → 42.84.0) (#75)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 17:54:42 +02:00
renovate[bot]
7fe55b86f8 chore(deps): update ivuorinen/actions action (v2026.01.06 → v2026.01.13) (#74)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 21:33:45 +02:00
renovate[bot]
2a157f1871 chore(deps): update pre-commit hook bridgecrewio/checkov (3.2.496 → 3.2.497) (#72)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 14:02:08 +00:00
renovate[bot]
36c4fd6e1d chore(deps): update pre-commit hook rhysd/actionlint (v1.7.9 → v1.7.10) (#73)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 15:53:57 +02:00
renovate[bot]
497353f4f3 chore(deps)!: update ivuorinen/actions (v2025.12.21 → v2026.01.06) (#71)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 15:49:08 +02:00
renovate[bot]
4ab3db8a12 chore(deps): update pre-commit hook renovatebot/pre-commit-hooks (42.64.1 → 42.69.2) (#70)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-02 12:25:44 +02:00
renovate[bot]
b3eea46780 chore(deps): update pre-commit hook bridgecrewio/checkov (3.2.495 → 3.2.496) (#67)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-30 08:40:39 +02:00
86deca0371 chore: update README.md 2025-12-22 14:03:27 +02:00
8866daaf33 feat: add advanced architecture, documentation, and coverage improvements (#65)
* fix(style): resolve PHPCS line-length warnings in source files

* fix(style): resolve PHPCS line-length warnings in test files

* feat(audit): add structured audit logging with ErrorContext and AuditContext

- ErrorContext: standardized error information with sensitive data sanitization
- AuditContext: structured context for audit entries with operation types
- StructuredAuditLogger: enhanced audit logger wrapper with timing support

* feat(recovery): add recovery mechanism for failed masking operations

- FailureMode enum: FAIL_OPEN, FAIL_CLOSED, FAIL_SAFE modes
- RecoveryStrategy interface and RecoveryResult value object
- RetryStrategy: exponential backoff with configurable attempts
- FallbackMaskStrategy: type-aware fallback values

* feat(strategies): add CallbackMaskingStrategy for custom masking logic

- Wraps custom callbacks as MaskingStrategy implementations
- Factory methods: constant(), hash(), partial() for common use cases
- Supports exact match and prefix match for field paths

* docs: add framework integration guides and examples

- symfony-integration.md: Symfony service configuration and Monolog setup
- psr3-decorator.md: PSR-3 logger decorator pattern implementation
- framework-examples.md: CakePHP, CodeIgniter 4, Laminas, Yii2, PSR-15
- docker-development.md: Docker development environment guide

* chore(docker): add Docker development environment

- Dockerfile: PHP 8.2-cli-alpine with Xdebug for coverage
- docker-compose.yml: development services with volume mounts

* feat(demo): add interactive GDPR pattern tester playground

- PatternTester.php: pattern testing utility with strategy support
- index.php: web API endpoint with JSON response handling
- playground.html: interactive web interface for testing patterns

* docs(todo): update with completed medium priority items

- Mark all PHPCS warnings as fixed (81 → 0)
- Document new Audit and Recovery features
- Update test count to 1,068 tests with 2,953 assertions
- Move remaining items to low priority

* feat: add advanced architecture, documentation, and coverage improvements

- Add architecture improvements:
  - ArrayAccessorInterface and DotArrayAccessor for decoupled array access
  - MaskingOrchestrator for single-responsibility masking coordination
  - GdprProcessorBuilder for fluent configuration
  - MaskingPluginInterface and AbstractMaskingPlugin for plugin architecture
  - PluginAwareProcessor for plugin hook execution
  - AuditLoggerFactory for instance-based audit logger creation

- Add advanced features:
  - SerializedDataProcessor for handling print_r/var_export/serialize output
  - KAnonymizer with GeneralizationStrategy for GDPR k-anonymity
  - RetentionPolicy for configurable data retention periods
  - StreamingProcessor for memory-efficient large log processing

- Add comprehensive documentation:
  - docs/performance-tuning.md - benchmarking, optimization, caching
  - docs/troubleshooting.md - common issues and solutions
  - docs/logging-integrations.md - ELK, Graylog, Datadog, etc.
  - docs/plugin-development.md - complete plugin development guide

- Improve test coverage (84.41% → 85.07%):
  - ConditionalRuleFactoryInstanceTest (100% coverage)
  - GdprProcessorBuilderEdgeCasesTest (100% coverage)
  - StrategyEdgeCasesTest for ReDoS detection and type parsing
  - 78 new tests, 119 new assertions

- Update TODO.md with current statistics:
  - 141 PHP files, 1,346 tests, 85.07% line coverage

* chore: tests, update actions, sonarcloud issues

* chore: rector

* fix: more sonarcloud fixes

* chore: more fixes

* refactor: copilot review fix

* chore: rector
2025-12-22 13:38:18 +02:00
renovate[bot]
b1eb567b92 chore(deps): update pre-commit hook renovatebot/pre-commit-hooks (42.40.3 → 42.64.1) (#66)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-20 12:47:47 +02:00
renovate[bot]
170cfb2fc9 chore(deps)!: update actions/cache (v4.3.0 → v5.0.1) (#60)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-17 23:01:36 +02:00
renovate[bot]
b5fec58dd5 chore(deps): update pre-commit hook igorshubovych/markdownlint-cli (v0.46.0 → v0.47.0) (#64)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-17 16:19:29 +02:00
renovate[bot]
6307a37e4d chore(deps): update codecov/codecov-action action (v5.5.1 → v5.5.2) (#62)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-16 10:09:39 +00:00
renovate[bot]
1967ee722b chore(deps)!: update actions/upload-artifact (v5.0.0 → v6.0.0) (#61)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-16 12:03:41 +02:00
renovate[bot]
03d24479c0 chore(deps): update github/codeql-action action (v4.31.7 → v4.31.8) (#63)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-16 09:49:32 +02:00
renovate[bot]
8d82b70304 chore(deps): update pre-commit hook renovatebot/pre-commit-hooks (42.27.1 → 42.40.3) (#58)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 06:27:08 +00:00
renovate[bot]
10923e99e9 chore(deps): update ivuorinen/actions action (v2025.11.30 → v2025.12.07) (#30) 2025-12-08 15:04:18 +02:00
renovate[bot]
7a48d493c4 chore(deps): update actions/checkout action (v6.0.0 → v6.0.1) (#50) 2025-12-08 07:40:16 +02:00
renovate[bot]
ad11859b46 chore(deps): update github/codeql-action action (v4.31.5 → v4.31.7) (#57) 2025-12-08 07:17:22 +02:00
c3d6b8b1c6 chore: workflow and package updates, linting (#59)
* chore(deps): update composer packages

* chore(ci): update workflows

* chore(lint): fix codeql language, composer lint:fix

* chore: set php version 8.2, update pre-commit hooks, linting
2025-12-01 11:18:44 +02:00
e293587296 fix(ci): actions not workflows 2025-11-11 22:55:15 +02:00
ac4559ae48 fix(ci): add workflows write to pr-lint 2025-11-11 22:45:13 +02:00
c30c136a92 chore(ci): add content write permission to pr-lint 2025-11-11 22:38:13 +02:00
renovate[bot]
5f2793ca99 chore(deps)!: update renovatebot/pre-commit-hooks (41.173.1 → 42.2.0) (#45) 2025-11-10 08:19:09 +02:00
renovate[bot]
ddfa3151ea chore(deps): update actions/cache action (v4.2.3 → v4.3.0) (#42)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-07 20:35:08 +02:00
renovate[bot]
433a2830f3 chore(deps): update pre-commit hook renovatebot/pre-commit-hooks (41.160.0 → 41.173.1) (#44)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-07 19:31:58 +02:00
renovate[bot]
e37bbbedcd chore(deps)!: update actions/checkout (v4.2.2 → v5.0.0) (#39) 2025-11-04 22:27:58 +02:00
renovate[bot]
294e5e5f3c chore(deps): update codecov/codecov-action action (v5.4.3 → v5.5.1) (#43)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-04 07:47:44 +02:00
renovate[bot]
44f6cdc380 chore(deps)!: update squizlabs/php_codesniffer (3.13.4 → 4.0.0) (#41)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-03 01:35:04 +02:00
renovate[bot]
0ba827a9fb chore(deps)!: update actions/upload-artifact (v4.6.2 → v5.0.0) (#29) 2025-10-31 17:03:21 +02:00
renovate[bot]
6afc04d67d chore(deps): update pre-commit hook bridgecrewio/checkov (3.2.484 → 3.2.489) (#31)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-31 12:10:28 +00:00
dependabot[bot]
5bf81ef083 ci(deps): bump shivammathur/setup-php from 2.35.2 to 2.35.5 (#37)
Bumps [shivammathur/setup-php](https://github.com/shivammathur/setup-php) from 2.35.2 to 2.35.5.
- [Release notes](https://github.com/shivammathur/setup-php/releases)
- [Commits](https://github.com/shivammathur/setup-php/compare/2.35.2...bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f)

---
updated-dependencies:
- dependency-name: shivammathur/setup-php
  dependency-version: 2.35.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-31 14:07:04 +02:00
renovate[bot]
5166e41fbc chore(deps): update github/codeql-action action (v4.30.9 → v4.31.2) (#32)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-31 14:03:42 +02:00
00c6f76c97 feat: performance, integrations, advanced features (#2)
* feat: performance, integrations, advanced features

* chore: fix linting problems

* chore: suppressions and linting

* chore(lint): pre-commit linting, fixes

* feat: comprehensive input validation, security hardening, and regression testing

- Add extensive input validation throughout codebase with proper error handling
- Implement comprehensive security hardening with ReDoS protection and bounds checking
- Add 3 new regression test suites covering critical bugs, security, and validation scenarios
- Enhance rate limiting with memory management and configurable cleanup intervals
- Update configuration security settings and improve Laravel integration
- Fix TODO.md timestamps to reflect actual development timeline
- Strengthen static analysis configuration and improve code quality standards

* feat: configure static analysis tools and enhance development workflow

- Complete configuration of Psalm, PHPStan, and Rector for harmonious static analysis.
- Fix invalid configurations and tool conflicts that prevented proper code quality analysis.
- Add comprehensive safe analysis script with interactive workflow, backup/restore
  capabilities, and dry-run modes. Update documentation with linting policy
  requiring issue resolution over suppression.
- Clean completed items from TODO to focus on actionable improvements.
- All static analysis tools now work together seamlessly to provide
  code quality insights without breaking existing functionality.

* fix(test): update Invalid regex pattern expectation

* chore: phpstan, psalm fixes

* chore: phpstan, psalm fixes, more tests

* chore: tooling tweaks, cleanup

* chore: tweaks to get the tests pass

* fix(lint): rector config tweaks and successful run

* feat: refactoring, more tests, fixes, cleanup

* chore: deduplication, use constants

* chore: psalm fixes

* chore: ignore phpstan deliberate errors in tests

* chore: improve codebase, deduplicate code

* fix: lint

* chore: deduplication, codebase simplification, sonarqube fixes

* fix: resolve SonarQube reliability rating issues

Fix useless object instantiation warnings in test files by assigning
instantiated objects to variables. This resolves the SonarQube reliability
rating issue (was C, now targeting A).

Changes:
- tests/Strategies/MaskingStrategiesTest.php: Fix 3 instances
- tests/Strategies/FieldPathMaskingStrategyTest.php: Fix 1 instance

The tests use expectException() to verify that constructors throw
exceptions for invalid input. SonarQube flagged standalone `new`
statements as useless. Fixed by assigning to variables with explicit
unset() and fail() calls.

All tests pass (623/623) and static analysis tools pass.

* fix: resolve more SonarQube detected issues

* fix: resolve psalm detected issues

* fix: resolve more SonarQube detected issues

* fix: resolve psalm detected issues

* fix: duplications

* fix: resolve SonarQube reliability rating issues

* fix: resolve psalm and phpstan detected issues
2025-10-31 13:59:01 +02:00
renovate[bot]
63637900c8 chore(deps): update pre-commit hook renovatebot/pre-commit-hooks (41.152.0 → 41.160.0) (#28)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-27 13:45:26 +02:00
renovate[bot]
da4cf50c95 chore(deps): update github/codeql-action action (v4.30.8 → v4.30.9) (#27)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-21 17:04:41 +03:00
renovate[bot]
79e8fe5bd6 chore(deps): update pre-commit hook renovatebot/pre-commit-hooks (41.122.3 → 41.152.0) (#26)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-18 10:38:17 +03:00
renovate[bot]
263199f72c chore(deps): update pre-commit hook rhysd/actionlint (v1.7.7 → v1.7.8) (#24)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-16 12:00:48 +03:00
renovate[bot]
3cf7e7b222 chore(deps)!: update github/codeql-action (v3.30.3 → v4.30.8) (#21) 2025-10-15 23:30:43 +03:00
renovate[bot]
2bcc8071fd chore(deps): update pre-commit hook bridgecrewio/checkov (3.2.471 → 3.2.484) (#23) 2025-10-15 23:14:04 +03:00
renovate[bot]
014f4e1da1 chore(deps)!: update squizlabs/php_codesniffer (3.13.2 → 4.0.0) (#18) 2025-09-24 20:29:31 +03:00
renovate[bot]
6fa57dee2d chore(deps): update shivammathur/setup-php action (2.35.4 → 2.35.5) (#20) 2025-09-24 18:09:46 +03:00
renovate[bot]
a5a285d527 chore(deps): update ivuorinen/actions action (25.9.19 → 25.9.21) (#19) 2025-09-24 18:06:31 +03:00
renovate[bot]
902e2861b6 chore(deps)!: update renovatebot/pre-commit-hooks (39.264.0 → 41.122.3) (#17) 2025-09-22 01:19:29 +03:00
renovate[bot]
bc78843e94 chore(deps)!: update actions/checkout (v4.3.0 → v5.0.0) (#13) 2025-09-22 01:18:50 +03:00
renovate[bot]
4d1eb0f3d8 chore(deps)!: update pre-commit/pre-commit-hooks (v5.0.0 → v6.0.0) (#15) 2025-09-22 01:17:59 +03:00
renovate[bot]
6c69098c38 chore(deps)!: update phpunit/phpunit (11.5.27 → 12.3.12) (#14) 2025-09-22 01:17:05 +03:00
renovate[bot]
cc70f1b331 chore(deps): update ivuorinen/actions action (25.7.21 → 25.9.19) (#12) 2025-09-21 21:28:16 +03:00
renovate[bot]
50e41ba710 chore(deps): update pre-commit hook bridgecrewio/checkov (3.2.400 → 3.2.471) (#4) 2025-09-19 01:57:34 +03:00
renovate[bot]
7325fe8700 chore(deps): update pre-commit hook adrienverge/yamllint (v1.37.0 → v1.37.1) (#3) 2025-09-18 19:35:38 +03:00
renovate[bot]
275f2231cc chore(deps): update shivammathur/setup-php action (2.35.0 → 2.35.4) (#6) 2025-09-18 19:35:24 +03:00
renovate[bot]
2b16eaaa68 chore(deps): update pre-commit hook koalaman/shellcheck-precommit (v0.10.0 → v0.11.0) (#10) 2025-09-18 19:34:53 +03:00
renovate[bot]
74ff852b34 chore(deps): update actions/checkout action (v4.2.2 → v4.3.0) (#7) 2025-09-18 16:08:48 +03:00
renovate[bot]
3bcc0fe551 chore(deps): update saschanowak/clovercodecoveragesummary action (1.1.0 → 1.1.1) (#5) 2025-09-18 16:07:42 +03:00
renovate[bot]
dcccea1cc3 chore(deps): update pre-commit hook igorshubovych/markdownlint-cli (v0.44.0 → v0.45.0) (#9) 2025-09-18 11:51:49 +03:00
renovate[bot]
0d45cacdc1 chore(deps): update github/codeql-action action (v3.29.4 → v3.30.3) (#8) 2025-09-18 11:47:44 +03:00
renovate[bot]
4925262cea chore(deps): update pre-commit hook renovatebot/pre-commit-hooks (39.227.2 → 39.264.0) (#11) 2025-09-18 11:47:09 +03:00
197 changed files with 46031 additions and 1406 deletions

View File

@@ -17,3 +17,9 @@ max_line_length = 120
[*.{md,json,yml,yaml,xml}]
indent_size = 2
[*.json]
max_line_length = 200
[{CHANGELOG.md,TODO.md}]
max_line_length = 300

View File

@@ -12,6 +12,7 @@ A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
@@ -24,15 +25,17 @@ A clear and concise description of what you expected to happen.
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@@ -9,8 +9,8 @@ field-level configuration, and custom callbacks. It is designed for easy integra
## Coding Conventions
- **Language:** PHP 8.2+
- **PHP Version:** Ensure compatibility with PHP 8.2 and above.
- **Language:** PHP 8.4+
- **PHP Version:** Ensure compatibility with PHP 8.4 and above.
- **PSR Standards:** Follow PSR-12 for code style and autoloading.
- **Testing:** Use PHPUnit for all tests. Place tests in the `tests/` directory. Run `composer test` to execute tests.
- All tests should be written in a way that they can run independently.

25
.github/renovate.json vendored
View File

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

122
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,122 @@
---
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
php-version: ["8.4", "8.5"]
name: PHP ${{ matrix.php-version }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup PHP
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
with:
php-version: ${{ matrix.php-version }}
extensions: mbstring, xml, ctype, iconv, intl, json
tools: composer:v2
coverage: pcov
- name: Get composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
- name: Cache composer dependencies
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-composer-
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run PHPUnit tests
run: composer test
- name: Run Psalm static analysis
run: ./vendor/bin/psalm --show-info=true
- name: Run PHPStan static analysis
run: ./vendor/bin/phpstan analyse --memory-limit=1G --no-progress
- name: Run PHP_CodeSniffer
run: ./vendor/bin/phpcs src/ tests/ rector.php --warning-severity=0
- name: Run Rector (dry-run)
run: ./vendor/bin/rector --dry-run --no-progress-bar
coverage:
runs-on: ubuntu-latest
name: Coverage
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup PHP
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
with:
php-version-file: '.php-version'
extensions: mbstring, xml, ctype, iconv, intl, json
tools: composer:v2
coverage: pcov
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run tests with coverage
run: composer test:ci
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
fail_ci_if_error: false
security:
runs-on: ubuntu-latest
name: Security Analysis
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup PHP
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
with:
php-version-file: '.php-version'
extensions: mbstring, xml, ctype, iconv, intl, json
tools: composer:v2
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run security audit
run: composer audit
- name: Check for known security vulnerabilities
uses: symfonycorp/security-checker-action@258311ef7ac571f1310780ef3d79fc5abef642b5 # v5

View File

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

View File

@@ -1,3 +1,5 @@
---
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: Code Style Check
on:
@@ -13,8 +15,10 @@ jobs:
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: shivammathur/setup-php@34a5396826718e0013f08e3e639d1c315d5f6b23 # 2.35.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
with:
php-version-file: '.php-version'
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Run PHP_CodeSniffer (PSR-12)

View File

@@ -4,15 +4,16 @@ name: Lint Code Base
on:
push:
branches: [master, main]
branches: [main]
pull_request:
branches: [master, main]
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: read-all
permissions:
contents: read
jobs:
Linter:
@@ -20,11 +21,14 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
statuses: write
contents: read
actions: write
contents: write
issues: write
packages: read
pull-requests: write
statuses: write
steps:
- name: Run PR Lint
# https://github.com/ivuorinen/actions
uses: ivuorinen/actions/pr-lint@8476cd4675ea8210eadf4a267bbeb13bddea4e75 # 25.7.21
uses: ivuorinen/actions/pr-lint@7f6a23b59316795c4b3cb3b3b28dd53e53655a33 # v2026.03.11

76
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,76 @@
---
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: Release
on:
push:
tags:
- "v*.*.*"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
name: Create Release
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Setup PHP
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
with:
php-version-file: '.php-version'
extensions: mbstring, xml, ctype, iconv, intl, json
tools: composer:v2
- name: Install dependencies
run: composer install --prefer-dist --no-progress --optimize-autoloader
- name: Run tests
run: composer test
- name: Run linting
run: composer lint
- name: Get tag name
id: tag
run: echo "name=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Extract changelog for this version
id: changelog
run: |
# Extract changelog section for this version
if [ -f CHANGELOG.md ]; then
# Get content between this version and next version header
awk '/^## \[${{ steps.tag.outputs.name }}\]/{flag=1; next} /^## \[/{flag=0} flag' CHANGELOG.md > /tmp/changelog.txt
if [ -s /tmp/changelog.txt ]; then
{
echo "content<<EOF"
cat /tmp/changelog.txt
echo "EOF"
} >> "$GITHUB_OUTPUT"
else
echo "content=Release ${{ steps.tag.outputs.name }}" >> "$GITHUB_OUTPUT"
fi
else
echo "content=Release ${{ steps.tag.outputs.name }}" >> "$GITHUB_OUTPUT"
fi
- name: Archive source code
run: |
mkdir -p release
composer archive --format=zip --dir=release --file=monolog-gdpr-filter-${{ steps.tag.outputs.name }}
- name: Create Release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
name: ${{ steps.tag.outputs.name }}
body: ${{ steps.changelog.outputs.content }}
draft: false
prerelease: ${{ contains(steps.tag.outputs.name, '-') }}
files: ./release/monolog-gdpr-filter-${{ steps.tag.outputs.name }}.zip

View File

@@ -4,7 +4,7 @@ name: Stale
on:
schedule:
- cron: '0 8 * * *' # Every day at 08:00
- cron: "0 8 * * *" # Every day at 08:00
workflow_call:
workflow_dispatch:
@@ -23,4 +23,4 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: ivuorinen/actions/stale@8476cd4675ea8210eadf4a267bbeb13bddea4e75 # 25.7.21
- uses: ivuorinen/actions/stale@7f6a23b59316795c4b3cb3b3b28dd53e53655a33 # v2026.03.11

View File

@@ -8,10 +8,10 @@ on:
- main
- master
paths:
- '.github/labels.yml'
- '.github/workflows/sync-labels.yml'
- ".github/labels.yml"
- ".github/workflows/sync-labels.yml"
schedule:
- cron: '34 5 * * *' # Run every day at 05:34 AM UTC
- cron: "34 5 * * *" # Run every day at 05:34 AM UTC
workflow_call:
workflow_dispatch:
merge_group:
@@ -20,7 +20,8 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: read-all
permissions:
contents: read
jobs:
labels:
@@ -34,8 +35,8 @@ jobs:
steps:
- name: ⤵️ Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: ⤵️ Sync Latest Labels Definitions
uses: ivuorinen/actions/sync-labels@8476cd4675ea8210eadf4a267bbeb13bddea4e75 # 25.7.21
uses: ivuorinen/actions/sync-labels@7f6a23b59316795c4b3cb3b3b28dd53e53655a33 # v2026.03.11

View File

@@ -1,3 +1,5 @@
---
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: Test & Coverage
on:
@@ -6,7 +8,7 @@ on:
push:
branches: [main]
permissions: read-all
permissions: {}
jobs:
test:
@@ -17,12 +19,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup PHP
uses: shivammathur/setup-php@34a5396826718e0013f08e3e639d1c315d5f6b23 # 2.35.0
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
with:
coverage: pcov
php-version-file: '.php-version'
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
@@ -35,23 +38,23 @@ jobs:
run: composer test:ci
- name: Upload coverage report
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: coverage-report
path: coverage.xml
- name: Code Coverage Summary Report
id: coverage-summary
uses: saschanowak/CloverCodeCoverageSummary@217593f67675e88fe1e6afeab0175018eb37deaa # 1.1.0
uses: saschanowak/CloverCodeCoverageSummary@74291a4a2e8b848605aae10d27adbe7d17aa71e5 # 1.1.1
with:
filename: coverage.xml
- name: 'Add Code Coverage to Job Summary'
- name: "Add Code Coverage to Job Summary"
run: |
cat code-coverage-summary.md >> $GITHUB_STEP_SUMMARY
cat code-coverage-details.md >> $GITHUB_STEP_SUMMARY
cat code-coverage-summary.md >> "$GITHUB_STEP_SUMMARY"
cat code-coverage-details.md >> "$GITHUB_STEP_SUMMARY"
- name: 'Add Code Coverage Summary as PR Comment'
- name: "Add Code Coverage Summary as PR Comment"
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
if: github.event_name == 'pull_request'
with:

2
.gitignore vendored
View File

@@ -18,3 +18,5 @@
# Ignore test coverage reports
/coverage/
coverage.xml
coverage*
*.bak

View File

@@ -8,6 +8,7 @@
"MD024": {
"siblings_only": true
},
"MD029": false,
"MD033": false,
"MD041": false
}

4
.markdownlintignore Normal file
View File

@@ -0,0 +1,4 @@
vendor/
node_modules/
coverage/
.git/

View File

@@ -35,3 +35,8 @@ TYPESCRIPT_ES_CONFIG_FILE: .eslintrc.json
FILTER_REGEX_EXCLUDE: >
(vendor|node_modules|\.automation/test|docs/json-schemas)
PHP_PHPCS_CLI_LINT_MODE: project
PHP_PHPCS_ARGUMENTS: "--warning-severity=0"
PHP_PSALM_CLI_LINT_MODE: project
PHP_PSALM_ARGUMENTS: "--memory-limit=1G"

134
.php-cs-fixer.dist.php Normal file
View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
$finder = PhpCsFixer\Finder::create()
->in(__DIR__ . '/src')
->in(__DIR__ . '/tests')
->name('*.php')
->notPath('vendor');
return (new PhpCsFixer\Config())
->setRiskyAllowed(true)
->setRules([
// PSR-12 compliance
'@PSR12' => true,
// Additional rules for better code quality
'array_syntax' => ['syntax' => 'short'],
'binary_operator_spaces' => ['default' => 'single_space'],
'blank_line_after_opening_tag' => true,
'blank_line_before_statement' => [
'statements' => ['return', 'try', 'throw', 'if', 'switch', 'for', 'foreach', 'while', 'do'],
],
'cast_spaces' => true,
'class_attributes_separation' => [
'elements' => [
'method' => 'one',
'property' => 'one',
'trait_import' => 'none',
],
],
'concat_space' => ['spacing' => 'one'],
'declare_strict_types' => true,
'fully_qualified_strict_types' => true,
'function_typehint_space' => true,
'general_phpdoc_tag_rename' => true,
'include' => true,
'increment_style' => ['style' => 'post'],
'linebreak_after_opening_tag' => true,
'lowercase_cast' => true,
'magic_constant_casing' => true,
'magic_method_casing' => true,
'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline'],
'native_function_casing' => true,
'native_function_type_declaration_casing' => true,
'new_with_braces' => true,
'no_alias_language_construct_call' => true,
'no_alternative_syntax' => true,
'no_binary_string' => true,
'no_blank_lines_after_phpdoc' => true,
'no_empty_phpdoc' => true,
'no_empty_statement' => true,
'no_extra_blank_lines' => [
'tokens' => [
'extra',
'throw',
'use',
],
],
'no_leading_import_slash' => true,
'no_leading_namespace_whitespace' => true,
'no_mixed_echo_print' => true,
'no_multiline_whitespace_around_double_arrow' => true,
'no_short_bool_cast' => true,
'no_singleline_whitespace_before_semicolons' => true,
'no_spaces_around_offset' => true,
'no_trailing_comma_in_singleline_array' => true,
'no_unneeded_control_parentheses' => true,
'no_unneeded_curly_braces' => true,
'no_unused_imports' => true,
'no_whitespace_before_comma_in_array' => true,
'normalize_index_brace' => true,
'object_operator_without_whitespace' => true,
'ordered_imports' => [
'imports_order' => ['class', 'function', 'const'],
'sort_algorithm' => 'alpha',
],
'phpdoc_align' => ['align' => 'left'],
'phpdoc_annotation_without_dot' => true,
'phpdoc_indent' => true,
'phpdoc_inline_tag_normalizer' => true,
'phpdoc_no_access' => true,
'phpdoc_no_alias_tag' => true,
'phpdoc_no_empty_return' => true,
'phpdoc_no_package' => true,
'phpdoc_no_useless_inheritdoc' => true,
'phpdoc_order' => true,
'phpdoc_return_self_reference' => true,
'phpdoc_scalar' => true,
'phpdoc_separation' => true,
'phpdoc_single_line_var_spacing' => true,
'phpdoc_summary' => true,
'phpdoc_tag_type' => true,
'phpdoc_to_comment' => true,
'phpdoc_trim' => true,
'phpdoc_trim_consecutive_blank_line_separation' => true,
'phpdoc_types' => true,
'phpdoc_types_order' => ['null_adjustment' => 'always_last'],
'phpdoc_var_without_name' => true,
'return_type_declaration' => true,
'semicolon_after_instruction' => true,
'short_scalar_cast' => true,
'single_blank_line_before_namespace' => true,
'single_class_element_per_statement' => true,
'single_line_comment_style' => true,
'single_quote' => true,
'space_after_semicolon' => ['remove_in_empty_for_expressions' => true],
'standardize_increment' => true,
'standardize_not_equals' => true,
'ternary_operator_spaces' => true,
'trailing_comma_in_multiline' => true,
'trim_array_spaces' => true,
'unary_operator_spaces' => true,
'visibility_required' => true,
'whitespace_after_comma_in_array' => true,
// Risky rules for better code quality
'strict_comparison' => true,
'strict_param' => true,
'array_push' => true,
'combine_consecutive_issets' => true,
'combine_consecutive_unsets' => true,
'dir_constant' => true,
'function_to_constant' => true,
'is_null' => true,
'modernize_types_casting' => true,
'no_alias_functions' => true,
'no_homoglyph_names' => true,
'non_printable_character' => true,
'php_unit_construct' => true,
'psr_autoloading' => true,
'self_accessor' => true,
])
->setFinder($finder);

1
.php-version Normal file
View File

@@ -0,0 +1 @@
8.4

View File

@@ -1,9 +1,8 @@
---
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v6.0.0
hooks:
- id: requirements-txt-fixer
- id: detect-private-key
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
@@ -23,41 +22,36 @@ repos:
args: [--autofix, --no-sort-keys]
- repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.44.0
rev: v0.48.0
hooks:
- id: markdownlint
args: [-c, .markdownlint.json, --fix]
- repo: https://github.com/adrienverge/yamllint
rev: v1.37.0
rev: v1.38.0
hooks:
- id: yamllint
- repo: https://github.com/scop/pre-commit-shfmt
rev: v3.11.0-1
rev: v3.12.0-2
hooks:
- id: shfmt
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.10.0
rev: v0.11.0
hooks:
- id: shellcheck
args: ['--severity=warning']
args: ["--severity=warning"]
- repo: https://github.com/rhysd/actionlint
rev: v1.7.7
rev: v1.7.11
hooks:
- id: actionlint
args: ['-shellcheck=']
- repo: https://github.com/renovatebot/pre-commit-hooks
rev: 39.227.2
hooks:
- id: renovate-config-validator
args: ["-shellcheck="]
- repo: https://github.com/bridgecrewio/checkov.git
rev: '3.2.400'
rev: "3.2.508"
hooks:
- id: checkov
args:
- '--quiet'
- "--quiet"

226
CHANGELOG.md Normal file
View File

@@ -0,0 +1,226 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- **Phase 6: Code Quality & Architecture ✅ COMPLETED (2025-07-29)**:
- **Custom Exception Classes**: Comprehensive exception hierarchy with rich context and error reporting
- `GdprProcessorException` - Base exception with context support for key-value error reporting
- `InvalidRegexPatternException` - Regex compilation errors with PCRE error details and ReDoS detection
- `MaskingOperationFailedException` - Failed masking operations with operation context and value previews
- `AuditLoggingException` - Audit logger failures with operation tracking and serialization error handling
- `RecursionDepthExceededException` - Deep nesting issues with recommendations and circular reference detection
- **Masking Strategy Interface System**: Complete extensible strategy pattern implementation
- `MaskingStrategyInterface` - Comprehensive method contracts for masking, validation, priority, and configuration
- `AbstractMaskingStrategy` - Base class with utilities for path matching, type preservation, and value conversion
- `RegexMaskingStrategy` - Pattern-based masking with ReDoS protection and include/exclude path filtering
- `FieldPathMaskingStrategy` - Dot-notation field path masking with wildcard support and FieldMaskConfig integration
- `ConditionalMaskingStrategy` - Context-aware conditional masking with AND/OR logic and factory methods
- `DataTypeMaskingStrategy` - PHP type-based masking with type-specific conversion and factory methods
- `StrategyManager` - Priority-based coordination with strategy validation, statistics, and default factory
- **PHP 8.2+ Modernization**: Comprehensive codebase modernization with backward compatibility
- Converted `FieldMaskConfig` to readonly class for immutability
- Added modern type declarations with proper imports (`Throwable`, `Closure`, `JsonException`)
- Applied `::class` syntax for class references instead of `get_class()`
- Implemented arrow functions where appropriate for concise code
- Used modern array comparisons (`=== []` instead of `empty()`)
- Enhanced string formatting with `sprintf()` for better performance
- Added newline consistency and proper imports throughout codebase
- **Code Quality Improvements**: Significant enhancements to code standards and type safety
- Fixed 287 PHPCS style issues automatically through code beautifier
- Reduced Psalm static analysis errors from 100+ to 61 (mostly false positives)
- Achieved 97.89% type coverage in Psalm analysis
- Applied 29 Rector modernization rules for PHP 8.2+ features
- Enhanced docblock types and removed redundant return tags
- Improved parameter type coercion and null safety
- **Phase 5: Advanced Features ✅ COMPLETED (2025-07-29)**:
- **Data Type-Based Masking**: Configurable type-specific masks for integers, strings, booleans, null, arrays, and objects
- **Conditional Masking**: Context-aware masking based on log level, channel, and custom rules with AND logic
- **Helper Methods**: Creating common conditional rules (level-based, channel-based, context field presence)
- **JSON String Masking**: Detection and recursive processing of JSON strings within log messages with validation
- **Rate Limiting**: Configurable audit logger rate limiting to prevent log flooding (profiles: strict, default, relaxed, testing)
- **Operation Classification**: Different rate limits for different operation types (JSON, conditional, regex, general)
- **Enhanced Audit Logging**: Detailed error context, conditional rule decisions, and operation tracking
- **Comprehensive Testing**: 30+ new tests across 6 test files (DataTypeMaskingTest, ConditionalMaskingTest, JsonMaskingTest, RateLimiterTest, RateLimitedAuditLoggerTest, GdprProcessorRateLimitingIntegrationTest)
- **Examples**: Created comprehensive examples for conditional masking and rate limiting features
- **Phase 4: Performance Optimizations ✅ COMPLETED (2025-07-29)**:
- **Exceptional Performance**: Optimized processing to 0.004ms per operation (exceeded 0.007ms target)
- **Static Pattern Caching**: 6.6% performance improvement after warmup with regex pattern validation
- **Recursion Depth Limiting**: Configurable maximum depth (default: 100 levels) preventing stack overflow
- **Memory-Efficient Processing**: Chunked processing for large nested arrays (1000+ items)
- **Automatic Garbage Collection**: For very large datasets (10,000+ items) with memory optimization
- **Memory Usage**: Optimized to only 2MB for 2,000 nested items with efficient data structures
- **Phase 4: Laravel Integration Package ✅ COMPLETED (2025-07-29)**:
- **Service Provider**: Complete Laravel Service Provider with auto-registration and configuration
- **Configuration**: Publishable configuration file with comprehensive GDPR processing options
- **Facade**: Laravel Facade for easy access (`Gdpr::regExpMessage()`, `Gdpr::createProcessor()`)
- **Artisan Commands**: Pattern testing and debugging commands (`gdpr:test-pattern`, `gdpr:debug`)
- **HTTP Middleware**: Request/response GDPR logging middleware for web applications
- **Documentation**: Comprehensive Laravel integration examples and step-by-step setup guide
- **Phase 4: Testing & Quality Assurance ✅ COMPLETED (2025-07-29)**:
- **Performance Benchmarks**: Tests measuring actual optimization impact (0.004ms per operation)
- **Memory Usage Tests**: Validation for large datasets with memory efficiency tracking
- **Concurrent Processing**: Simulation tests for high-volume concurrent processing scenarios
- **Pattern Caching**: Effectiveness validation showing 6.6% improvement after warmup
- **Major GDPR Pattern Expansion**: Added 15+ new patterns doubling coverage
- IPv4 and IPv6 IP address patterns
- Vehicle registration number patterns (US license plates)
- National ID patterns (UK National Insurance, Canadian SIN)
- Bank account patterns (UK sort codes, Canadian transit numbers)
- Health insurance patterns (US Medicare, European Health Insurance Cards)
- **Enhanced Security**:
- Regex pattern validation to prevent injection attacks
- ReDoS (Regular Expression Denial of Service) protection
- Comprehensive error handling replacing `@` suppression
- **Type Safety Improvements**:
- Fixed all PHPStan type errors for better code quality
- Enhanced type annotations throughout codebase
- Improved generic type specifications
- **Development Infrastructure**:
- PHPStan configuration file with maximum level analysis
- GitHub Actions CI/CD pipeline with multi-PHP version testing
- Automated security scanning and dependency updates
- Comprehensive documentation (CONTRIBUTING.md, SECURITY.md)
- **Quality Assurance**:
- Enhanced test suite with improved error handling validation
- All tests passing across PHP 8.2, 8.3, and 8.4
- Comprehensive linting with Psalm, PHPStan, and PHPCS
### Changed
- **Phase 6: Code Quality & Architecture (2025-07-29)**:
- **Exception System**: Replaced generic exceptions with specific, context-rich exception classes
- **Strategy Pattern**: Refactored masking logic into pluggable strategy system with priority management
- **Type System**: Enhanced type safety with PHP 8.2+ features and strict type declarations
- **Code Standards**: Applied modern PHP conventions and automated code quality improvements
- **Phase 5: Advanced Features (2025-07-29)**:
- **Improved Error Handling**: Replaced error suppression with proper try-catch blocks
- **Enhanced Audit Logging**: More detailed error context and security measures
- **Better Pattern Organization**: Grouped patterns by category with clear documentation
- **Type Safety**: Stricter type declarations and validation throughout
### Security
- **Phase 6: Enhanced Security (2025-07-29)**:
- **ReDoS Protection**: Enhanced regular expression denial of service detection in InvalidRegexPatternException
- **Type Safety**: Improved parameter validation and type coercion safety
- **Error Context**: Added secure error reporting without exposing sensitive data
- **Phase 5: Critical Security Fixes (2025-07-29)**:
- Eliminated regex injection vulnerabilities
- Added ReDoS attack protection
- Implemented pattern validation for untrusted input
- Enhanced audit logger security measures
### Fixed
- **Phase 6: Code Quality Fixes (2025-07-29)**:
- Fixed 287 PHPCS formatting and style issues
- Resolved Psalm type coercion warnings and parameter type issues
- Improved null safety and optional parameter handling
- Enhanced docblock accuracy and type specifications
- **Phase 5: Stability Fixes (2025-07-29)**:
- All PHPStan type safety errors resolved
- Improved error handling in regex processing
- Fixed potential security vulnerabilities in pattern handling
- Resolved test compatibility issues across PHP versions
## [Previous Versions]
### [1.0.0] - Initial Release
- Basic GDPR processor implementation
- Initial pattern set (Finnish SSN, US SSN, IBAN, etc.)
- Monolog integration
- Laravel compatibility
- Field-level masking with dot notation
- Custom callback support
- Audit logging functionality
---
## Migration Guide
### From 1.x to 2.x (Upcoming)
#### Breaking Changes
- None currently - maintaining backward compatibility
#### Deprecated Features
- `setAuditLogger()` method parameter type changed (constructor parameter preferred)
#### New Features
- 15+ new GDPR patterns available by default
- Enhanced security validation
- Improved error handling and logging
#### Security Improvements
- All regex patterns now validated for safety
- ReDoS protection enabled by default
- Enhanced audit logging security
### Developer Notes
#### Pattern Validation
New patterns are automatically validated for:
- Basic regex syntax correctness
- ReDoS attack patterns
- Security vulnerabilities
#### Error Handling
The library now uses proper exception handling instead of error suppression:
```php
// Old (deprecated)
$result = @preg_replace($pattern, $replacement, $input);
// New (secure)
try {
$result = preg_replace($pattern, $replacement, $input);
if ($result === null) {
// Handle error properly
}
} catch (\Error $e) {
// Handle regex compilation errors
}
```
#### Type Safety
Enhanced type declarations provide better IDE support and error detection:
```php
// Improved type annotations
/**
* @param array<string, string> $patterns
* @param array<string, FieldMaskConfig|string> $fieldPaths
*/
```
---
## Contributing
Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute to this project.
## Security
Please see [SECURITY.md](SECURITY.md) for information about reporting security vulnerabilities.
## Support
- **Documentation**: See README.md for usage examples
- **Issues**: Report bugs and request features via GitHub Issues
- **Discussions**: General questions via GitHub Discussions

198
CLAUDE.md Normal file
View File

@@ -0,0 +1,198 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
### Development
```bash
# Install dependencies
composer install
# Run all linting tools
composer lint
# Auto-fix code issues (runs Rector, Psalm fix, and PHPCBF)
composer lint:fix
# Run tests with coverage
composer test
composer test:coverage # Generates HTML coverage report
# Individual linting tools
composer lint:tool:phpcs # PHP_CodeSniffer
composer lint:tool:phpcbf # PHP Code Beautifier and Fixer
composer lint:tool:psalm # Static analysis
composer lint:tool:psalm:fix # Auto-fix Psalm issues
composer lint:tool:rector # Code refactoring
# Preview changes before applying (dry-run)
composer lint:tool:rector -- --dry-run
composer lint:tool:psalm -- --alter --dry-run
# Check for hardcoded constant values
php check_for_constants.php # Basic scan
php check_for_constants.php --verbose # Show line context
```
### Testing
```bash
# Run all tests
composer test
# Run specific test file
./vendor/bin/phpunit tests/GdprProcessorTest.php
# Run specific test method
./vendor/bin/phpunit --filter testMethodName
```
## Architecture
This is a Monolog processor library for GDPR compliance that masks sensitive data in logs.
### Core Components
1. **GdprProcessor** (`src/GdprProcessor.php`): The main processor implementing Monolog's `ProcessorInterface`
- Processes log records to mask/remove/replace sensitive data
- Supports regex patterns, field paths (dot notation), and custom callbacks
- Provides static factory methods for common field configurations
- Includes default GDPR patterns (SSN, credit cards, emails, etc.)
2. **FieldMaskConfig** (`src/FieldMaskConfig.php`): Configuration value object with three types:
- `MASK_REGEX`: Apply regex patterns to field value
- `REMOVE`: Remove field entirely from context
- `REPLACE`: Replace with static value
### Key Design Patterns
- **Processor Pattern**: Implements Monolog's ProcessorInterface for log record transformation
- **Value Objects**: FieldMaskConfig is immutable configuration
- **Factory Methods**: Static methods for creating common configurations
- **Dot Notation**: Uses `adbario/php-dot-notation` for nested array access (e.g., "user.email")
### Laravel Integration
The library can be integrated with Laravel in two ways:
1. Service Provider registration
2. Using a Tap class to modify logging channels
## Code Standards
- **PHP 8.4+** with strict types
- **PSR-12** coding standard (enforced by PHP_CodeSniffer)
- **Psalm Level 5** static analysis with conservative configuration
- **PHPStan Level 6** for additional code quality insights
- **Rector** for safe automated code improvements
- **EditorConfig**: 4 spaces, LF line endings, UTF-8, trim trailing whitespace
- **PHPUnit 11** for testing with strict configuration
### Static Analysis & Linting Policy
**All issues reported by static analysis tools MUST be fixed.** The project uses a comprehensive static analysis setup:
- **Psalm**: Conservative Level 5 with targeted suppressions for valid patterns
- **PHPStan**: Level 6 analysis with Laravel compatibility
- **Rector**: Safe automated improvements (return types, string casting, etc.)
- **PHPCS**: PSR-12 compliance enforcement
- **SonarQube**: Cloud-based code quality and security analysis (quality gate must pass)
**Issue Resolution Priority:**
1. **Fix the underlying issue** (preferred approach)
2. **Refactor code** to avoid the issue pattern
3. **Use safe automated fixes** via `composer lint:fix`
4. **Ask before suppressing** - Suppression should be used only as an absolute last resort and requires
explicit discussion
**Zero-Tolerance Policy:**
- **ALL issues must be addressed** - this includes ERROR, WARNING, and INFO level issues
- **INFO-level issues are NOT acceptable** - they indicate potential problems that should be resolved
- **Never ignore or suppress issues** without explicit approval and documented justification
- **Psalm INFO messages** should be addressed by:
- Refactoring code to avoid the pattern
- Adding proper type hints and assertions
- Using `@psalm-suppress` ONLY when absolutely necessary and with clear comments explaining why
- **Exit code must be 0** - any non-zero exit from linting tools is a failure
**Tip:** Use `git stash` before running `composer lint:fix` to easily revert changes if needed.
### SonarQube-Specific Guidelines
SonarQube is a **static analysis tool** that analyzes code structure,
not runtime behavior. Unlike human reviewers, it does NOT understand:
- PHPUnit's `expectException()` mechanism
- Test intent or context
- Comments explaining why code is written a certain way
**Common SonarQube issues and their fixes:**
1. **S1848: Useless object instantiation**
- **Issue**: `new ClassName()` in tests that expect exceptions
- **Why it occurs**: SonarQube doesn't understand `expectException()` means the object creation is the test
- **Fix**: Assign to variable and add assertion: `$obj = new ClassName(); $this->assertInstanceOf(...)`
2. **S4833: Replace require_once with use statement**
- **Issue**: Direct file inclusion instead of autoloading
- **Fix**: Use composer's autoloader and proper `use` statements
3. **S1172: Remove unused function parameter**
- **Issue**: Callback parameters that aren't used in the function body
- **Fix**: Remove unused parameters from function signature
4. **S112: Define dedicated exception instead of generic one**
- **Issue**: Throwing `\RuntimeException` or `\Exception` directly
- **Fix**: Use project-specific exceptions like `RuleExecutionException`, `MaskingOperationFailedException`
5. **S1192: Define constant instead of duplicating literal**
- **Issue**: String/number literals repeated 3+ times
- **Fix**: Add to `TestConstants` or `MaskConstants` and use the constant reference
6. **S1481: Remove unused local variable**
- **Issue**: Variable assigned but never read
- **Fix**: Remove assignment or use the variable
**IMPORTANT**: Comments and docblocks do NOT fix SonarQube issues. The code structure itself must be changed.
## Code Quality
### Constant Usage
To reduce code duplication and improve maintainability
(as required by SonarQube), the project uses centralized constants:
- **MaskConstants** (`src/MaskConstants.php`): Mask replacement values (e.g., `MASK_MASKED`, `MASK_REDACTED`)
- **TestConstants** (`tests/TestConstants.php`): Test data values, patterns, field paths, messages
**Always use constants instead of hardcoded strings** for values defined in these files.
Use the constant checker to identify hardcoded values:
```bash
# Scan for hardcoded constant values
php check_for_constants.php
# Show line context for each match
php check_for_constants.php --verbose
```
The checker intelligently scans all PHP files and reports where constant references should be used:
- **MaskConstants** checked in both `src/` and `tests/` directories
- **TestConstants** checked only in `tests/` directory (not enforced in production code)
- Filters out common false positives like array keys and internal identifiers
- Helps maintain SonarQube code quality standards
## Important Notes
- **Always run `composer lint:fix` before manual fixes**
- **Fix all linting issues** - suppression requires explicit approval
- **Use constants instead of hardcoded values** - run `php check_for_constants.php` to verify
- The library focuses on GDPR compliance - be careful when modifying masking logic
- Default patterns include Finnish SSN, US SSN, IBAN, credit cards, emails, phones, and IPs
- Audit logging feature can track when sensitive data was masked for compliance
- All static analysis tools are configured to work harmoniously without conflicts

277
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,277 @@
# Contributing to Monolog GDPR Filter
Thank you for your interest in contributing to Monolog GDPR Filter!
This document provides guidelines and information about contributing to this project.
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [Getting Started](#getting-started)
- [Development Setup](#development-setup)
- [Making Changes](#making-changes)
- [Testing](#testing)
- [Code Quality](#code-quality)
- [Submitting Changes](#submitting-changes)
- [Adding New GDPR Patterns](#adding-new-gdpr-patterns)
- [Security Issues](#security-issues)
## Code of Conduct
This project adheres to a code of conduct that promotes a welcoming and inclusive environment.
Please be respectful in all interactions.
## Getting Started
### Prerequisites
- PHP 8.4 or higher
- Composer
- Git
### Development Setup
1. **Fork and clone the repository:**
```bash
git clone https://github.com/yourusername/monolog-gdpr-filter.git
cd monolog-gdpr-filter
```
2. **Install dependencies:**
```bash
composer install
```
3. **Verify the setup:**
```bash
composer test
composer lint
```
## Making Changes
### Branch Structure
- `main` - Stable releases
- `develop` - Development branch for new features
- Feature branches: `feature/description`
- Bug fixes: `bugfix/description`
- Security fixes: `security/description`
### Workflow
1. **Create a feature branch:**
```bash
git checkout -b feature/your-feature-name
```
2. **Make your changes** following our coding standards
3. **Test your changes:**
```bash
composer test
composer lint
```
4. **Commit your changes:**
```bash
git commit -m "feat: add new GDPR pattern for vehicle registration"
```
## Testing
### Running Tests
```bash
# Run all tests
composer test
# Run tests with coverage (requires Xdebug)
composer test:coverage
# Run specific test file
./vendor/bin/phpunit tests/GdprProcessorTest.php
# Run specific test method
./vendor/bin/phpunit --filter testMethodName
```
### Writing Tests
- Write tests for all new functionality
- Follow existing test patterns in the `tests/` directory
- Use descriptive test method names
- Include both positive and negative test cases
- Test edge cases and error conditions
### Test Structure
```php
public function testNewGdprPattern(): void
{
$processor = new GdprProcessor([
'/your-pattern/' => '***MASKED***',
]);
$result = $processor->regExpMessage('sensitive data');
$this->assertSame('***MASKED***', $result);
}
```
## Code Quality
### Coding Standards
This project follows:
- **PSR-12** coding standard
- **PHPStan level max** for static analysis
- **Psalm** for additional type checking
### Quality Tools
```bash
# Run all linting tools
composer lint
# Auto-fix code style issues
composer lint:fix
# Individual tools
composer lint:tool:phpcs # PHP_CodeSniffer
composer lint:tool:phpcbf # PHP Code Beautifier and Fixer
composer lint:tool:psalm # Static analysis
composer lint:tool:phpstan # Static analysis (max level)
composer lint:tool:rector # Code refactoring
```
### Code Style Guidelines
- Use strict types: `declare(strict_types=1);`
- Use proper type hints for all parameters and return types
- Document all public methods with PHPDoc
- Use meaningful variable and method names
- Keep methods focused and concise
- Avoid deep nesting (max 3 levels)
## Submitting Changes
### Pull Request Process
1. **Ensure all checks pass:**
- All tests pass
- All linting checks pass
- No merge conflicts
2. **Write a clear PR description:**
- What changes were made
- Why the changes were necessary
- Any breaking changes
- Link to related issues
3. **PR Title Format:**
- `feat: add new feature`
- `fix: resolve bug in pattern matching`
- `docs: update README examples`
- `refactor: improve code structure`
- `test: add missing test coverage`
### Commit Message Guidelines
Follow [Conventional Commits](https://conventionalcommits.org/):
```text
type(scope): description
[optional body]
[optional footer(s)]
```
Types:
- `feat`: New features
- `fix`: Bug fixes
- `docs`: Documentation changes
- `style`: Code style changes
- `refactor`: Code refactoring
- `test`: Adding tests
- `chore`: Maintenance tasks
## Adding New GDPR Patterns
### Pattern Guidelines
When adding new GDPR patterns to the `getDefaultPatterns()` method:
1. **Be Specific**: Patterns should be specific enough to avoid false positives
2. **Security First**: Validate patterns using the built-in `isValidRegexPattern()` method
3. **Documentation**: Include clear comments explaining what the pattern matches
4. **Testing**: Add comprehensive tests for the new pattern
### Pattern Structure
```php
// Pattern comment explaining what it matches
'/your-regex-pattern/' => '***MASKED_TYPE***',
```
### Pattern Testing
```php
public function testNewPattern(): void
{
$patterns = GdprProcessor::getDefaultPatterns();
$processor = new GdprProcessor($patterns);
// Test positive case
$result = $processor->regExpMessage('sensitive-data-123');
$this->assertSame('***MASKED_TYPE***', $result);
// Test negative case (should not match)
$result = $processor->regExpMessage('normal-data');
$this->assertSame('normal-data', $result);
}
```
### Pattern Validation
Before submitting, validate your pattern:
```php
// Test pattern safety
GdprProcessor::validatePatterns([
'/your-pattern/' => '***TEST***'
]);
// Test ReDoS resistance
$processor = new GdprProcessor(['/your-pattern/' => '***TEST***']);
$result = $processor->regExpMessage('very-long-string-to-test-performance');
```
## Security Issues
If you discover a security vulnerability, please refer to our
[Security Policy](SECURITY.md) for responsible disclosure procedures.
## Questions and Support
- **Issues**: Use GitHub Issues for bug reports and feature requests
- **Discussions**: Use GitHub Discussions for questions and general discussion
- **Documentation**: Check README.md and code comments first
## Recognition
Contributors are recognized in:
- Git commit history
- Release notes for significant contributions
- Special thanks for security fixes
Thank you for contributing to Monolog GDPR Filter! 🎉

548
README.md
View File

@@ -1,143 +1,326 @@
# Monolog GDPR Filter
Monolog GDPR Filter is a PHP library that provides a Monolog processor for GDPR compliance. It allows masking, removing,
or replacing sensitive data in logs using regex patterns, field-level configuration, and custom callbacks. Designed for
easy integration with Monolog and Laravel.
A PHP library providing a Monolog processor for GDPR compliance.
Mask, remove, or replace sensitive data in logs using regex patterns, field-level configuration,
custom callbacks, and advanced features like streaming, rate limiting, and k-anonymity.
## Features
- **Regex-based masking** for patterns like SSNs, credit cards, emails
- **Field-level masking/removal/replacement** using dot-notation paths
- **Custom callbacks** for advanced masking logic per field
- **Audit logging** for compliance tracking
- **Easy integration with Monolog and Laravel**
### Core Masking
- **Regex-based masking** for patterns like SSNs, credit cards, emails, IPs, and more
- **Field-level masking** using dot-notation paths with flexible configuration
- **Custom callbacks** for advanced per-field masking logic
- **Data type masking** to mask values based on their PHP type
- **Serialized data support** for JSON, print_r, var_export, and serialize formats
### Enterprise Features
- **Fluent builder API** for readable processor configuration
- **Streaming processor** for memory-efficient large file processing
- **Rate-limited audit logging** to prevent log flooding
- **Plugin system** for extensible pre/post-processing hooks
- **K-anonymity support** for statistical privacy guarantees
- **Retry and recovery** with configurable failure modes
- **Conditional masking** based on log level, channel, or context
### Framework Integration
- **Monolog 3.x compatible** with ProcessorInterface implementation
- **Laravel integration** with service provider, middleware, and console commands
- **Audit logging** for compliance tracking and debugging
## Requirements
- PHP 8.4 or higher
- Monolog 3.x
## Installation
Install via Composer:
```bash
composer require ivuorinen/monolog-gdpr-filter
```
## Usage
### Basic Monolog Setup
## Quick Start
```php
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
$patterns = GdprProcessor::getDefaultPatterns();
$fieldPaths = [
'user.ssn' => GdprProcessor::removeField(),
'payment.card' => GdprProcessor::replaceWith('[CC]'),
'contact.email' => GdprProcessor::maskWithRegex(),
'metadata.session' => GdprProcessor::replaceWith('[SESSION]'),
];
// Optional: custom callback for advanced masking
$customCallbacks = [
'user.name' => fn($value) => strtoupper($value),
];
// Optional: audit logger for compliance
$auditLogger = function($path, $original, $masked) {
error_log("GDPR mask: $path: $original => $masked");
};
$logger = new Logger('app');
$logger->pushHandler(new StreamHandler('path/to/your.log', Logger::WARNING));
$logger->pushProcessor(
new GdprProcessor($patterns, $fieldPaths, $customCallbacks, $auditLogger)
// Create processor with default GDPR patterns
$processor = new GdprProcessor(
patterns: GdprProcessor::getDefaultPatterns(),
fieldPaths: [
'user.email' => FieldMaskConfig::remove(),
'user.ssn' => FieldMaskConfig::replace('[REDACTED]'),
]
);
$logger->warning('This is a warning message.', [
'user' => ['ssn' => '123456-900T'],
'contact' => ['email' => 'user@example.com'],
'payment' => ['card' => '1234567812345678'],
// Integrate with Monolog
$logger = new Logger('app');
$logger->pushHandler(new StreamHandler('app.log', Level::Warning));
$logger->pushProcessor($processor);
// Sensitive data is automatically masked
$logger->warning('User login', [
'user' => [
'email' => 'john@example.com', // Will be removed
'ssn' => '123-45-6789', // Will be replaced with [REDACTED]
]
]);
```
### FieldMaskConfig Options
## Core Concepts
- `GdprProcessor::maskWithRegex()` — Mask field value using regex patterns
- `GdprProcessor::removeField()` — Remove field from context
- `GdprProcessor::replaceWith($value)` — Replace field value with static value
### Regex Patterns
### Custom Callbacks
Provide custom callbacks for specific fields:
Define regex patterns to mask sensitive data in log messages and context values:
```php
$customCallbacks = [
'user.name' => fn($value) => strtoupper($value),
$patterns = [
'/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/' => '***EMAIL***',
'/\b\d{3}-\d{2}-\d{4}\b/' => '***SSN***',
'/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/' => '***CARD***',
];
$processor = new GdprProcessor(patterns: $patterns);
```
Use `GdprProcessor::getDefaultPatterns()` for a comprehensive set of pre-configured patterns
covering SSNs, credit cards, emails, phone numbers, IBANs, IP addresses, and more.
### Field Path Masking (FieldMaskConfig)
Configure masking for specific fields using dot-notation paths:
```php
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
$fieldPaths = [
// Remove field entirely from logs
'user.password' => FieldMaskConfig::remove(),
// Replace with static value
'payment.card_number' => FieldMaskConfig::replace('[CARD]'),
// Apply processor's regex patterns to this field
'user.bio' => FieldMaskConfig::useProcessorPatterns(),
// Apply custom regex pattern
'user.phone' => FieldMaskConfig::regexMask('/\d{3}-\d{4}/', '***-****'),
];
```
### Audit Logger
### Custom Callbacks
Optionally provide an audit logger callback to record masking actions:
Provide custom masking functions for complex scenarios:
```php
$auditLogger = function($path, $original, $masked) {
// Log or store audit info
};
$customCallbacks = [
'user.name' => fn($value) => strtoupper(substr($value, 0, 1)) . '***',
'user.id' => fn($value) => hash('sha256', (string) $value),
];
$processor = new GdprProcessor(
patterns: [],
fieldPaths: [],
customCallbacks: $customCallbacks
);
```
> **IMPORTANT**: Be mindful what you send to your audit log. Passing the original value might defeat the whole purpose
> of this project.
## Basic Usage
### Direct GdprProcessor Usage
```php
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
$processor = new GdprProcessor(
patterns: GdprProcessor::getDefaultPatterns(),
fieldPaths: [
'user.ssn' => FieldMaskConfig::remove(),
'payment.card' => FieldMaskConfig::replace('[REDACTED]'),
'contact.email' => FieldMaskConfig::useProcessorPatterns(),
],
customCallbacks: [
'user.name' => fn($v) => strtoupper($v),
],
auditLogger: function($path, $original, $masked) {
// Log masking operations for compliance
error_log("Masked: $path");
},
maxDepth: 100,
);
```
### Using GdprProcessorBuilder (Recommended)
The builder provides a fluent, readable API:
```php
use Ivuorinen\MonologGdprFilter\Builder\GdprProcessorBuilder;
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
$processor = GdprProcessorBuilder::create()
->withDefaultPatterns()
->addPattern('/custom-secret-\w+/', '[SECRET]')
->addFieldPath('user.email', FieldMaskConfig::remove())
->addFieldPath('user.ssn', FieldMaskConfig::replace('[SSN]'))
->addCallback('user.id', fn($v) => hash('sha256', (string) $v))
->withMaxDepth(50)
->withAuditLogger(function($path, $original, $masked) {
// Audit logging
})
->build();
```
## Advanced Features
### Conditional Masking
Apply masking only when specific conditions are met:
```php
use Ivuorinen\MonologGdprFilter\ConditionalRuleFactory;
use Monolog\Level;
$processor = new GdprProcessor(
patterns: GdprProcessor::getDefaultPatterns(),
conditionalRules: [
// Only mask error-level logs
'error_only' => ConditionalRuleFactory::createLevelBasedRule([Level::Error]),
// Only mask specific channels
'app_channel' => ConditionalRuleFactory::createChannelBasedRule(['app', 'security']),
// Custom condition
'has_user' => fn($record) => isset($record->context['user']),
]
);
```
### Data Type Masking
Mask values based on their PHP type:
```php
use Ivuorinen\MonologGdprFilter\MaskConstants;
$processor = new GdprProcessor(
patterns: [],
dataTypeMasks: [
'integer' => MaskConstants::MASK_INT,
'double' => MaskConstants::MASK_FLOAT,
'boolean' => MaskConstants::MASK_BOOL,
]
);
```
### Rate-Limited Audit Logging
Prevent audit log flooding in high-volume applications:
```php
use Ivuorinen\MonologGdprFilter\RateLimitedAuditLogger;
$baseLogger = function($path, $original, $masked) {
// Your audit logging logic
};
// Create rate-limited wrapper (100 logs per minute)
$rateLimitedLogger = new RateLimitedAuditLogger($baseLogger, 100, 60);
$processor = new GdprProcessor(
patterns: GdprProcessor::getDefaultPatterns(),
auditLogger: $rateLimitedLogger
);
// Available rate limit profiles via factory
$strictLogger = RateLimitedAuditLogger::create($baseLogger, 'strict'); // 50/min
$defaultLogger = RateLimitedAuditLogger::create($baseLogger, 'default'); // 100/min
$relaxedLogger = RateLimitedAuditLogger::create($baseLogger, 'relaxed'); // 200/min
```
### Streaming Large Files
Process large log files with memory-efficient streaming:
```php
use Ivuorinen\MonologGdprFilter\Streaming\StreamingProcessor;
use Ivuorinen\MonologGdprFilter\MaskingOrchestrator;
$orchestrator = new MaskingOrchestrator(GdprProcessor::getDefaultPatterns());
$streaming = new StreamingProcessor($orchestrator, chunkSize: 1000);
// Process file line by line
$lineParser = fn(string $line) => ['message' => $line, 'context' => []];
foreach ($streaming->processFile('large-app.log', $lineParser) as $maskedRecord) {
// Write to output file or process further
fwrite($output, $maskedRecord['message'] . "\n");
}
// Or process to file directly
$formatter = fn(array $record) => json_encode($record);
$count = $streaming->processToFile($records, 'masked-output.log', $formatter);
```
## Laravel Integration
You can integrate the GDPR processor with Laravel logging in two ways:
### 1. Service Provider
### Service Provider
```php
// app/Providers/AppServiceProvider.php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
class AppServiceProvider extends ServiceProvider
{
public function boot()
public function boot(): void
{
$patterns = GdprProcessor::getDefaultPatterns();
$fieldPaths = [
'user.ssn' => '[GDPR]',
'payment.card' => '[CC]',
'contact.email' => '', // empty string = regex mask
'metadata.session' => '[SESSION]',
];
$this->app['log']->getLogger()
->pushProcessor(new GdprProcessor($patterns, $fieldPaths));
$processor = new GdprProcessor(
patterns: GdprProcessor::getDefaultPatterns(),
fieldPaths: [
'user.email' => FieldMaskConfig::remove(),
'user.password' => FieldMaskConfig::remove(),
]
);
$this->app['log']->getLogger()->pushProcessor($processor);
}
}
```
### 2. Tap Class (config/logging.php)
### Tap Class
```php
// app/Logging/GdprTap.php
namespace App\Logging;
use Monolog\Logger;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
class GdprTap
{
public function __invoke(Logger $logger)
public function __invoke(Logger $logger): void
{
$patterns = GdprProcessor::getDefaultPatterns();
$fieldPaths = [
'user.ssn' => '[GDPR]',
'payment.card' => '[CC]',
'contact.email' => '',
'metadata.session' => '[SESSION]',
];
$logger->pushProcessor(new GdprProcessor($patterns, $fieldPaths));
$processor = new GdprProcessor(
patterns: GdprProcessor::getDefaultPatterns(),
fieldPaths: [
'user.email' => FieldMaskConfig::remove(),
'payment.card' => FieldMaskConfig::replace('[CARD]'),
]
);
$logger->pushProcessor($processor);
}
}
```
@@ -146,79 +329,192 @@ Reference in `config/logging.php`:
```php
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['single'],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
'tap' => [App\Logging\GdprTap::class],
],
// ...
],
```
## Configuration
### Console Commands
You can configure the processor to filter out sensitive data by specifying:
- **Regex patterns:** Used for masking values in messages and context
- **Field paths:** Dot-notation paths for masking/removal/replacement
- **Custom callbacks:** For advanced per-field masking
- **Audit logger:** For compliance tracking
## Testing & Quality
This project uses PHPUnit for testing, Psalm and PHPStan for static analysis, and PHP_CodeSniffer for code style checks.
### Running Tests
To run the test suite:
The library provides Artisan commands for testing and debugging:
```bash
# Test a pattern against sample data
php artisan gdpr:test-pattern '/\b\d{3}-\d{2}-\d{4}\b/' 'SSN: 123-45-6789'
# Debug current GDPR configuration
php artisan gdpr:debug
```
## Plugin System
Extend the processor with custom pre/post-processing hooks:
```php
use Ivuorinen\MonologGdprFilter\Contracts\MaskingPluginInterface;
use Ivuorinen\MonologGdprFilter\Builder\GdprProcessorBuilder;
class CustomPlugin implements MaskingPluginInterface
{
public function getName(): string
{
return 'custom-plugin';
}
public function getPriority(): int
{
return 10; // Lower = earlier execution
}
public function preProcessMessage(string $message): string
{
// Modify message before masking
return $message;
}
public function postProcessMessage(string $message): string
{
// Modify message after masking
return $message;
}
public function preProcessContext(array $context): array
{
return $context;
}
public function postProcessContext(array $context): array
{
return $context;
}
}
$processor = GdprProcessorBuilder::create()
->withDefaultPatterns()
->addPlugin(new CustomPlugin())
->buildWithPlugins();
```
## Default Patterns Reference
`GdprProcessor::getDefaultPatterns()` includes patterns for:
| Category | Data Types |
| -------- | ---------- |
| Personal IDs | Finnish SSN (HETU), US SSN, Passport numbers, National IDs |
| Financial | Credit cards, IBAN, Bank account numbers |
| Contact | Email addresses, Phone numbers (E.164) |
| Technical | IPv4/IPv6 addresses, MAC addresses, API keys, Bearer tokens |
| Health | Medicare numbers, European Health Insurance Card (EHIC) |
| Dates | Birth dates in multiple formats |
## Performance Considerations
### Pattern Optimization
Order patterns from most specific to most general:
```php
// Recommended: specific patterns first
$patterns = [
'/\b\d{3}-\d{2}-\d{4}\b/' => '***SSN***', // Specific format
'/\b\d+\b/' => '***NUMBER***', // Generic fallback
];
```
### Memory-Efficient Processing
For large datasets:
- Use `StreamingProcessor` for file-based processing
- Configure appropriate `maxDepth` to limit recursion
- Use rate-limited audit logging to prevent memory growth
### Pattern Caching
Patterns are validated and cached internally.
For high-throughput applications, the library automatically caches compiled patterns.
## Troubleshooting
### Pattern Not Matching
```php
// Test pattern in isolation
$pattern = '/your-pattern/';
if (preg_match($pattern, $testString)) {
echo 'Pattern matches';
}
// Validate pattern safety
try {
GdprProcessor::validatePatternsArray([
'/your-pattern/' => '***MASKED***'
]);
} catch (PatternValidationException $e) {
echo 'Invalid pattern: ' . $e->getMessage();
}
```
### Performance Issues
- Reduce pattern count to essential patterns only
- Use field-specific masking instead of broad regex patterns
- Profile with audit logging to identify slow operations
### Audit Logger Issues
```php
// Safe audit logging (never log original sensitive data)
$auditLogger = function($path, $original, $masked) {
error_log(sprintf(
'GDPR Audit: %s - type=%s, masked=%s',
$path,
gettype($original),
$original !== $masked ? 'yes' : 'no'
));
};
```
## Testing and Quality
```bash
# Run tests
composer test
```
To generate a code coverage report (HTML output in the `coverage/` directory):
```bash
# Run tests with coverage report
composer test:coverage
```
### Linting & Static Analysis
To run all linters and static analysis:
```bash
# Run all linters
composer lint
```
To automatically fix code style and static analysis issues:
```bash
# Auto-fix code style issues
composer lint:fix
```
## Notable Implementation Details
## Security
- If a regex replacement in `regExpMessage` results in an empty string or the string "0", the original message is
returned. This is covered by dedicated PHPUnit tests.
- If a regex pattern is invalid, the audit logger (if set) is called, and the original message is returned.
- All patterns are validated for safety before use to prevent regex injection attacks
- The library includes ReDoS (Regular Expression Denial of Service) protection
- Dangerous patterns with recursive structures or excessive backtracking are rejected
## Directory Structure
- `src/` — Main library source code
- `tests/` — PHPUnit tests
- `coverage/` — Code coverage reports
- `vendor/` — Composer dependencies
For security vulnerabilities, please see [SECURITY.md](SECURITY.md) for responsible disclosure guidelines.
## Legal Disclaimer
> **CAUTION**: This library helps mask/filter sensitive data for GDPR compliance, but it is your responsibility to
> ensure your application fully complies with all legal requirements. Review your logging and data handling policies
> regularly.
This library helps mask and filter sensitive data for GDPR compliance, but it is your responsibility
to ensure your application fully complies with all applicable legal requirements.
This tool is provided as-is without warranty.
Review your logging and data handling policies regularly with legal counsel.
## Contributing
If you would like to contribute to this project, please fork the repository and submit a pull request.
Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
## License
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details.
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.

266
SECURITY.md Normal file
View File

@@ -0,0 +1,266 @@
# Security Policy
## Table of Contents
- [Supported Versions](#supported-versions)
- [Security Features](#security-features)
- [Reporting Security Vulnerabilities](#reporting-security-vulnerabilities)
- [Security Best Practices](#security-best-practices)
- [Known Security Considerations](#known-security-considerations)
- [Security Measures Implemented](#security-measures-implemented)
## Supported Versions
We actively support the following versions with security updates:
| Version | Supported | PHP Requirements |
| ------- | -------------------- | ---------------- |
| 2.x | Active support | PHP 8.4+ |
| 1.x | Security fixes only | PHP 8.4+ |
## Security Features
This library includes several built-in security features:
### 🛡️ Regex Injection Protection
- All regex patterns are validated before use
- Input sanitization prevents malicious pattern injection
- Built-in pattern validation using `isValidRegexPattern()`
### 🛡️ ReDoS (Regular Expression Denial of Service) Protection
- Automatic detection of dangerous regex patterns
- Protection against nested quantifiers and excessive backtracking
- Safe pattern compilation with error handling
### 🛡️ Secure Error Handling
- No error suppression (`@`) operators used
- Proper exception handling for all regex operations
- Comprehensive error logging for security monitoring
### 🛡️ Audit Trail Security
- Secure audit logging with configurable callbacks
- Protection against sensitive data exposure in audit logs
- Validation of audit logger parameters
## Reporting Security Vulnerabilities
If you discover a security vulnerability, please follow these steps:
### 🚨 **DO NOT** create a public GitHub issue for security vulnerabilities
### ✅ **DO** report privately using one of these methods
1. **GitHub Security Advisories** (Preferred):
- Go to the [Security tab](https://github.com/ivuorinen/monolog-gdpr-filter/security)
- Click "Report a vulnerability"
- Provide detailed information about the vulnerability
2. **Direct Email**:
- Send to: [security@ivuorinen.com](mailto:security@ivuorinen.com)
- Use subject: "SECURITY: Monolog GDPR Filter Vulnerability"
- Include GPG encrypted message if possible
### 📝 What to Include in Your Report
Please provide as much information as possible:
- **Description**: Clear description of the vulnerability
- **Impact**: Potential impact and attack scenarios
- **Reproduction**: Step-by-step reproduction instructions
- **Environment**: PHP version, library version, OS details
- **Proof of Concept**: Code example demonstrating the issue
- **Suggested Fix**: If you have ideas for remediation
### 🕒 Response Timeline
- **Initial Response**: Within 48 hours
- **Vulnerability Assessment**: Within 1 week
- **Fix Development**: Depends on severity (1-4 weeks)
- **Release**: Security fixes are prioritized
- **Public Disclosure**: After fix is released and users have time to update
## Security Best Practices
### For Users of This Library
#### ✅ Pattern Validation
Always validate custom patterns before use:
```php
// Good: Validate custom patterns
try {
GdprProcessor::validatePatterns([
'/your-custom-pattern/' => '***MASKED***'
]);
$processor = new GdprProcessor($patterns);
} catch (InvalidArgumentException $e) {
// Handle invalid pattern
}
```
#### ✅ Secure Audit Logging
Be careful with audit logger implementation:
```php
// Good: Secure audit logger
$auditLogger = function (string $path, mixed $original, mixed $masked): void {
// DON'T log the original sensitive data
error_log("GDPR: Masked field '{$path}' - type: " . gettype($original));
};
// Bad: Insecure audit logger
$auditLogger = function (string $path, mixed $original, mixed $masked): void {
// NEVER do this - logs sensitive data!
error_log("GDPR: {$path} changed from {$original} to {$masked}");
};
```
#### ✅ Input Validation
Validate input when using custom callbacks:
```php
// Good: Validate callback input
$customCallback = function (mixed $value): string {
if (!is_string($value)) {
return '***INVALID***';
}
// Additional validation
if (strlen($value) > 1000) {
return '***TOOLONG***';
}
return preg_replace('/sensitive/', '***MASKED***', $value) ?? '***ERROR***';
};
```
#### ✅ Regular Updates
- Keep the library updated to get security fixes
- Monitor security advisories
- Review changelogs for security-related changes
### For Contributors
#### 🔒 Secure Development Practices
1. **Never commit sensitive data**:
- No real credentials, tokens, or personal data in tests
- Use placeholder data only
- Review diffs before committing
2. **Validate all regex patterns**:
```php
// Always test new patterns for security
if (!$this->isValidRegexPattern($pattern)) {
throw new InvalidArgumentException('Invalid pattern');
}
```
3. **Use proper error handling**:
```php
// Good
try {
$result = preg_replace($pattern, $replacement, $input);
} catch (\Error $e) {
// Handle error
}
// Bad
$result = @preg_replace($pattern, $replacement, $input);
```
## Known Security Considerations
### ⚠️ Performance Considerations
- Complex regex patterns may cause performance issues
- Large input strings should be validated for reasonable size
- Consider implementing timeouts for regex operations
### ⚠️ Pattern Conflicts
- Multiple patterns may interact unexpectedly
- Pattern order matters for security
- Test all patterns together, not just individually
### ⚠️ Audit Logging
- Audit loggers can inadvertently log sensitive data
- Implement audit loggers carefully
- Consider what data is actually needed for compliance
## Security Measures Implemented
### 🔒 Code-Level Security
1. **Input Validation**:
- All regex patterns validated before compilation
- ReDoS pattern detection and prevention
- Type safety enforcement with strict typing
2. **Error Handling**:
- No error suppression operators used
- Comprehensive exception handling
- Secure failure modes
3. **Memory Safety**:
- Proper resource cleanup
- Prevention of memory exhaustion attacks
- Bounded regex operations
### 🔒 Development Security
1. **Static Analysis**:
- PHPStan at maximum level
- Psalm static analysis
- Security-focused linting rules
2. **Automated Testing**:
- Comprehensive test suite
- Security-specific test cases
- Continuous integration with security checks
3. **Dependency Management**:
- Regular dependency updates via Dependabot
- Security vulnerability scanning
- Minimal dependency footprint
### 🔒 Release Security
1. **Secure Release Process**:
- Automated builds and testing
- Signed releases
- Security review before major releases
2. **Version Management**:
- Semantic versioning for security transparency
- Clear documentation of security changes
- Migration guides for security updates
## Contact
For security-related questions or concerns:
- **Security Issues**: Use GitHub Security Advisories or email <security@ivuorinen.com>
- **General Questions**: Create a GitHub Discussion
- **Documentation**: Refer to README.md and inline code documentation
## Acknowledgments
We appreciate responsible disclosure from security researchers and the community.
Contributors who report valid security vulnerabilities will be acknowledged
in release notes (unless they prefer to remain anonymous).
---
**Last Updated**: 2025-07-29

121
TODO.md Normal file
View File

@@ -0,0 +1,121 @@
# TODO.md - Monolog GDPR Filter
This file tracks remaining issues, improvements, and feature requests for the monolog-gdpr-filter library.
## Current Status - PRODUCTION READY
**Project Statistics (verified 2025-12-01):**
- **141 PHP files** (60 source files, 81 test files)
- **1,346 tests** with **100% success rate** (3,386 assertions)
- **85.07% line coverage**, **88.31% method coverage**
- **PHP 8.4+** with modern language features and strict type safety
- **Zero Critical Issues**: All functionality-blocking bugs resolved
- **Static Analysis**: All tools pass cleanly (Psalm, PHPStan, Rector, PHPCS)
## Static Analysis Status
All static analysis tools now pass:
- **Psalm Level 5**: 0 errors
- **PHPStan Level 6**: 0 errors
- **Rector**: No changes needed
- **PHPCS**: 0 errors, 0 warnings
## Completed Items (2025-12-01)
### Developer Experience
- [x] **Added recovery mechanism** for failed masking operations
- `src/Recovery/FailureMode.php` - Enum for failure modes (FAIL_OPEN, FAIL_CLOSED, FAIL_SAFE)
- `src/Recovery/RecoveryStrategy.php` - Interface for recovery strategies
- `src/Recovery/RecoveryResult.php` - Value object for recovery outcomes
- `src/Recovery/RetryStrategy.php` - Retry with exponential backoff
- `src/Recovery/FallbackMaskStrategy.php` - Type-aware fallback values
- [x] **Improved error context** in audit logging with detailed context
- `src/Audit/ErrorContext.php` - Standardized error information with sensitive data sanitization
- `src/Audit/AuditContext.php` - Structured context for audit entries with operation types
- `src/Audit/StructuredAuditLogger.php` - Enhanced audit logger wrapper
- [x] **Created interactive demo/playground** for pattern testing
- `demo/PatternTester.php` - Pattern testing utility
- `demo/index.php` - Web API endpoint
- `demo/templates/playground.html` - Interactive web interface
### Code Quality
- [x] **Fixed all PHPCS Warnings** (81 warnings → 0):
- Added missing PHPDoc documentation blocks
- Fixed line length and spacing formatting issues
- Full PSR-12 compliance achieved
### Framework Integration
- [x] **Created Symfony integration guide** - `docs/symfony-integration.md`
- [x] **Added PSR-3 logger decorator pattern example** - `docs/psr3-decorator.md`
- [x] **Created Docker development environment** - `docker/Dockerfile`, `docker/docker-compose.yml`
- [x] **Added examples for other popular frameworks** - `docs/framework-examples.md`
- CakePHP, CodeIgniter 4, Laminas, Yii2, PSR-15 middleware
### Architecture
- [x] **Extended Strategy Pattern support**:
- `src/Strategies/CallbackMaskingStrategy.php` - Wraps custom callbacks as strategies
- Factory methods: `constant()`, `hash()`, `partial()` for common use cases
### Advanced Features (Completed 2025-12-01)
- [x] **Support masking arrays/objects in message strings**
- `src/SerializedDataProcessor.php` - Handles print_r, var_export, serialize output formats
- [x] **Add data anonymization with k-anonymity**
- `src/Anonymization/KAnonymizer.php` - K-anonymity implementation for GDPR compliance
- `src/Anonymization/GeneralizationStrategy.php` - Age, date, location, numeric range strategies
- [x] **Add retention policy support**
- `src/Retention/RetentionPolicy.php` - Configurable retention periods with actions (delete, anonymize, archive)
- [x] **Add data portability features (export masked logs)**
- `src/Streaming/StreamingProcessor.php::processToFile()` - Export processed logs to files
- [x] **Implement streaming processing for very large logs**
- `src/Streaming/StreamingProcessor.php` - Memory-efficient chunked processing with generators
### Architecture Improvements (Completed 2025-12-01)
- [x] **Refactor to follow Single Responsibility Principle more strictly**
- `src/MaskingOrchestrator.php` - Extracted masking coordination from GdprProcessor
- [x] **Reduce coupling with `Adbar\Dot` library (create abstraction)**
- `src/Contracts/ArrayAccessorInterface.php` - Abstraction interface
- `src/ArrayAccessor/DotArrayAccessor.php` - Implementation using adbario/php-dot-notation
- `src/ArrayAccessor/ArrayAccessorFactory.php` - Factory for creating accessors
- [x] **Add dependency injection container support**
- `src/Builder/GdprProcessorBuilder.php` - Fluent builder for configuration
- [x] **Replace remaining static methods for better testability**
- `src/Factory/AuditLoggerFactory.php` - Instance-based factory for audit loggers
- `src/PatternValidator.php` - Instance methods added (static methods deprecated)
- [x] **Implement plugin architecture for custom processors**
- `src/Contracts/MaskingPluginInterface.php` - Contract for masking plugins
- `src/Plugins/AbstractMaskingPlugin.php` - Base class with no-op defaults
- `src/Builder/PluginAwareProcessor.php` - Wrapper with pre/post processing hooks
### Documentation (Completed 2025-12-01)
- [x] **Create performance tuning guide**
- `docs/performance-tuning.md` - Benchmarking, pattern optimization, memory management, caching, streaming
- [x] **Add troubleshooting guide with common issues**
- `docs/troubleshooting.md` - Installation, pattern matching, performance, memory, integration issues
- [x] **Add integration examples with popular logging solutions**
- `docs/logging-integrations.md` - ELK, Graylog, Datadog, New Relic, Sentry, Papertrail, Loggly, AWS CloudWatch, Google Cloud, Fluentd
- [x] **Create plugin development guide**
- `docs/plugin-development.md` - Comprehensive guide for creating custom masking plugins (interface, hooks, priority, use cases)
## Development Notes
- **All critical, high, medium, and low priority functionality is complete**
- **Project is production-ready** with comprehensive test coverage (85.07% line coverage)
- **Static analysis tools all pass** - maintain this standard
- **Use `composer lint:fix` for automated code quality improvements**
- **Follow linting policy: fix issues, don't suppress unless absolutely necessary**
- **Run demo**: `php -S localhost:8080 demo/index.php`
---
**Last Updated**: 2025-12-01
**Production Status**: Ready
**All Items**: Complete

325
check_for_constants.php Executable file
View File

@@ -0,0 +1,325 @@
#!/usr/bin/env php
<?php
/**
* Check for hardcoded constant values in PHP files.
*
* This script scans all PHP files in the project and identifies places where
* constant values from MaskConstants and TestConstants are hardcoded instead
* of using the actual constant references.
*
* Usage: php check_for_constants.php [--verbose]
*/
declare(strict_types=1);
// ANSI color codes for better readability
const COLOR_RED = "\033[31m";
const COLOR_GREEN = "\033[32m";
const COLOR_YELLOW = "\033[33m";
const COLOR_BLUE = "\033[34m";
const COLOR_MAGENTA = "\033[35m";
const COLOR_CYAN = "\033[36m";
const COLOR_RESET = "\033[0m";
const COLOR_BOLD = "\033[1m";
$verbose = in_array('--verbose', $argv) || in_array('-v', $argv);
echo "\n";
echo sprintf("%s%s+%s+\n%s", COLOR_BOLD, COLOR_CYAN, str_repeat("=", 62), COLOR_RESET);
echo sprintf(
"%s%s| Constant Value Duplication Checker%s|\n%s",
COLOR_BOLD,
COLOR_CYAN,
str_repeat(" ", 26),
COLOR_RESET
);
echo sprintf("%s%s+%s+\n%s", COLOR_BOLD, COLOR_CYAN, str_repeat("=", 62), COLOR_RESET);
echo "\n";
// Load constant files
$maskConstantsFile = __DIR__ . '/src/MaskConstants.php';
$testConstantsFile = __DIR__ . '/tests/TestConstants.php';
if (!file_exists($maskConstantsFile)) {
echo sprintf(
"%sError: MaskConstants file not found at: $maskConstantsFile\n%s",
COLOR_RED,
COLOR_RESET
);
exit(1);
}
if (!file_exists($testConstantsFile)) {
echo sprintf(
"%sError: TestConstants file not found at: $testConstantsFile\n%s",
COLOR_RED,
COLOR_RESET
);
exit(1);
}
echo COLOR_BLUE . "Loading constants from:\n" . COLOR_RESET;
echo " - src/MaskConstants.php\n";
echo " - tests/TestConstants.php\n\n";
// Load composer autoloader to enable namespace imports
require_once __DIR__ . '/vendor/autoload.php';
use Ivuorinen\MonologGdprFilter\MaskConstants;
use Tests\TestConstants;
try {
$maskReflection = new ReflectionClass(MaskConstants::class);
$maskConstants = $maskReflection->getConstants();
$testReflection = new ReflectionClass(TestConstants::class);
$testConstants = $testReflection->getConstants();
} catch (ReflectionException $e) {
echo sprintf("%sError loading constants: %s\n%s", COLOR_RED, $e->getMessage(), COLOR_RESET);
exit(1);
}
echo sprintf(
"%s✓ Loaded %s constants from MaskConstants\n%s",
COLOR_GREEN,
count($maskConstants),
COLOR_RESET
);
echo sprintf(
"%s✓ Loaded %s constants from TestConstants\n%s",
COLOR_GREEN,
count($testConstants),
COLOR_RESET
);
echo sprintf(
"%s Note: TestConstants only checked in tests/ directory\n\n%s",
COLOR_BLUE,
COLOR_RESET
);
// Combine all constants for searching
$allConstants = [
'MaskConstants' => $maskConstants,
'TestConstants' => $testConstants,
];
// Find all PHP files to scan
$phpFiles = [];
$directories = [
__DIR__ . '/src',
__DIR__ . '/tests',
];
foreach ($directories as $dir) {
if (!is_dir($dir)) {
continue;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
// Skip the constant definition files themselves
$realPath = $file->getRealPath();
if ($realPath === $maskConstantsFile || $realPath === $testConstantsFile) {
continue;
}
$phpFiles[] = $file->getRealPath();
}
}
}
echo COLOR_BLUE . "Scanning " . count($phpFiles)
. " PHP files for hardcoded constant values...\n\n" . COLOR_RESET;
// Track findings
$findings = [];
$filesChecked = 0;
$totalMatches = 0;
// Scan each file for hardcoded constant values
foreach ($phpFiles as $filePath) {
$filesChecked++;
$content = file_get_contents($filePath);
$lines = explode("\n", $content);
// Determine if this is a test file
$isTestFile = str_contains($filePath, '/tests/');
foreach ($allConstants as $className => $constants) {
// Skip TestConstants for non-test files (src/ directory)
if ($className === 'TestConstants' && !$isTestFile) {
continue;
}
foreach ($constants as $constantName => $constantValue) {
// Skip non-string constants and empty values
if (!is_string($constantValue) || strlen($constantValue) === 0) {
continue;
}
// Skip very generic values that would produce too many false positives
$skipGeneric = [
'test',
'value',
'field',
'path',
'key',
'data',
'name',
'id',
'type',
'error'
];
if (
in_array(strtolower($constantValue), $skipGeneric)
&& strlen($constantValue) < 10
) {
continue;
}
// Additional filtering for src/ files - skip common internal identifiers
if (!$isTestFile) {
// In src/ files, skip values commonly used as array keys or internal identifiers
$srcSkipValues = [
'masked',
'original',
'remove',
'message',
'password',
'email',
'user_id',
'sensitive_data',
'audit',
'security',
'application',
'Cannot be null or empty for REPLACE type',
'Rate limiting key cannot be empty',
'Test message'
];
if (in_array($constantValue, $srcSkipValues)) {
continue;
}
}
// Create search patterns for both single and double-quoted strings
$patterns = [
"'" . str_replace("'", "\\'", $constantValue) . "'",
'"' . str_replace('"', '\\"', $constantValue) . '"',
];
foreach ($patterns as $pattern) {
$lineNumber = 0;
foreach ($lines as $line) {
$lineNumber++;
// Skip lines that already use the constant
if (str_contains($line, $className . '::' . $constantName)) {
continue;
}
// Skip lines that are comments
$trimmedLine = trim($line);
if (
str_starts_with($trimmedLine, '//')
|| str_starts_with($trimmedLine, '*')
|| str_starts_with($trimmedLine, '/*')
) {
continue;
}
if (str_contains($line, $pattern)) {
$relativePath = str_replace(__DIR__ . '/', '', $filePath);
if (!isset($findings[$relativePath])) {
$findings[$relativePath] = [];
}
$findings[$relativePath][] = [
'line' => $lineNumber,
'constant' => $className . '::' . $constantName,
'value' => $constantValue,
'content' => trim($line),
];
$totalMatches++;
}
}
}
}
}
}
echo COLOR_BOLD . str_repeat("=", 64) . "\n" . COLOR_RESET;
echo COLOR_BOLD . "Scan Results\n" . COLOR_RESET;
echo COLOR_BOLD . str_repeat("=", 64) . "\n\n" . COLOR_RESET;
if (empty($findings)) {
echo COLOR_GREEN . COLOR_BOLD . "✓ No hardcoded constant values found!\n\n" . COLOR_RESET;
echo COLOR_GREEN . "All files are using proper constant references. "
. "Great job! 🎉\n\n" . COLOR_RESET;
exit(0);
}
echo sprintf(
"%s%s⚠ Found %d potential hardcoded constant value(s) in %s file(s)\n\n%s",
COLOR_YELLOW,
COLOR_BOLD,
$totalMatches,
count($findings),
COLOR_RESET
);
// Display findings grouped by file
foreach ($findings as $file => $matches) {
echo sprintf(
"%s%s📄 %s%s (%s match%s)\n",
COLOR_BOLD,
COLOR_MAGENTA,
$file,
COLOR_RESET,
count($matches),
count($matches) > 1 ? "es" : ""
);
echo COLOR_BOLD . str_repeat("", 64) . "\n" . COLOR_RESET;
foreach ($matches as $match) {
echo sprintf("%s Line %s: %s", COLOR_CYAN, $match['line'], COLOR_RESET);
echo sprintf("Use %s%s%s", COLOR_YELLOW, $match['constant'], COLOR_RESET);
echo sprintf(" instead of %s'%s'%s\n", COLOR_RED, addslashes($match['value']), COLOR_RESET);
if ($verbose) {
echo sprintf(
"%s Context: %s%s",
COLOR_BLUE,
COLOR_RESET,
substr($match['content'], 0, 100)
);
if (strlen($match['content']) > 100) {
echo "...";
}
echo "\n";
}
}
echo "\n";
}
echo COLOR_BOLD . str_repeat("=", 64) . "\n\n" . COLOR_RESET;
echo COLOR_YELLOW . "Summary:\n" . COLOR_RESET;
echo sprintf(" • Files checked: %d\n", $filesChecked);
echo sprintf(" • Files with issues: %s\n", count($findings));
echo sprintf(" • Total matches: %d\n\n", $totalMatches);
echo sprintf(
"%sTip: Use --verbose flag to see line context for each match\n%s",
COLOR_BLUE,
COLOR_RESET
);
echo sprintf("%sExample: php check_for_constants.php --verbose\n\n%s", COLOR_BLUE, COLOR_RESET);
exit(1);

View File

@@ -7,42 +7,59 @@
"type": "library",
"scripts": {
"lint": [
"@lint:tool:ec",
"@lint:tool:psalm",
"@lint:tool:phpcs"
"@lint:tool:phpstan",
"@lint:tool:phpcs",
"@lint:tool:md"
],
"lint:fix": [
"@lint:tool:rector",
"@lint:tool:psalm:fix",
"@lint:tool:phpcbf"
"@lint:tool:phpcbf",
"@lint:tool:md:fix",
"@lint:tool:ec:fix"
],
"test": "XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-text",
"test:coverage": "XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-text --coverage-html=coverage",
"test:ci": "XDEBUG_MODE=coverage ./vendor/bin/phpunit --teamcity --coverage-clover=coverage.xml",
"lint:tool:phpcs": "./vendor/bin/phpcs src/ tests/ rector.php",
"lint:tool:phpcbf": "./vendor/bin/phpcbf src/ tests/ rector.php",
"test": "./vendor/bin/phpunit --coverage-text",
"test:coverage": "./vendor/bin/phpunit --coverage-text --coverage-html=coverage",
"test:ci": "./vendor/bin/phpunit --teamcity --coverage-clover=coverage.xml",
"lint:tool:ec": "./vendor/bin/ec *.md *.json *.yml *.yaml *.xml *.php",
"lint:tool:ec:fix": "./vendor/bin/ec *.md *.json *.yml *.yaml *.xml *.php --fix",
"lint:tool:phpcs": "./vendor/bin/phpcs src/ tests/ examples/ config/ rector.php --warning-severity=0",
"lint:tool:phpcbf": "./vendor/bin/phpcbf src/ tests/ examples/ config/ rector.php || [ $? -eq 2 ]",
"lint:tool:phpstan": "./vendor/bin/phpstan analyse --memory-limit=1G",
"lint:tool:psalm": "./vendor/bin/psalm --show-info=true",
"lint:tool:psalm:fix": "./vendor/bin/psalm --alter --issues=all",
"lint:tool:rector": "./vendor/bin/rector"
"lint:tool:psalm:fix": "./vendor/bin/psalm --alter --issues=MissingReturnType,MissingParamType,MissingClosureReturnType",
"lint:tool:rector": "./vendor/bin/rector",
"lint:tool:md:fix": "npx -y markdownlint-cli -f '**/*.md'",
"lint:tool:md": "npx -y markdownlint-cli '**/*.md'"
},
"require": {
"php": "^8.2",
"php": "^8.4",
"monolog/monolog": "^3.0",
"adbario/php-dot-notation": "^3.3"
},
"require-dev": {
"phpunit/phpunit": "^11",
"squizlabs/php_codesniffer": "^3.9",
"rector/rector": "^2.1",
"vimeo/psalm": "^6.13",
"psalm/plugin-phpunit": "^0.19.5",
"orklah/psalm-strict-equality": "^3.1",
"armin/editorconfig-cli": "^2.1",
"ergebnis/composer-normalize": "^2.47",
"guuzen/psalm-enum-plugin": "^1.1",
"ergebnis/composer-normalize": "^2.47"
"illuminate/console": "*",
"illuminate/contracts": "*",
"illuminate/http": "*",
"orklah/psalm-strict-equality": "^3.1",
"phpunit/phpunit": "^13",
"psalm/plugin-phpunit": "^0.19.5",
"rector/rector": "^2.1",
"squizlabs/php_codesniffer": "^4.0",
"vimeo/psalm": "^6.13"
},
"autoload": {
"psr-4": {
"Ivuorinen\\MonologGdprFilter\\": "src/"
}
},
"files": [
"stubs/laravel-helpers.php"
]
},
"autoload-dev": {
"psr-4": {

4995
composer.lock generated

File diff suppressed because it is too large Load Diff

128
config/gdpr.php Normal file
View File

@@ -0,0 +1,128 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Auto Registration
|--------------------------------------------------------------------------
|
| Whether to automatically register the GDPR processor with Laravel's
| logging system. If false, you'll need to manually register it.
|
*/
'auto_register' => filter_var(env('GDPR_AUTO_REGISTER', false), FILTER_VALIDATE_BOOLEAN),
/*
|--------------------------------------------------------------------------
| Logging Channels
|--------------------------------------------------------------------------
|
| Which logging channels should have GDPR processing applied.
| Only used when auto_register is true.
|
*/
'channels' => [
'single',
'daily',
'stack',
// Add other channels as needed
],
/*
|--------------------------------------------------------------------------
| GDPR Patterns
|--------------------------------------------------------------------------
|
| Regex patterns for detecting and masking sensitive data.
| Leave empty to use the default patterns, or add your own.
|
*/
'patterns' => [
// Uncomment and customize as needed:
// '/\bcustom-pattern\b/' => '***CUSTOM***',
],
/*
|--------------------------------------------------------------------------
| Field Paths
|--------------------------------------------------------------------------
|
| Dot-notation paths for field-specific masking/removal/replacement.
| More efficient than regex patterns for known field locations.
|
*/
'field_paths' => [
// Examples:
// 'user.email' => '', // Mask with regex
// 'user.ssn' => GdprProcessor::removeField(),
// 'payment.card' => GdprProcessor::replaceWith('[CARD]'),
],
/*
|--------------------------------------------------------------------------
| Custom Callbacks
|--------------------------------------------------------------------------
|
| Custom masking functions for specific field paths.
| Most flexible but slowest option.
|
*/
'custom_callbacks' => [
// Examples:
// 'user.name' => fn($value) => strtoupper($value),
// 'metadata.ip' => fn($value) => hash('sha256', $value),
],
/*
|--------------------------------------------------------------------------
| Recursion Depth Limit
|--------------------------------------------------------------------------
|
| Maximum depth for recursive processing of nested arrays.
| Prevents stack overflow on deeply nested data structures.
|
*/
'max_depth' => max(1, min(1000, (int) env('GDPR_MAX_DEPTH', 100))),
/*
|--------------------------------------------------------------------------
| Audit Logging
|--------------------------------------------------------------------------
|
| Configuration for audit logging of GDPR processing actions.
| Useful for compliance tracking and debugging.
|
*/
'audit_logging' => [
'enabled' => filter_var(env('GDPR_AUDIT_ENABLED', false), FILTER_VALIDATE_BOOLEAN),
'channel' => trim((string) env('GDPR_AUDIT_CHANNEL', 'gdpr-audit')) ?: 'gdpr-audit',
],
/*
|--------------------------------------------------------------------------
| Performance Settings
|--------------------------------------------------------------------------
|
| Settings for optimizing performance with large datasets.
|
*/
'performance' => [
'chunk_size' => max(100, min(10000, (int) env('GDPR_CHUNK_SIZE', 1000))),
'garbage_collection_threshold' => max(1000, min(100000, (int) env('GDPR_GC_THRESHOLD', 10000))),
],
/*
|--------------------------------------------------------------------------
| Input Validation Settings
|--------------------------------------------------------------------------
|
| Settings for input validation and security.
|
*/
'validation' => [
'max_pattern_length' => max(10, min(1000, (int) env('GDPR_MAX_PATTERN_LENGTH', 500))),
'max_field_path_length' => max(5, min(500, (int) env('GDPR_MAX_FIELD_PATH_LENGTH', 100))),
'allow_empty_patterns' => filter_var(env('GDPR_ALLOW_EMPTY_PATTERNS', false), FILTER_VALIDATE_BOOLEAN),
'strict_regex_validation' => filter_var(env('GDPR_STRICT_REGEX_VALIDATION', true), FILTER_VALIDATE_BOOLEAN),
],
];

293
demo/PatternTester.php Normal file
View File

@@ -0,0 +1,293 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Demo;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\Strategies\RegexMaskingStrategy;
use Ivuorinen\MonologGdprFilter\Strategies\FieldPathMaskingStrategy;
use Ivuorinen\MonologGdprFilter\Strategies\StrategyManager;
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
use Monolog\Level;
use Monolog\LogRecord;
use DateTimeImmutable;
use Throwable;
/**
* Pattern testing utility for the demo playground.
*/
final class PatternTester
{
/** @var array<array{path: string, original: mixed, masked: mixed}> */
private array $auditLog = [];
/**
* Test regex patterns against sample text.
*
* @param string $text Sample text to test
* @param array<string, string> $patterns Regex patterns to apply
* @return array{masked: string, matches: array<string, array<string>>, errors: array<string>}
*/
public function testPatterns(string $text, array $patterns): array
{
$errors = [];
$matches = [];
$masked = $text;
foreach ($patterns as $pattern => $replacement) {
// Validate pattern
if (@preg_match($pattern, '') === false) {
$errors[] = "Invalid pattern: {$pattern}";
continue;
}
// Find matches
if (preg_match_all($pattern, $text, $found)) {
$matches[$pattern] = $found[0];
}
// Apply replacement
$result = @preg_replace($pattern, $replacement, $masked);
if ($result !== null) {
$masked = $result;
}
}
return [
'masked' => $masked,
'matches' => $matches,
'errors' => $errors,
];
}
/**
* Test with the full GdprProcessor.
*
* @param string $message Log message to test
* @param array<string, mixed> $context Log context to test
* @param array<string, string> $patterns Custom patterns (or empty for defaults)
* @param array<string, string|FieldMaskConfig> $fieldPaths Field path configurations
* @return array{
* original_message: string,
* masked_message: string,
* original_context: array<string, mixed>,
* masked_context: array<string, mixed>,
* audit_log: array<array{path: string, original: mixed, masked: mixed}>,
* errors: array<string>
* }
*/
public function testProcessor(
string $message,
array $context = [],
array $patterns = [],
array $fieldPaths = []
): array {
$this->auditLog = [];
$errors = [];
try {
// Use default patterns if none provided
if (empty($patterns)) {
$patterns = DefaultPatterns::get();
}
// Create audit logger
$auditLogger = function (string $path, mixed $original, mixed $masked): void {
$this->auditLog[] = [
'path' => $path,
'original' => $original,
'masked' => $masked,
];
};
// Convert field paths to FieldMaskConfig
$configuredPaths = $this->convertFieldPathsToConfig($fieldPaths);
// Create processor
$processor = new GdprProcessor(
patterns: $patterns,
fieldPaths: $configuredPaths,
auditLogger: $auditLogger
);
// Create log record
$record = new LogRecord(
datetime: new DateTimeImmutable(),
channel: 'demo',
level: Level::Info,
message: $message,
context: $context
);
// Process
$result = $processor($record);
return [
'original_message' => $message,
'masked_message' => $result->message,
'original_context' => $context,
'masked_context' => $result->context,
'audit_log' => $this->auditLog,
'errors' => $errors,
];
} catch (Throwable $e) {
$errors[] = $e->getMessage();
return [
'original_message' => $message,
'masked_message' => $message,
'original_context' => $context,
'masked_context' => $context,
'audit_log' => [],
'errors' => $errors,
];
}
}
/**
* Test with the Strategy pattern.
*
* @param string $message Log message
* @param array<string, mixed> $context Log context
* @param array<string, string> $patterns Regex patterns
* @param array<string> $includePaths Paths to include
* @param array<string> $excludePaths Paths to exclude
* @return array<string, mixed>
*/
public function testStrategies(
string $message,
array $context = [],
array $patterns = [],
array $includePaths = [],
array $excludePaths = []
): array {
$errors = [];
try {
if (empty($patterns)) {
$patterns = DefaultPatterns::get();
}
// Create strategies
$regexStrategy = new RegexMaskingStrategy(
patterns: $patterns,
includePaths: $includePaths,
excludePaths: $excludePaths
);
// Create strategy manager
$manager = new StrategyManager([$regexStrategy]);
// Create log record
$record = new LogRecord(
datetime: new DateTimeImmutable(),
channel: 'demo',
level: Level::Info,
message: $message,
context: $context
);
// Mask message
$maskedMessage = $manager->maskValue($message, 'message', $record);
// Mask context recursively
$maskedContext = $this->maskContextWithStrategies($context, $manager, $record);
return [
'original_message' => $message,
'masked_message' => $maskedMessage,
'original_context' => $context,
'masked_context' => $maskedContext,
'strategy_stats' => $manager->getStatistics(),
'errors' => $errors,
];
} catch (Throwable $e) {
$errors[] = $e->getMessage();
return [
'original_message' => $message,
'masked_message' => $message,
'original_context' => $context,
'masked_context' => $context,
'strategy_stats' => [],
'errors' => $errors,
];
}
}
/**
* Get default patterns for display.
*
* @return array<string, string>
*/
public function getDefaultPatterns(): array
{
return DefaultPatterns::get();
}
/**
* Validate a single regex pattern.
*
* @return array{valid: bool, error: string|null}
*/
public function validatePattern(string $pattern): array
{
if ($pattern === '') {
return ['valid' => false, 'error' => 'Pattern cannot be empty'];
}
if (@preg_match($pattern, '') === false) {
$error = preg_last_error_msg();
return ['valid' => false, 'error' => $error];
}
return ['valid' => true, 'error' => null];
}
/**
* Convert field paths to configuration array.
*
* @param array<string, string|FieldMaskConfig> $fieldPaths
* @return array<string, string|FieldMaskConfig>
*/
private function convertFieldPathsToConfig(array $fieldPaths): array
{
$configuredPaths = [];
foreach ($fieldPaths as $path => $config) {
// Accept both FieldMaskConfig instances and strings
$configuredPaths[$path] = $config;
}
return $configuredPaths;
}
/**
* Recursively mask context values using strategy manager.
*
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
private function maskContextWithStrategies(
array $context,
StrategyManager $manager,
LogRecord $record,
string $prefix = ''
): array {
$result = [];
foreach ($context as $key => $value) {
$path = $prefix === '' ? $key : $prefix . '.' . $key;
if (is_array($value)) {
$result[$key] = $this->maskContextWithStrategies($value, $manager, $record, $path);
} elseif (is_string($value)) {
$result[$key] = $manager->maskValue($value, $path, $record);
} else {
$result[$key] = $value;
}
}
return $result;
}
}

270
demo/index.php Normal file
View File

@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
/**
* GDPR Pattern Tester - Interactive Demo
*
* This is a simple web interface for testing GDPR masking patterns.
* Run with: php -S localhost:8080 demo/index.php
*/
require_once __DIR__ . '/../vendor/autoload.php';
use Ivuorinen\MonologGdprFilter\Demo\PatternTester;
// Auto-load the PatternTester class
spl_autoload_register(function (string $class): void {
if (str_starts_with($class, 'Ivuorinen\\MonologGdprFilter\\Demo\\')) {
$file = __DIR__ . '/' . substr($class, strlen('Ivuorinen\\MonologGdprFilter\\Demo\\')) . '.php';
if (file_exists($file)) {
require_once $file;
}
}
});
$tester = new PatternTester();
// Handle API requests
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_SERVER['CONTENT_TYPE']) && str_contains($_SERVER['CONTENT_TYPE'], 'application/json')) {
header('Content-Type: application/json');
$input = json_decode(file_get_contents('php://input'), true);
if (!is_array($input)) {
echo json_encode(['error' => 'Invalid JSON input']);
exit;
}
$action = $input['action'] ?? 'test';
$result = match ($action) {
'test_patterns' => $tester->testPatterns(
$input['text'] ?? '',
$input['patterns'] ?? []
),
'test_processor' => $tester->testProcessor(
$input['message'] ?? '',
$input['context'] ?? [],
$input['patterns'] ?? [],
$input['field_paths'] ?? []
),
'test_strategies' => $tester->testStrategies(
$input['message'] ?? '',
$input['context'] ?? [],
$input['patterns'] ?? []
),
'validate_pattern' => $tester->validatePattern($input['pattern'] ?? ''),
'get_defaults' => ['patterns' => $tester->getDefaultPatterns()],
default => ['error' => 'Unknown action'],
};
echo json_encode($result, JSON_PRETTY_PRINT);
exit;
}
// Serve the HTML template
$templatePath = __DIR__ . '/templates/playground.html';
if (file_exists($templatePath)) {
readfile($templatePath);
} else {
// Fallback inline template
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GDPR Pattern Tester</title>
<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
h1 { color: #333; }
.container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.panel {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.full-width { grid-column: 1 / -1; }
label { display: block; margin-bottom: 5px; font-weight: 600; }
textarea, input[type="text"] {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-family: monospace;
font-size: 14px;
}
textarea { min-height: 150px; resize: vertical; }
button {
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
margin-top: 10px;
}
button:hover { background: #0056b3; }
.result {
background: #f8f9fa;
padding: 15px;
border-radius: 4px;
margin-top: 10px;
font-family: monospace;
white-space: pre-wrap;
word-break: break-all;
}
.error { background: #f8d7da; color: #721c24; }
.success { background: #d4edda; color: #155724; }
.match { background: #fff3cd; padding: 2px 4px; border-radius: 2px; }
.patterns-list {
max-height: 200px;
overflow-y: auto;
font-size: 12px;
}
.pattern-item {
padding: 5px;
border-bottom: 1px solid #eee;
}
.pattern-item code {
background: #e9ecef;
padding: 2px 4px;
border-radius: 2px;
}
</style>
</head>
<body>
<h1>GDPR Pattern Tester</h1>
<p>Test regex patterns for masking sensitive data in log messages.</p>
<div class="container">
<div class="panel">
<h2>Sample Text</h2>
<label for="sampleText">Enter text containing sensitive data:</label>
<textarea id="sampleText">User john.doe@example.com logged in from 192.168.1.100.
Credit card: 4532-1234-5678-9012
SSN: 123-45-6789
Phone: +1 (555) 123-4567</textarea>
</div>
<div class="panel">
<h2>Custom Patterns</h2>
<label for="patterns">JSON patterns (pattern => replacement):</label>
<textarea id="patterns">{
"/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}/": "[EMAIL]",
"/\\b\\d{3}-\\d{2}-\\d{4}\\b/": "***-**-****",
"/\\b\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}\\b/": "****-****-****-****"
}</textarea>
<button onclick="loadDefaults()">Load Default Patterns</button>
</div>
<div class="panel full-width">
<button onclick="testPatterns()">Test Patterns</button>
<button onclick="testProcessor()">Test Full Processor</button>
</div>
<div class="panel full-width">
<h2>Results</h2>
<div id="results" class="result">Results will appear here...</div>
</div>
<div class="panel">
<h2>Default Patterns</h2>
<div id="defaultPatterns" class="patterns-list">Loading...</div>
</div>
<div class="panel">
<h2>Audit Log</h2>
<div id="auditLog" class="result">Audit log will appear here...</div>
</div>
</div>
<script>
async function api(action, data = {}) {
const response = await fetch(window.location.href, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, ...data })
});
return response.json();
}
async function testPatterns() {
const text = document.getElementById('sampleText').value;
let patterns;
try {
patterns = JSON.parse(document.getElementById('patterns').value);
} catch (e) {
showResult({ error: 'Invalid JSON in patterns: ' + e.message });
return;
}
const result = await api('test_patterns', { text, patterns });
showResult(result);
}
async function testProcessor() {
const message = document.getElementById('sampleText').value;
let patterns;
try {
patterns = JSON.parse(document.getElementById('patterns').value);
} catch (e) {
patterns = {};
}
const result = await api('test_processor', { message, patterns });
showResult(result);
if (result.audit_log) {
document.getElementById('auditLog').textContent =
JSON.stringify(result.audit_log, null, 2);
}
}
async function loadDefaults() {
const result = await api('get_defaults');
if (result.patterns) {
document.getElementById('patterns').value =
JSON.stringify(result.patterns, null, 4);
}
}
function showResult(result) {
const el = document.getElementById('results');
if (result.error || (result.errors && result.errors.length)) {
el.className = 'result error';
} else {
el.className = 'result success';
}
el.textContent = JSON.stringify(result, null, 2);
}
// Load defaults on page load
(async function() {
const result = await api('get_defaults');
if (result.patterns) {
const container = document.getElementById('defaultPatterns');
container.innerHTML = Object.entries(result.patterns)
.map(([pattern, replacement]) =>
`<div class="pattern-item"><code>${pattern}</code> → <code>${replacement}</code></div>`
).join('');
}
})();
</script>
</body>
</html>
<?php
}

View File

@@ -0,0 +1,478 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GDPR Pattern Tester - Monolog GDPR Filter</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
header {
text-align: center;
color: white;
margin-bottom: 30px;
}
header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
}
header p {
opacity: 0.9;
font-size: 1.1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
}
.card {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
padding: 25px;
transition: transform 0.2s;
}
.card:hover {
transform: translateY(-2px);
}
.card h2 {
color: #333;
margin-bottom: 15px;
font-size: 1.3rem;
display: flex;
align-items: center;
gap: 10px;
}
.card h2::before {
content: '';
width: 4px;
height: 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 2px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #555;
font-size: 0.9rem;
}
textarea, input[type="text"] {
width: 100%;
padding: 12px;
border: 2px solid #e1e5eb;
border-radius: 8px;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 13px;
transition: border-color 0.2s, box-shadow 0.2s;
}
textarea:focus, input[type="text"]:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
textarea {
min-height: 180px;
resize: vertical;
}
.btn-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 15px;
}
button {
background: linear-gradient(135deg, #4c51bf 0%, #553c9a 100%);
color: #ffffff;
border: none;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: transform 0.2s, box-shadow 0.2s;
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(76, 81, 191, 0.4);
}
button.secondary {
background: #f8f9fa;
color: #333;
}
button.secondary:hover {
background: #e9ecef;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.result-box {
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
margin-top: 15px;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
max-height: 400px;
overflow-y: auto;
}
.result-box.error {
background: #fff5f5;
border-left: 4px solid #e53e3e;
}
.result-box.success {
background: #f0fff4;
border-left: 4px solid #38a169;
}
.patterns-list {
max-height: 250px;
overflow-y: auto;
}
.pattern-item {
padding: 10px;
border-bottom: 1px solid #f0f0f0;
font-size: 12px;
}
.pattern-item:last-child {
border-bottom: none;
}
.pattern-item code {
background: #e9ecef;
padding: 2px 6px;
border-radius: 4px;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.pattern-item .arrow {
color: #553c9a;
margin: 0 8px;
}
.tabs {
display: flex;
gap: 5px;
margin-bottom: 15px;
}
.tab {
padding: 8px 16px;
background: #f0f0f0;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.tab.active {
background: linear-gradient(135deg, #4c51bf 0%, #553c9a 100%);
color: #ffffff;
}
.highlight {
background: #fff3cd;
padding: 1px 4px;
border-radius: 3px;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-top: 10px;
}
.stat-box {
text-align: center;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
}
.stat-box .value {
font-size: 24px;
font-weight: bold;
color: #667eea;
}
.stat-box .label {
font-size: 11px;
color: #888;
margin-top: 5px;
}
footer {
text-align: center;
color: white;
margin-top: 30px;
opacity: 0.8;
}
footer a {
color: white;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>GDPR Pattern Tester</h1>
<p>Test and validate regex patterns for masking sensitive data in log messages</p>
</header>
<div class="grid">
<div class="card">
<h2>Sample Input</h2>
<label for="sampleText">Enter text containing sensitive data:</label>
<textarea id="sampleText">User john.doe@example.com logged in from 192.168.1.100.
Credit card: 4532-1234-5678-9012
SSN: 123-45-6789
Phone: +1 (555) 123-4567
Finnish SSN: 131052-308T
IBAN: FI21 1234 5600 0007 85</textarea>
</div>
<div class="card">
<h2>Custom Patterns</h2>
<label for="patterns">JSON patterns (pattern => replacement):</label>
<textarea id="patterns">{
"/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}/": "[EMAIL]",
"/\\b\\d{3}-\\d{2}-\\d{4}\\b/": "***-**-****",
"/\\b\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}\\b/": "****-****-****-****",
"/\\b\\d{6}[-+A]\\d{3}[A-Z0-9]\\b/": "******-****"
}</textarea>
<div class="btn-group">
<button class="secondary" onclick="loadDefaults()">Load Defaults</button>
<button class="secondary" onclick="clearPatterns()">Clear</button>
</div>
</div>
<div class="card" style="grid-column: 1 / -1;">
<h2>Actions</h2>
<div class="btn-group">
<button onclick="testPatterns()">Test Patterns</button>
<button onclick="testProcessor()">Test Full Processor</button>
<button onclick="testStrategies()">Test with Strategies</button>
<button class="secondary" onclick="validatePatterns()">Validate Patterns</button>
</div>
</div>
<div class="card">
<h2>Masked Output</h2>
<div id="maskedOutput" class="result-box">Masked output will appear here...</div>
</div>
<div class="card">
<h2>Pattern Matches</h2>
<div id="matchesOutput" class="result-box">Matches will appear here...</div>
</div>
<div class="card">
<h2>Default Patterns</h2>
<div id="defaultPatterns" class="patterns-list">Loading default patterns...</div>
</div>
<div class="card">
<h2>Audit Log</h2>
<div id="auditLog" class="result-box">Audit log entries will appear here...</div>
</div>
<div class="card" style="grid-column: 1 / -1;">
<h2>Full Results</h2>
<div id="fullResults" class="result-box">Complete results will appear here...</div>
</div>
</div>
<footer>
<p>
<a href="https://github.com/ivuorinen/monolog-gdpr-filter" target="_blank">
ivuorinen/monolog-gdpr-filter
</a>
&mdash; Run with: <code>php -S localhost:8080 demo/index.php</code>
</p>
</footer>
</div>
<script>
async function api(action, data = {}) {
try {
const response = await fetch(globalThis.location.href, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, ...data })
});
return await response.json();
} catch (error) {
return { error: error.message };
}
}
function getPatterns() {
try {
return JSON.parse(document.getElementById('patterns').value);
} catch (error) {
showError('Invalid JSON in patterns field: ' + error.message);
return null;
}
}
async function testPatterns() {
const text = document.getElementById('sampleText').value;
const patterns = getPatterns();
if (!patterns) {
showError('Invalid JSON in patterns field');
return;
}
const result = await api('test_patterns', { text, patterns });
if (result.error) {
showError(result.error);
return;
}
document.getElementById('maskedOutput').textContent = result.masked || '';
document.getElementById('maskedOutput').className = 'result-box success';
if (result.matches && Object.keys(result.matches).length > 0) {
document.getElementById('matchesOutput').textContent =
JSON.stringify(result.matches, null, 2);
} else {
document.getElementById('matchesOutput').textContent = 'No matches found';
}
document.getElementById('fullResults').textContent =
JSON.stringify(result, null, 2);
if (result.errors && result.errors.length > 0) {
document.getElementById('fullResults').className = 'result-box error';
} else {
document.getElementById('fullResults').className = 'result-box success';
}
}
async function testProcessor() {
const message = document.getElementById('sampleText').value;
const patterns = getPatterns() || {};
const result = await api('test_processor', { message, patterns });
if (result.error) {
showError(result.error);
return;
}
document.getElementById('maskedOutput').textContent =
result.masked_message || '';
document.getElementById('maskedOutput').className = 'result-box success';
if (result.audit_log && result.audit_log.length > 0) {
document.getElementById('auditLog').textContent =
JSON.stringify(result.audit_log, null, 2);
} else {
document.getElementById('auditLog').textContent = 'No audit entries';
}
document.getElementById('fullResults').textContent =
JSON.stringify(result, null, 2);
document.getElementById('fullResults').className = 'result-box success';
}
async function testStrategies() {
const message = document.getElementById('sampleText').value;
const patterns = getPatterns() || {};
const result = await api('test_strategies', { message, patterns });
document.getElementById('maskedOutput').textContent =
result.masked_message || '';
document.getElementById('maskedOutput').className = 'result-box success';
if (result.strategy_stats) {
document.getElementById('matchesOutput').textContent =
'Strategy Statistics:\n' + JSON.stringify(result.strategy_stats, null, 2);
}
document.getElementById('fullResults').textContent =
JSON.stringify(result, null, 2);
document.getElementById('fullResults').className = 'result-box success';
}
async function validatePatterns() {
const patterns = getPatterns();
if (!patterns) {
showError('Invalid JSON in patterns field');
return;
}
const results = [];
for (const pattern of Object.keys(patterns)) {
const result = await api('validate_pattern', { pattern });
results.push({
pattern,
valid: result.valid,
error: result.error
});
}
const valid = results.filter(r => r.valid).length;
const invalid = results.filter(r => !r.valid).length;
document.getElementById('fullResults').textContent =
`Validation Results: ${valid} valid, ${invalid} invalid\n\n` +
JSON.stringify(results, null, 2);
document.getElementById('fullResults').className =
invalid > 0 ? 'result-box error' : 'result-box success';
}
async function loadDefaults() {
const result = await api('get_defaults');
if (result.patterns) {
document.getElementById('patterns').value =
JSON.stringify(result.patterns, null, 4);
}
}
function clearPatterns() {
document.getElementById('patterns').value = '{\n \n}';
}
function showError(message) {
document.getElementById('fullResults').textContent = 'Error: ' + message;
document.getElementById('fullResults').className = 'result-box error';
}
// Load default patterns on page load
async function loadDefaultPatternsOnInit() {
try {
const result = await api('get_defaults');
if (result.patterns) {
const container = document.getElementById('defaultPatterns');
container.innerHTML = Object.entries(result.patterns)
.map(([pattern, replacement]) =>
`<div class="pattern-item">
<code>${escapeHtml(pattern)}</code>
<span class="arrow">→</span>
<code>${escapeHtml(replacement)}</code>
</div>`
).join('');
}
} catch (error) {
const container = document.getElementById('defaultPatterns');
container.textContent = 'Error loading default patterns: ' + error.message;
}
}
// Initialize on page load
loadDefaultPatternsOnInit().catch(error => {
console.error('Failed to initialize:', error);
});
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>

35
docker/Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
FROM php:8.5-cli-alpine
# Install system dependencies
RUN apk add --no-cache \
git \
unzip \
curl \
libzip-dev \
icu-dev \
&& docker-php-ext-install \
zip \
intl \
pcntl
# Install Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Install PCOV for code coverage
RUN apk add --no-cache $PHPIZE_DEPS \
&& pecl install pcov \
&& docker-php-ext-enable pcov
# Set working directory
WORKDIR /app
# Set recommended PHP settings for development and create non-root user
RUN echo "memory_limit=512M" >> /usr/local/etc/php/conf.d/docker-php-memory.ini \
&& echo "error_reporting=E_ALL" >> /usr/local/etc/php/conf.d/docker-php-errors.ini \
&& echo "display_errors=On" >> /usr/local/etc/php/conf.d/docker-php-errors.ini \
&& addgroup -g 1000 developer \
&& adduser -D -u 1000 -G developer developer
USER developer
CMD ["php", "-v"]

29
docker/docker-compose.yml Normal file
View File

@@ -0,0 +1,29 @@
version: '3.8'
services:
php:
build:
context: .
dockerfile: Dockerfile
volumes:
- ..:/app
- composer-cache:/home/developer/.composer/cache
working_dir: /app
environment:
- COMPOSER_HOME=/home/developer/.composer
stdin_open: true
tty: true
command: tail -f /dev/null
# PHP 8.3 for testing compatibility
php83:
image: php:8.5-cli-alpine
volumes:
- ..:/app
working_dir: /app
profiles:
- testing
command: php -v
volumes:
composer-cache:

315
docs/docker-development.md Normal file
View File

@@ -0,0 +1,315 @@
# Docker Development Environment
This guide explains how to set up a Docker development environment for working with the Monolog GDPR Filter library.
## Quick Start
### Using Docker Compose
```bash
# Clone the repository
git clone https://github.com/ivuorinen/monolog-gdpr-filter.git
cd monolog-gdpr-filter
# Start the development environment
docker compose up -d
# Run tests
docker compose exec php composer test
# Run linting
docker compose exec php composer lint
```
## Docker Configuration Files
### docker/Dockerfile
```dockerfile
FROM php:8.4-cli-alpine
# Install system dependencies
RUN apk add --no-cache \
git \
unzip \
curl \
libzip-dev \
icu-dev \
&& docker-php-ext-install \
zip \
intl \
pcntl
# Install Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Install Xdebug for code coverage
RUN apk add --no-cache $PHPIZE_DEPS \
&& pecl install xdebug \
&& docker-php-ext-enable xdebug
# Configure Xdebug
RUN echo "xdebug.mode=coverage,debug" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
&& echo "xdebug.client_host=host.docker.internal" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
# Set working directory
WORKDIR /app
# Set recommended PHP settings for development
RUN echo "memory_limit=512M" >> /usr/local/etc/php/conf.d/docker-php-memory.ini \
&& echo "error_reporting=E_ALL" >> /usr/local/etc/php/conf.d/docker-php-errors.ini \
&& echo "display_errors=On" >> /usr/local/etc/php/conf.d/docker-php-errors.ini
# Create non-root user
RUN addgroup -g 1000 developer \
&& adduser -D -u 1000 -G developer developer
USER developer
CMD ["php", "-v"]
```
### docker/docker-compose.yml
```yaml
version: '3.8'
services:
php:
build:
context: .
dockerfile: Dockerfile
volumes:
- ..:/app
- composer-cache:/home/developer/.composer/cache
working_dir: /app
environment:
- COMPOSER_HOME=/home/developer/.composer
- XDEBUG_MODE=coverage
stdin_open: true
tty: true
command: tail -f /dev/null
# Optional: PHP 8.5 for testing compatibility
php83:
image: php:8.5-cli-alpine
volumes:
- ..:/app
working_dir: /app
profiles:
- testing
command: php -v
volumes:
composer-cache:
```
## Running Tests
### All Tests
```bash
docker compose exec php composer test
```
### With Coverage Report
```bash
docker compose exec php composer test:coverage
```
### Specific Test File
```bash
docker compose exec php ./vendor/bin/phpunit tests/GdprProcessorTest.php
```
### Specific Test Method
```bash
docker compose exec php ./vendor/bin/phpunit --filter testEmailMasking
```
## Running Linting Tools
### All Linting
```bash
docker compose exec php composer lint
```
### Individual Tools
```bash
# PHP CodeSniffer
docker compose exec php ./vendor/bin/phpcs
# Auto-fix with PHPCBF
docker compose exec php ./vendor/bin/phpcbf
# Psalm
docker compose exec php ./vendor/bin/psalm
# PHPStan
docker compose exec php ./vendor/bin/phpstan analyse
# Rector (dry-run)
docker compose exec php ./vendor/bin/rector --dry-run
```
## Development Workflow
### Initial Setup
```bash
# Build containers
docker compose build
# Start services
docker compose up -d
# Install dependencies
docker compose exec php composer install
# Run initial checks
docker compose exec php composer lint
docker compose exec php composer test
```
### Daily Development
```bash
# Start environment
docker compose up -d
# Make changes...
# Run tests
docker compose exec php composer test
# Run linting
docker compose exec php composer lint
# Auto-fix issues
docker compose exec php composer lint:fix
```
### Testing Multiple PHP Versions
```bash
# Test with PHP 8.3
docker compose --profile testing run php83 php -v
docker compose --profile testing run php83 ./vendor/bin/phpunit
```
## Debugging
### Enable Xdebug
The Docker configuration includes Xdebug. Configure your IDE to listen on port 9003.
For VS Code, add to `.vscode/launch.json`:
```json
{
"version": "0.2.0",
"configurations": [
{
"name": "Listen for Xdebug",
"type": "php",
"request": "launch",
"port": 9003,
"pathMappings": {
"/app": "${workspaceFolder}"
}
}
]
}
```
### Interactive Shell
```bash
docker compose exec php sh
```
### View Logs
```bash
docker compose logs -f php
```
## CI/CD Integration
### GitHub Actions Example
```yaml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
php: ['8.4', '8.5']
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: intl, zip
coverage: xdebug
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run linting
run: composer lint
- name: Run tests
run: composer test:coverage
```
## Troubleshooting
### Permission Issues
If you encounter permission issues:
```bash
# Fix ownership
docker compose exec -u root php chown -R developer:developer /app
# Or run as root temporarily
docker compose exec -u root php composer install
```
### Composer Memory Limit
```bash
docker compose exec php php -d memory_limit=-1 /usr/bin/composer install
```
### Clear Caches
```bash
# Clear composer cache
docker compose exec php composer clear-cache
# Clear Psalm cache
docker compose exec php ./vendor/bin/psalm --clear-cache
# Clear PHPStan cache
docker compose exec php ./vendor/bin/phpstan clear-result-cache
```
## See Also
- [Symfony Integration](symfony-integration.md)
- [PSR-3 Decorator](psr3-decorator.md)
- [Framework Examples](framework-examples.md)

372
docs/framework-examples.md Normal file
View File

@@ -0,0 +1,372 @@
# Framework Integration Examples
This guide provides integration examples for various PHP frameworks.
## CakePHP
### Installation
```bash
composer require ivuorinen/monolog-gdpr-filter
```
### Configuration
Create a custom log engine in `src/Log/Engine/GdprFileLog.php`:
```php
<?php
declare(strict_types=1);
namespace App\Log\Engine;
use Cake\Log\Engine\FileLog;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Monolog\Level;
use Monolog\LogRecord;
use DateTimeImmutable;
class GdprFileLog extends FileLog
{
protected GdprProcessor $gdprProcessor;
public function __construct(array $config = [])
{
parent::__construct($config);
$patterns = $config['gdpr_patterns'] ?? [
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => '[email]',
'/\b\d{3}-\d{2}-\d{4}\b/' => '***-**-****',
];
$this->gdprProcessor = new GdprProcessor($patterns);
}
public function log($level, string $message, array $context = []): void
{
$record = new LogRecord(
datetime: new DateTimeImmutable(),
channel: 'app',
level: $this->convertLevel($level),
message: $message,
context: $context
);
$processed = ($this->gdprProcessor)($record);
parent::log($level, $processed->message, $processed->context);
}
private function convertLevel(mixed $level): Level
{
return match ($level) {
'emergency' => Level::Emergency,
'alert' => Level::Alert,
'critical' => Level::Critical,
'error' => Level::Error,
'warning' => Level::Warning,
'notice' => Level::Notice,
'info' => Level::Info,
'debug' => Level::Debug,
default => Level::Info,
};
}
}
```
Configure in `config/app.php`:
```php
'Log' => [
'default' => [
'className' => \App\Log\Engine\GdprFileLog::class,
'path' => LOGS,
'file' => 'debug',
'levels' => ['notice', 'info', 'debug'],
'gdpr_patterns' => [
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => '[email]',
],
],
],
```
## CodeIgniter 4
### Configuration
Create a custom logger in `app/Libraries/GdprLogger.php`:
```php
<?php
namespace App\Libraries;
use CodeIgniter\Log\Logger;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Monolog\Level;
use Monolog\LogRecord;
use DateTimeImmutable;
class GdprLogger extends Logger
{
protected GdprProcessor $gdprProcessor;
public function __construct($config, bool $introspect = true)
{
parent::__construct($config, $introspect);
$patterns = $config->gdprPatterns ?? [
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => '[email]',
];
$this->gdprProcessor = new GdprProcessor($patterns);
}
public function log($level, $message, array $context = []): bool
{
$record = new LogRecord(
datetime: new DateTimeImmutable(),
channel: 'ci4',
level: $this->mapLevel($level),
message: (string) $message,
context: $context
);
$processed = ($this->gdprProcessor)($record);
return parent::log($level, $processed->message, $processed->context);
}
private function mapLevel(mixed $level): Level
{
return match (strtolower((string) $level)) {
'emergency' => Level::Emergency,
'alert' => Level::Alert,
'critical' => Level::Critical,
'error' => Level::Error,
'warning' => Level::Warning,
'notice' => Level::Notice,
'info' => Level::Info,
'debug' => Level::Debug,
default => Level::Info,
};
}
}
```
Register in `app/Config/Services.php`:
```php
public static function logger(bool $getShared = true): \App\Libraries\GdprLogger
{
if ($getShared) {
return static::getSharedInstance('logger');
}
return new \App\Libraries\GdprLogger(new \Config\Logger());
}
```
## Laminas (formerly Zend Framework)
### Service Configuration
```php
<?php
// config/autoload/logging.global.php
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Laminas\Log\Logger;
use Laminas\Log\Writer\Stream;
use Laminas\Log\Processor\ProcessorInterface;
use Psr\Container\ContainerInterface;
return [
'service_manager' => [
'factories' => [
GdprProcessor::class => function (ContainerInterface $container) {
$config = $container->get('config')['gdpr'] ?? [];
return new GdprProcessor(
$config['patterns'] ?? [],
$config['field_paths'] ?? []
);
},
'GdprLogProcessor' => function (ContainerInterface $container) {
$gdprProcessor = $container->get(GdprProcessor::class);
return new class($gdprProcessor) implements ProcessorInterface {
public function __construct(
private readonly GdprProcessor $gdprProcessor
) {}
public function process(array $event): array
{
// Convert to LogRecord, process, convert back
$record = new \Monolog\LogRecord(
datetime: new \DateTimeImmutable(),
channel: 'laminas',
level: \Monolog\Level::Info,
message: $event['message'] ?? '',
context: $event['extra'] ?? []
);
$processed = ($this->gdprProcessor)($record);
$event['message'] = $processed->message;
$event['extra'] = $processed->context;
return $event;
}
};
},
Logger::class => function (ContainerInterface $container) {
$logger = new Logger();
$logger->addWriter(new Stream('data/logs/app.log'));
$logger->addProcessor($container->get('GdprLogProcessor'));
return $logger;
},
],
],
'gdpr' => [
'patterns' => [
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => '[email]',
'/\b\d{3}-\d{2}-\d{4}\b/' => '***-**-****',
],
'field_paths' => [
'user.password' => '***REMOVED***',
],
],
];
```
## Yii2
### Component Configuration
```php
<?php
// config/web.php or config/console.php
return [
'components' => [
'log' => [
'traceLevel' => YII_DEBUG ? 3 : 0,
'targets' => [
[
'class' => 'app\components\GdprFileTarget',
'levels' => ['error', 'warning', 'info'],
'gdprPatterns' => [
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => '[email]',
],
],
],
],
],
];
```
Create `components/GdprFileTarget.php`:
```php
<?php
namespace app\components;
use yii\log\FileTarget;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Monolog\Level;
use Monolog\LogRecord;
use DateTimeImmutable;
class GdprFileTarget extends FileTarget
{
public array $gdprPatterns = [];
private ?GdprProcessor $processor = null;
public function init(): void
{
parent::init();
if (!empty($this->gdprPatterns)) {
$this->processor = new GdprProcessor($this->gdprPatterns);
}
}
public function formatMessage($message): string
{
if ($this->processor !== null) {
[$text, $level, $category, $timestamp] = $message;
$record = new LogRecord(
datetime: new DateTimeImmutable('@' . $timestamp),
channel: $category,
level: Level::Info,
message: is_string($text) ? $text : json_encode($text) ?: '',
context: []
);
$processed = ($this->processor)($record);
$message[0] = $processed->message;
}
return parent::formatMessage($message);
}
}
```
## Generic PSR-15 Middleware
For any framework supporting PSR-15 middleware:
```php
<?php
namespace YourApp\Middleware;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
class GdprLoggingMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly GdprProcessor $gdprProcessor
) {
}
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
// Log request (with GDPR filtering applied via decorator)
$this->logger->info('Request received', [
'method' => $request->getMethod(),
'uri' => (string) $request->getUri(),
'body' => $request->getParsedBody(),
]);
$response = $handler->handle($request);
// Log response
$this->logger->info('Response sent', [
'status' => $response->getStatusCode(),
]);
return $response;
}
}
```
## See Also
- [Symfony Integration](symfony-integration.md)
- [PSR-3 Decorator](psr3-decorator.md)
- [Docker Development](docker-development.md)

View File

@@ -0,0 +1,595 @@
# Logging Platform Integrations
This guide covers integrating the Monolog GDPR Filter with popular logging platforms and services.
## Table of Contents
- [ELK Stack (Elasticsearch, Logstash, Kibana)](#elk-stack)
- [Graylog](#graylog)
- [Datadog](#datadog)
- [New Relic](#new-relic)
- [Sentry](#sentry)
- [Papertrail](#papertrail)
- [Loggly](#loggly)
- [AWS CloudWatch](#aws-cloudwatch)
- [Google Cloud Logging](#google-cloud-logging)
- [Fluentd/Fluent Bit](#fluentdfluent-bit)
## ELK Stack
### Elasticsearch with Monolog
```php
<?php
use Monolog\Logger;
use Monolog\Handler\ElasticsearchHandler;
use Monolog\Formatter\ElasticsearchFormatter;
use Elastic\Elasticsearch\ClientBuilder;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
// Create Elasticsearch client
$client = ClientBuilder::create()
->setHosts(['localhost:9200'])
->build();
// Create handler
$handler = new ElasticsearchHandler($client, [
'index' => 'app-logs',
'type' => '_doc',
]);
$handler->setFormatter(new ElasticsearchFormatter('app-logs', '_doc'));
// Create logger with GDPR processor
$logger = new Logger('app');
$logger->pushHandler($handler);
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
// Logs are now GDPR-compliant before reaching Elasticsearch
$logger->info('User login', ['email' => 'user@example.com', 'ip' => '192.168.1.1']);
```
### Logstash Integration
For Logstash, use the Gelf handler or send JSON to a TCP/UDP input:
```php
<?php
use Monolog\Logger;
use Monolog\Handler\SocketHandler;
use Monolog\Formatter\JsonFormatter;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
$handler = new SocketHandler('tcp://logstash.example.com:5000');
$handler->setFormatter(new JsonFormatter());
$logger = new Logger('app');
$logger->pushHandler($handler);
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
```
Logstash configuration:
```ruby
input {
tcp {
port => 5000
codec => json
}
}
output {
elasticsearch {
hosts => ["elasticsearch:9200"]
index => "app-logs-%{+YYYY.MM.dd}"
}
}
```
## Graylog
### GELF Handler Integration
```php
<?php
use Monolog\Logger;
use Monolog\Handler\GelfHandler;
use Gelf\Publisher;
use Gelf\Transport\UdpTransport;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
// Create GELF transport
$transport = new UdpTransport('graylog.example.com', 12201);
$publisher = new Publisher($transport);
// Create handler
$handler = new GelfHandler($publisher);
// Create logger with GDPR processor
$logger = new Logger('app');
$logger->pushHandler($handler);
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
$logger->info('Payment processed', [
'user_email' => 'customer@example.com',
'card_last_four' => '4242',
]);
```
### Graylog Stream Configuration
Create a stream to filter GDPR-sensitive logs:
1. Create an extractor to identify masked fields
2. Set up alerts for potential data leaks (unmasked patterns)
```php
<?php
// Add metadata to help Graylog categorize
$logger->pushProcessor(function ($record) {
$record['extra']['gdpr_processed'] = true;
$record['extra']['app_version'] = '1.0.0';
return $record;
});
```
## Datadog
### Datadog Handler Integration
```php
<?php
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Formatter\JsonFormatter;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
// Datadog agent reads from file or stdout
$handler = new StreamHandler('php://stdout');
$handler->setFormatter(new JsonFormatter());
$logger = new Logger('app');
$logger->pushHandler($handler);
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
// Add Datadog-specific context
$logger->pushProcessor(function ($record) {
$record['extra']['dd'] = [
'service' => 'my-php-app',
'env' => getenv('DD_ENV') ?: 'production',
'version' => '1.0.0',
];
return $record;
});
$logger->info('User action', ['user_id' => 123, 'email' => 'user@example.com']);
```
### Datadog APM Integration
```php
<?php
use DDTrace\GlobalTracer;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
// Add trace context to logs
$logger->pushProcessor(function ($record) {
$tracer = GlobalTracer::get();
$span = $tracer->getActiveSpan();
if ($span) {
$record['extra']['dd.trace_id'] = $span->getTraceId();
$record['extra']['dd.span_id'] = $span->getSpanId();
}
return $record;
});
```
## New Relic
### New Relic Handler Integration
```php
<?php
use Monolog\Logger;
use Monolog\Handler\NewRelicHandler;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
$handler = new NewRelicHandler(
level: Logger::ERROR,
appName: 'My PHP App'
);
$logger = new Logger('app');
$logger->pushHandler($handler);
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
// Errors are sent to New Relic with masked PII
$logger->error('Authentication failed', [
'email' => 'user@example.com',
'ip' => '192.168.1.1',
]);
```
### Custom Attributes
```php
<?php
// Add New Relic custom attributes
$logger->pushProcessor(function ($record) {
if (function_exists('newrelic_add_custom_parameter')) {
newrelic_add_custom_parameter('log_level', $record['level_name']);
newrelic_add_custom_parameter('channel', $record['channel']);
}
return $record;
});
```
## Sentry
### Sentry Handler Integration
```php
<?php
use Monolog\Logger;
use Sentry\Monolog\Handler;
use Sentry\State\Hub;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
\Sentry\init(['dsn' => 'https://key@sentry.io/project']);
$handler = new Handler(Hub::getCurrent());
$logger = new Logger('app');
$logger->pushHandler($handler);
// IMPORTANT: Add GDPR processor BEFORE Sentry handler processes
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
$logger->error('Payment failed', [
'user_email' => 'customer@example.com',
'card_number' => '4111111111111111',
]);
```
### Sentry Breadcrumbs
```php
<?php
use Sentry\Breadcrumb;
// Add breadcrumb processor that respects GDPR
$logger->pushProcessor(function ($record) {
\Sentry\addBreadcrumb(new Breadcrumb(
Breadcrumb::LEVEL_INFO,
Breadcrumb::TYPE_DEFAULT,
$record['channel'],
$record['message'], // Already masked by GDPR processor
$record['context'] // Already masked
));
return $record;
});
```
## Papertrail
### Papertrail Handler Integration
```php
<?php
use Monolog\Logger;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Formatter\LineFormatter;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
$handler = new SyslogUdpHandler(
'logs.papertrailapp.com',
12345 // Your Papertrail port
);
$formatter = new LineFormatter(
"%channel%.%level_name%: %message% %context% %extra%\n",
null,
true,
true
);
$handler->setFormatter($formatter);
$logger = new Logger('my-app');
$logger->pushHandler($handler);
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
```
## Loggly
### Loggly Handler Integration
```php
<?php
use Monolog\Logger;
use Monolog\Handler\LogglyHandler;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
$handler = new LogglyHandler('your-loggly-token/tag/monolog');
$logger = new Logger('app');
$logger->pushHandler($handler);
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
$logger->info('User registered', [
'email' => 'newuser@example.com',
'phone' => '+1-555-123-4567',
]);
```
## AWS CloudWatch
### CloudWatch Handler Integration
```php
<?php
use Monolog\Logger;
use Aws\CloudWatchLogs\CloudWatchLogsClient;
use Maxbanton\Cwh\Handler\CloudWatch;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
$client = new CloudWatchLogsClient([
'region' => 'us-east-1',
'version' => 'latest',
]);
$handler = new CloudWatch(
$client,
'app-log-group',
'app-log-stream',
retentionDays: 14
);
$logger = new Logger('app');
$logger->pushHandler($handler);
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
$logger->info('API request', [
'user_email' => 'api-user@example.com',
'endpoint' => '/api/v1/users',
]);
```
### CloudWatch with Laravel
```php
<?php
// config/logging.php
return [
'channels' => [
'cloudwatch' => [
'driver' => 'custom',
'via' => App\Logging\CloudWatchLoggerFactory::class,
'retention' => 14,
'group' => env('CLOUDWATCH_LOG_GROUP', 'laravel'),
'stream' => env('CLOUDWATCH_LOG_STREAM', 'app'),
],
],
];
```
```php
<?php
// app/Logging/CloudWatchLoggerFactory.php
namespace App\Logging;
use Aws\CloudWatchLogs\CloudWatchLogsClient;
use Maxbanton\Cwh\Handler\CloudWatch;
use Monolog\Logger;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
class CloudWatchLoggerFactory
{
public function __invoke(array $config): Logger
{
$client = new CloudWatchLogsClient([
'region' => config('services.aws.region'),
'version' => 'latest',
]);
$handler = new CloudWatch(
$client,
$config['group'],
$config['stream'],
$config['retention']
);
$logger = new Logger('cloudwatch');
$logger->pushHandler($handler);
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
return $logger;
}
}
```
## Google Cloud Logging
### Google Cloud Handler Integration
```php
<?php
use Monolog\Logger;
use Google\Cloud\Logging\LoggingClient;
use Google\Cloud\Logging\PsrLogger;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
$logging = new LoggingClient([
'projectId' => 'your-project-id',
]);
$psrLogger = $logging->psrLogger('app-logs');
// Wrap in Monolog for processor support
$monologLogger = new Logger('app');
$monologLogger->pushHandler(new \Monolog\Handler\PsrHandler($psrLogger));
$monologLogger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
$monologLogger->info('User action', [
'email' => 'user@example.com',
'action' => 'login',
]);
```
## Fluentd/Fluent Bit
### Fluentd Integration
```php
<?php
use Monolog\Logger;
use Monolog\Handler\SocketHandler;
use Monolog\Formatter\JsonFormatter;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
// Send to Fluentd forward input
$handler = new SocketHandler('tcp://fluentd:24224');
$handler->setFormatter(new JsonFormatter());
$logger = new Logger('app');
$logger->pushHandler($handler);
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
// Add Fluentd tag
$logger->pushProcessor(function ($record) {
$record['extra']['fluent_tag'] = 'app.logs';
return $record;
});
```
Fluentd configuration:
```ruby
<source>
@type forward
port 24224
</source>
<match app.**>
@type elasticsearch
host elasticsearch
port 9200
index_name app-logs
</match>
```
### Fluent Bit with File Tail
```php
<?php
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Formatter\JsonFormatter;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
// Write JSON logs to file for Fluent Bit to tail
$handler = new StreamHandler('/var/log/app/app.json.log');
$handler->setFormatter(new JsonFormatter());
$logger = new Logger('app');
$logger->pushHandler($handler);
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
```
Fluent Bit configuration:
```ini
[INPUT]
Name tail
Path /var/log/app/*.json.log
Parser json
[OUTPUT]
Name es
Host elasticsearch
Port 9200
Index app-logs
```
## Best Practices
### 1. Always Process Before Sending
Ensure the GDPR processor runs before logs leave your application:
```php
<?php
// Correct order: GDPR processor added AFTER handlers
$logger = new Logger('app');
$logger->pushHandler($externalHandler);
$logger->pushProcessor(new GdprProcessor($patterns)); // Runs before handlers
```
### 2. Add Compliance Metadata
```php
<?php
$logger->pushProcessor(function ($record) {
$record['extra']['gdpr'] = [
'processed' => true,
'processor_version' => '3.0.0',
'timestamp' => date('c'),
];
return $record;
});
```
### 3. Monitor for Leaks
Set up alerts in your logging platform for unmasked PII patterns:
```json
{
"query": {
"regexp": {
"message": "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"
}
}
}
```
### 4. Retention Policies
Configure retention aligned with GDPR requirements:
- Most platforms support automatic log deletion
- Set retention to 30 days for most operational logs
- Archive critical audit logs separately with longer retention

453
docs/performance-tuning.md Normal file
View File

@@ -0,0 +1,453 @@
# Performance Tuning Guide
This guide covers optimization strategies for the Monolog GDPR Filter library in high-throughput environments.
## Table of Contents
- [Benchmarking Your Setup](#benchmarking-your-setup)
- [Pattern Optimization](#pattern-optimization)
- [Memory Management](#memory-management)
- [Caching Strategies](#caching-strategies)
- [Rate Limiting](#rate-limiting)
- [Streaming Large Logs](#streaming-large-logs)
- [Production Configuration](#production-configuration)
## Benchmarking Your Setup
Before optimizing, establish baseline metrics:
```php
<?php
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
$processor = new GdprProcessor(DefaultPatterns::all());
$record = [
'message' => 'User john@example.com logged in from 192.168.1.100',
'context' => [
'user' => ['email' => 'john@example.com', 'ssn' => '123-45-6789'],
'ip' => '192.168.1.100',
],
'level' => 200,
'level_name' => 'INFO',
'channel' => 'app',
'datetime' => new DateTimeImmutable(),
'extra' => [],
];
// Benchmark
$iterations = 10000;
$start = microtime(true);
for ($i = 0; $i < $iterations; $i++) {
$processor($record);
}
$elapsed = microtime(true) - $start;
$perSecond = $iterations / $elapsed;
echo "Processed {$iterations} records in {$elapsed:.4f} seconds\n";
echo "Throughput: {$perSecond:.0f} records/second\n";
```
**Target benchmarks:**
- Simple patterns: 50,000+ records/second
- Complex patterns with nested context: 10,000+ records/second
- With audit logging: 5,000+ records/second
## Pattern Optimization
### 1. Order Patterns by Frequency
Place most frequently matched patterns first:
```php
<?php
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\MaskConstants;
// Good: Email (common) before SSN (rare)
$patterns = [
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => MaskConstants::MASK_EMAIL,
'/\b\d{3}-\d{2}-\d{4}\b/' => MaskConstants::MASK_SSN,
];
$processor = new GdprProcessor($patterns);
```
### 2. Use Specific Patterns Over Generic
Specific patterns are faster than broad ones:
```php
<?php
// Slow: Generic catch-all
$slowPattern = '/\b\d{3}[-.\s]?\d{3}[-.\s]?\d{4}\b/';
// Fast: Specific format
$fastPattern = '/\b\d{3}-\d{3}-\d{4}\b/';
```
### 3. Avoid Catastrophic Backtracking
```php
<?php
// Bad: Potential backtracking issues
$badPattern = '/.*@.*\..*/';
// Good: Bounded repetition
$goodPattern = '/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/';
```
### 4. Use Non-Capturing Groups
```php
<?php
// Slower: Capturing groups
$slowPattern = '/(foo|bar|baz)/';
// Faster: Non-capturing groups
$fastPattern = '/(?:foo|bar|baz)/';
```
### 5. Pre-validate Patterns
Use the PatternValidator to cache validation results:
```php
<?php
use Ivuorinen\MonologGdprFilter\PatternValidator;
$validator = new PatternValidator();
// Cache all patterns at startup
$validator->cacheAllPatterns($patterns);
```
## Memory Management
### 1. Limit Recursion Depth
```php
<?php
use Ivuorinen\MonologGdprFilter\GdprProcessor;
// Default is 10, reduce for memory-constrained environments
$processor = new GdprProcessor(
patterns: $patterns,
maxDepth: 5 // Limit nested array processing
);
```
### 2. Use Streaming for Large Logs
```php
<?php
use Ivuorinen\MonologGdprFilter\Streaming\StreamingProcessor;
use Ivuorinen\MonologGdprFilter\MaskingOrchestrator;
$orchestrator = new MaskingOrchestrator($patterns);
$streaming = new StreamingProcessor(
orchestrator: $orchestrator,
chunkSize: 500 // Process 500 records at a time
);
// Process large file with constant memory usage
$lineParser = fn(string $line): array => [
'message' => $line,
'context' => [],
];
foreach ($streaming->processFile('/var/log/large.log', $lineParser) as $record) {
// Handle processed record
}
```
### 3. Disable Audit Logging in High-Volume Scenarios
```php
<?php
use Ivuorinen\MonologGdprFilter\GdprProcessor;
// No audit logger = less memory allocation
$processor = new GdprProcessor(
patterns: $patterns,
auditLogger: null
);
```
## Caching Strategies
### 1. Pattern Compilation Caching
Patterns are compiled once and cached internally. Ensure you reuse processor instances:
```php
<?php
// Good: Singleton pattern
class ProcessorFactory
{
private static ?GdprProcessor $instance = null;
public static function getInstance(): GdprProcessor
{
if (self::$instance === null) {
self::$instance = new GdprProcessor(DefaultPatterns::all());
}
return self::$instance;
}
}
```
### 2. Result Caching for Repeated Values
For applications processing similar data repeatedly:
```php
<?php
class CachedGdprProcessor
{
private GdprProcessor $processor;
private array $cache = [];
private int $maxCacheSize = 1000;
public function __construct(GdprProcessor $processor)
{
$this->processor = $processor;
}
public function process(array $record): array
{
$key = md5(serialize($record['message'] . json_encode($record['context'])));
if (isset($this->cache[$key])) {
return $this->cache[$key];
}
$result = ($this->processor)($record);
if (count($this->cache) >= $this->maxCacheSize) {
array_shift($this->cache);
}
$this->cache[$key] = $result;
return $result;
}
}
```
## Rate Limiting
### 1. Rate-Limited Audit Logging
Prevent audit log flooding:
```php
<?php
use Ivuorinen\MonologGdprFilter\RateLimitedAuditLogger;
use Ivuorinen\MonologGdprFilter\RateLimiter;
$rateLimiter = new RateLimiter(
maxEvents: 100, // Max 100 events
windowSeconds: 60, // Per 60 seconds
burstLimit: 20 // Allow burst of 20
);
$auditLogger = new RateLimitedAuditLogger(
baseLogger: fn($path, $original, $masked) => error_log("Masked: $path"),
rateLimiter: $rateLimiter
);
```
### 2. Sampling for High-Volume Logging
```php
<?php
class SampledProcessor
{
private GdprProcessor $processor;
private float $sampleRate;
public function __construct(GdprProcessor $processor, float $sampleRate = 0.1)
{
$this->processor = $processor;
$this->sampleRate = $sampleRate;
}
public function __invoke(array $record): array
{
// Only process sample of records for audit
$shouldAudit = (mt_rand() / mt_getrandmax()) < $this->sampleRate;
if (!$shouldAudit) {
// Process without audit logging
return $this->processWithoutAudit($record);
}
return ($this->processor)($record);
}
private function processWithoutAudit(array $record): array
{
// Implement lightweight processing
return $record;
}
}
```
## Streaming Large Logs
### 1. Chunk Size Optimization
```php
<?php
use Ivuorinen\MonologGdprFilter\Streaming\StreamingProcessor;
// For memory-constrained environments
$smallChunks = new StreamingProcessor($orchestrator, chunkSize: 100);
// For throughput-optimized environments
$largeChunks = new StreamingProcessor($orchestrator, chunkSize: 1000);
```
### 2. Parallel Processing
For multi-core systems, process chunks in parallel:
```php
<?php
// Using pcntl_fork for parallel processing
function processInParallel(array $files, StreamingProcessor $processor): void
{
$pids = [];
foreach ($files as $file) {
$pid = pcntl_fork();
if ($pid === 0) {
// Child process
$lineParser = fn(string $line): array => ['message' => $line, 'context' => []];
foreach ($processor->processFile($file, $lineParser) as $record) {
// Process record
}
exit(0);
}
$pids[] = $pid;
}
// Wait for all children
foreach ($pids as $pid) {
pcntl_waitpid($pid, $status);
}
}
```
## Production Configuration
### 1. Minimal Pattern Set
Only include patterns you actually need:
```php
<?php
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
// Instead of DefaultPatterns::all(), use specific patterns
$patterns = array_merge(
DefaultPatterns::emails(),
DefaultPatterns::creditCards(),
// Only what you need
);
$processor = new GdprProcessor($patterns);
```
### 2. Disable Debug Features
```php
<?php
use Ivuorinen\MonologGdprFilter\Builder\GdprProcessorBuilder;
$processor = (new GdprProcessorBuilder())
->withDefaultPatterns()
->withMaxDepth(5) // Limit recursion
->withAuditLogger(null) // Disable audit logging
->build();
```
### 3. OPcache Configuration
Ensure OPcache is properly configured in `php.ini`:
```ini
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=10000
opcache.jit=1255
opcache.jit_buffer_size=128M
```
### 4. Preloading (PHP 8.0+)
Create a preload script:
```php
<?php
// preload.php
require_once __DIR__ . '/vendor/autoload.php';
// Preload core classes
$classes = [
\Ivuorinen\MonologGdprFilter\GdprProcessor::class,
\Ivuorinen\MonologGdprFilter\MaskingOrchestrator::class,
\Ivuorinen\MonologGdprFilter\DefaultPatterns::class,
\Ivuorinen\MonologGdprFilter\PatternValidator::class,
];
foreach ($classes as $class) {
class_exists($class);
}
```
Configure in `php.ini`:
```ini
opcache.preload=/path/to/preload.php
opcache.preload_user=www-data
```
## Performance Checklist
- [ ] Benchmark baseline performance
- [ ] Order patterns by frequency
- [ ] Use specific patterns over generic
- [ ] Limit recursion depth appropriately
- [ ] Use streaming for large log files
- [ ] Implement rate limiting for audit logs
- [ ] Enable OPcache with JIT
- [ ] Consider preloading in production
- [ ] Reuse processor instances (singleton)
- [ ] Disable unnecessary features in production

599
docs/plugin-development.md Normal file
View File

@@ -0,0 +1,599 @@
# Plugin Development Guide
This guide explains how to create custom plugins for the Monolog GDPR Filter library.
## Table of Contents
- [Introduction](#introduction)
- [Quick Start](#quick-start)
- [Plugin Interface](#plugin-interface)
- [Abstract Base Class](#abstract-base-class)
- [Registration](#registration)
- [Hook Execution Order](#hook-execution-order)
- [Priority System](#priority-system)
- [Configuration Contribution](#configuration-contribution)
- [Use Cases](#use-cases)
- [Best Practices](#best-practices)
## Introduction
Plugins extend the GDPR processor's functionality without modifying core code. Use plugins when you need to:
- Add custom masking patterns for your domain
- Transform messages before or after standard masking
- Enrich context with metadata
- Integrate with external systems
- Apply organization-specific compliance rules
### When to Use Plugins vs. Configuration
| Scenario | Use Plugin | Use Configuration |
| -------- | --------- | ----------------- |
| Add regex patterns | ✅ (via `getPatterns()`) | ✅ (via constructor) |
| Custom transformation logic | ✅ | ❌ |
| Conditional processing | ✅ | ❌ |
| Multiple reusable rules | ✅ | ❌ |
| Simple field masking | ❌ | ✅ |
## Quick Start
Create a minimal plugin in three steps:
### Step 1: Create the Plugin Class
```php
<?php
namespace App\Logging\Plugins;
use Ivuorinen\MonologGdprFilter\Plugins\AbstractMaskingPlugin;
class MyCompanyPlugin extends AbstractMaskingPlugin
{
public function getName(): string
{
return 'my-company-plugin';
}
public function getPatterns(): array
{
return [
'/INTERNAL-\d{6}/' => '[INTERNAL-ID]', // Internal ID format
'/EMP-[A-Z]{2}\d{4}/' => '[EMPLOYEE-ID]', // Employee IDs
];
}
}
```
### Step 2: Register the Plugin
```php
<?php
use Ivuorinen\MonologGdprFilter\Builder\GdprProcessorBuilder;
use App\Logging\Plugins\MyCompanyPlugin;
$processor = GdprProcessorBuilder::create()
->withDefaultPatterns()
->addPlugin(new MyCompanyPlugin())
->buildWithPlugins();
```
### Step 3: Use with Monolog
```php
<?php
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
$logger = new Logger('app');
$logger->pushHandler(new StreamHandler('app.log'));
$logger->pushProcessor($processor);
// Internal IDs and employee IDs are now masked
$logger->info('User INTERNAL-123456 (EMP-AB1234) logged in');
// Output: User [INTERNAL-ID] ([EMPLOYEE-ID]) logged in
```
## Plugin Interface
All plugins must implement `MaskingPluginInterface`:
```php
interface MaskingPluginInterface
{
// Identification
public function getName(): string;
// Pre-processing hooks (before standard masking)
public function preProcessContext(array $context): array;
public function preProcessMessage(string $message): string;
// Post-processing hooks (after standard masking)
public function postProcessContext(array $context): array;
public function postProcessMessage(string $message): string;
// Configuration contribution
public function getPatterns(): array;
public function getFieldPaths(): array;
// Execution order control
public function getPriority(): int;
}
```
### Method Reference
| Method | Purpose | When Called |
| ------ | ------- | ----------- |
| `getName()` | Unique identifier for debugging | On registration |
| `preProcessContext()` | Modify context before masking | Before core masking |
| `preProcessMessage()` | Modify message before masking | Before core masking |
| `postProcessContext()` | Modify context after masking | After core masking |
| `postProcessMessage()` | Modify message after masking | After core masking |
| `getPatterns()` | Provide regex patterns | During build |
| `getFieldPaths()` | Provide field paths to mask | During build |
| `getPriority()` | Control execution order | During sorting |
## Abstract Base Class
Extend `AbstractMaskingPlugin` to avoid implementing unused methods:
```php
<?php
namespace Ivuorinen\MonologGdprFilter\Plugins;
abstract class AbstractMaskingPlugin implements MaskingPluginInterface
{
public function __construct(protected readonly int $priority = 100)
{
}
// Default implementations return input unchanged
public function preProcessContext(array $context): array { return $context; }
public function postProcessContext(array $context): array { return $context; }
public function preProcessMessage(string $message): string { return $message; }
public function postProcessMessage(string $message): string { return $message; }
public function getPatterns(): array { return []; }
public function getFieldPaths(): array { return []; }
public function getPriority(): int { return $this->priority; }
}
```
### Benefits
- Override only the methods you need
- Default priority of 100 (customizable via constructor)
- All hooks pass data through unchanged by default
## Registration
Register plugins using `GdprProcessorBuilder`:
```php
<?php
use Ivuorinen\MonologGdprFilter\Builder\GdprProcessorBuilder;
// Single plugin
$processor = GdprProcessorBuilder::create()
->addPlugin($plugin)
->buildWithPlugins();
// Multiple plugins
$processor = GdprProcessorBuilder::create()
->addPlugins([$plugin1, $plugin2, $plugin3])
->buildWithPlugins();
// With other configuration
$processor = GdprProcessorBuilder::create()
->withDefaultPatterns()
->addPattern('/custom/', '[MASKED]')
->addFieldPath('secret', FieldMaskConfig::remove())
->addPlugin($plugin)
->withAuditLogger($auditLogger)
->buildWithPlugins();
```
### Return Types
```php
// No plugins: returns GdprProcessor (no wrapper overhead)
$processor = GdprProcessorBuilder::create()
->withDefaultPatterns()
->buildWithPlugins(); // GdprProcessor
// With plugins: returns PluginAwareProcessor (wraps GdprProcessor)
$processor = GdprProcessorBuilder::create()
->addPlugin($plugin)
->buildWithPlugins(); // PluginAwareProcessor
```
## Hook Execution Order
Understanding execution order is critical for plugins that interact:
```text
1. preProcessMessage() - Plugins in priority order (10, 20, 30...)
2. preProcessContext() - Plugins in priority order (10, 20, 30...)
3. [Core GdprProcessor masking]
4. postProcessMessage() - Plugins in REVERSE order (30, 20, 10...)
5. postProcessContext() - Plugins in REVERSE order (30, 20, 10...)
```
### Why Reverse Order for Post-Processing?
Post-processing runs in reverse to properly "unwrap" transformations:
```php
// Plugin A (priority 10) wraps: "data" -> "[A:data:A]"
// Plugin B (priority 20) wraps: "[A:data:A]" -> "[B:[A:data:A]:B]"
// Post-processing reverse order ensures proper unwrapping:
// Plugin B runs first: "[B:[A:masked:A]:B]" -> "[A:masked:A]"
// Plugin A runs second: "[A:masked:A]" -> "masked"
```
## Priority System
Lower numbers execute earlier in pre-processing:
```php
class HighPriorityPlugin extends AbstractMaskingPlugin
{
public function __construct()
{
parent::__construct(priority: 10); // Runs early
}
}
class NormalPriorityPlugin extends AbstractMaskingPlugin
{
// Default priority: 100
}
class LowPriorityPlugin extends AbstractMaskingPlugin
{
public function __construct()
{
parent::__construct(priority: 200); // Runs late
}
}
```
### Recommended Priority Ranges
| Range | Use Case | Example |
| ----- | -------- | ------- |
| 1-50 | Security/validation | Input sanitization |
| 50-100 | Standard processing | Pattern masking |
| 100-150 | Business logic | Domain-specific rules |
| 150-200 | Enrichment | Adding metadata |
| 200+ | Cleanup/finalization | Removing temp fields |
## Configuration Contribution
Plugins can contribute patterns and field paths that are merged into the processor:
### Adding Patterns
```php
public function getPatterns(): array
{
return [
'/ACME-\d{8}/' => '[ACME-ORDER]',
'/INV-[A-Z]{2}-\d+/' => '[INVOICE]',
];
}
```
### Adding Field Paths
```php
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
public function getFieldPaths(): array
{
return [
// Static replacement
'api_key' => FieldMaskConfig::replace('[API_KEY]'),
// Remove field entirely
'internal.debug' => FieldMaskConfig::remove(),
// Apply regex to field value
'user.notes' => FieldMaskConfig::regexMask('/\d{3}-\d{2}-\d{4}/', '[SSN]'),
// Use processor's global patterns
'user.bio' => FieldMaskConfig::useProcessorPatterns(),
];
}
```
## Use Cases
### Use Case 1: Message Transformation
Transform messages before masking:
```php
class NormalizePlugin extends AbstractMaskingPlugin
{
public function getName(): string
{
return 'normalize-plugin';
}
public function preProcessMessage(string $message): string
{
// Normalize whitespace before masking
return preg_replace('/\s+/', ' ', trim($message));
}
}
```
### Use Case 2: Domain-Specific Patterns
Add patterns for your organization:
```php
class HealthcarePlugin extends AbstractMaskingPlugin
{
public function getName(): string
{
return 'healthcare-plugin';
}
public function getPatterns(): array
{
return [
// Medical Record Number
'/MRN-\d{10}/' => '[MRN]',
// National Provider Identifier
'/NPI-\d{10}/' => '[NPI]',
// DEA Number
'/DEA-[A-Z]{2}\d{7}/' => '[DEA]',
];
}
public function getFieldPaths(): array
{
return [
'patient.diagnosis' => FieldMaskConfig::replace('[PHI]'),
'patient.medications' => FieldMaskConfig::remove(),
];
}
}
```
### Use Case 3: Context Enrichment
Add metadata to context:
```php
class AuditPlugin extends AbstractMaskingPlugin
{
public function getName(): string
{
return 'audit-plugin';
}
public function __construct(private readonly string $environment)
{
parent::__construct(priority: 150); // Run late
}
public function postProcessContext(array $context): array
{
$context['_audit'] = [
'processed_at' => date('c'),
'environment' => $this->environment,
'plugin_version' => '1.0.0',
];
return $context;
}
}
```
### Use Case 4: Conditional Masking
Apply masking based on conditions:
```php
class EnvironmentAwarePlugin extends AbstractMaskingPlugin
{
public function getName(): string
{
return 'environment-aware-plugin';
}
public function preProcessContext(array $context): array
{
// Only mask in production
if (getenv('APP_ENV') !== 'production') {
return $context;
}
// Add extra masking for production
if (isset($context['debug_info'])) {
$context['debug_info'] = '[REDACTED IN PRODUCTION]';
}
return $context;
}
}
```
### Use Case 5: External Integration
Integrate with external services:
```php
class CompliancePlugin extends AbstractMaskingPlugin
{
public function getName(): string
{
return 'compliance-plugin';
}
public function __construct(
private readonly ComplianceService $service
) {
parent::__construct(priority: 50);
}
public function postProcessContext(array $context): array
{
// Log to compliance system
$this->service->recordMaskingEvent(
fields: array_keys($context),
timestamp: new \DateTimeImmutable()
);
return $context;
}
}
```
## Best Practices
### 1. Keep Plugins Focused
Each plugin should have a single responsibility:
```php
// Good: Single purpose
class EmailPatternPlugin extends AbstractMaskingPlugin { /* ... */ }
class PhonePatternPlugin extends AbstractMaskingPlugin { /* ... */ }
// Avoid: Multiple unrelated responsibilities
class EverythingPlugin extends AbstractMaskingPlugin { /* ... */ }
```
### 2. Use Descriptive Names
Plugin names should be unique and descriptive:
```php
// Good
public function getName(): string
{
return 'acme-healthcare-hipaa-v2';
}
// Avoid
public function getName(): string
{
return 'plugin1';
}
```
### 3. Handle Errors Gracefully
Plugins should not throw exceptions that break logging:
```php
public function preProcessContext(array $context): array
{
try {
// Risky operation
$context['processed'] = $this->riskyTransform($context);
} catch (\Throwable $e) {
// Log error but don't break logging
error_log("Plugin error: " . $e->getMessage());
}
return $context; // Always return context
}
```
### 4. Document Your Patterns
Add comments explaining pattern purpose:
```php
public function getPatterns(): array
{
return [
// ACME internal order numbers: ACME-YYYYMMDD-NNNN
'/ACME-\d{8}-\d{4}/' => '[ORDER-ID]',
// Employee badges: EMP followed by 6 digits
'/EMP\d{6}/' => '[EMPLOYEE]',
];
}
```
### 5. Test Your Plugins
Create comprehensive tests:
```php
class MyPluginTest extends TestCase
{
public function testPatternMasking(): void
{
$plugin = new MyPlugin();
$patterns = $plugin->getPatterns();
// Test each pattern
foreach ($patterns as $pattern => $replacement) {
$this->assertMatchesRegularExpression($pattern, 'INTERNAL-123456');
}
}
public function testPreProcessing(): void
{
$plugin = new MyPlugin();
$context = ['sensitive' => 'value'];
$result = $plugin->preProcessContext($context);
$this->assertArrayHasKey('sensitive', $result);
}
}
```
### 6. Consider Performance
Avoid expensive operations in hooks that run for every log entry:
```php
// Good: Simple operations
public function preProcessMessage(string $message): string
{
return trim($message);
}
// Avoid: Heavy operations for every log
public function preProcessMessage(string $message): string
{
return $this->httpClient->validateMessage($message); // Slow!
}
```
### 7. Use Priority Thoughtfully
Consider how your plugin interacts with others:
```php
// Security validation should run early
class SecurityPlugin extends AbstractMaskingPlugin
{
public function __construct()
{
parent::__construct(priority: 10);
}
}
// Metadata enrichment should run late
class MetadataPlugin extends AbstractMaskingPlugin
{
public function __construct()
{
parent::__construct(priority: 180);
}
}
```

334
docs/psr3-decorator.md Normal file
View File

@@ -0,0 +1,334 @@
# PSR-3 Logger Decorator Guide
This guide explains how to wrap any PSR-3 compatible logger with GDPR masking capabilities.
## Overview
The PSR-3 decorator pattern allows you to add GDPR filtering to any logger that implements `Psr\Log\LoggerInterface`, making the library compatible with virtually any PHP logging framework.
## Basic Usage
### Creating a PSR-3 Wrapper
Here's a simple decorator that wraps any PSR-3 logger:
```php
<?php
namespace YourApp\Logging;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use Monolog\Level;
use Monolog\LogRecord;
use DateTimeImmutable;
use Stringable;
class GdprLoggerDecorator implements LoggerInterface
{
public function __construct(
private readonly LoggerInterface $innerLogger,
private readonly GdprProcessor $gdprProcessor
) {
}
public function emergency(string|Stringable $message, array $context = []): void
{
$this->log(LogLevel::EMERGENCY, $message, $context);
}
public function alert(string|Stringable $message, array $context = []): void
{
$this->log(LogLevel::ALERT, $message, $context);
}
public function critical(string|Stringable $message, array $context = []): void
{
$this->log(LogLevel::CRITICAL, $message, $context);
}
public function error(string|Stringable $message, array $context = []): void
{
$this->log(LogLevel::ERROR, $message, $context);
}
public function warning(string|Stringable $message, array $context = []): void
{
$this->log(LogLevel::WARNING, $message, $context);
}
public function notice(string|Stringable $message, array $context = []): void
{
$this->log(LogLevel::NOTICE, $message, $context);
}
public function info(string|Stringable $message, array $context = []): void
{
$this->log(LogLevel::INFO, $message, $context);
}
public function debug(string|Stringable $message, array $context = []): void
{
$this->log(LogLevel::DEBUG, $message, $context);
}
public function log($level, string|Stringable $message, array $context = []): void
{
// Convert PSR-3 level to Monolog level
$monologLevel = $this->convertLevel($level);
// Create a Monolog LogRecord for processing
$record = new LogRecord(
datetime: new DateTimeImmutable(),
channel: 'app',
level: $monologLevel,
message: (string) $message,
context: $context
);
// Apply GDPR processing
$processedRecord = ($this->gdprProcessor)($record);
// Pass to inner logger
$this->innerLogger->log($level, $processedRecord->message, $processedRecord->context);
}
private function convertLevel(mixed $level): Level
{
return match ($level) {
LogLevel::EMERGENCY => Level::Emergency,
LogLevel::ALERT => Level::Alert,
LogLevel::CRITICAL => Level::Critical,
LogLevel::ERROR => Level::Error,
LogLevel::WARNING => Level::Warning,
LogLevel::NOTICE => Level::Notice,
LogLevel::INFO => Level::Info,
LogLevel::DEBUG => Level::Debug,
default => Level::Info,
};
}
}
```
## Usage Examples
### With Any PSR-3 Logger
```php
<?php
use YourApp\Logging\GdprLoggerDecorator;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
// Your existing PSR-3 logger (could be Monolog, any other, etc.)
$existingLogger = new Logger('app');
$existingLogger->pushHandler(new StreamHandler('php://stdout'));
// Create GDPR processor
$gdprProcessor = new GdprProcessor([
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => '[email]',
'/\b\d{3}-\d{2}-\d{4}\b/' => '***-**-****',
]);
// Wrap with GDPR decorator
$logger = new GdprLoggerDecorator($existingLogger, $gdprProcessor);
// Use as normal
$logger->info('User john@example.com logged in with SSN 123-45-6789');
// Output: User [email] logged in with SSN ***-**-****
```
### With Dependency Injection
```php
<?php
use YourApp\Logging\GdprLoggerDecorator;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Psr\Log\LoggerInterface;
class UserService
{
public function __construct(
private readonly LoggerInterface $logger
) {
}
public function createUser(string $email, string $ssn): void
{
// Log will be automatically GDPR-filtered
$this->logger->info("Creating user: {email}, SSN: {ssn}", [
'email' => $email,
'ssn' => $ssn,
]);
}
}
// Container configuration (pseudo-code)
$container->register(GdprProcessor::class, function () {
return new GdprProcessor([
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => '[email]',
]);
});
$container->register(LoggerInterface::class, function ($container) {
return new GdprLoggerDecorator(
$container->get('original_logger'),
$container->get(GdprProcessor::class)
);
});
```
## Enhanced Decorator with Channel Support
```php
<?php
namespace YourApp\Logging;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use Monolog\Level;
use Monolog\LogRecord;
use DateTimeImmutable;
use Stringable;
class GdprLoggerDecorator implements LoggerInterface
{
public function __construct(
private readonly LoggerInterface $innerLogger,
private readonly GdprProcessor $gdprProcessor,
private readonly string $channel = 'app'
) {
}
/**
* Create a new instance with a different channel.
*/
public function withChannel(string $channel): self
{
return new self($this->innerLogger, $this->gdprProcessor, $channel);
}
public function log($level, string|Stringable $message, array $context = []): void
{
$record = new LogRecord(
datetime: new DateTimeImmutable(),
channel: $this->channel,
level: $this->convertLevel($level),
message: (string) $message,
context: $context
);
$processedRecord = ($this->gdprProcessor)($record);
$this->innerLogger->log($level, $processedRecord->message, $processedRecord->context);
}
// ... other methods remain the same
}
```
## Using with Popular Frameworks
### Laravel
```php
<?php
// app/Providers/LoggingServiceProvider.php
namespace App\Providers;
use App\Logging\GdprLoggerDecorator;
use Illuminate\Support\ServiceProvider;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Psr\Log\LoggerInterface;
class LoggingServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->extend(LoggerInterface::class, function ($logger) {
$processor = new GdprProcessor(
config('gdpr.patterns', [])
);
return new GdprLoggerDecorator($logger, $processor);
});
}
}
```
### Slim Framework
```php
<?php
// config/container.php
use DI\Container;
use YourApp\Logging\GdprLoggerDecorator;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Psr\Log\LoggerInterface;
return [
LoggerInterface::class => function (Container $c) {
$baseLogger = new Logger('app');
$baseLogger->pushHandler(new StreamHandler('logs/app.log'));
$processor = new GdprProcessor([
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => '[email]',
]);
return new GdprLoggerDecorator($baseLogger, $processor);
},
];
```
## Testing Your Decorator
```php
<?php
namespace Tests\Logging;
use PHPUnit\Framework\TestCase;
use YourApp\Logging\GdprLoggerDecorator;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
class GdprLoggerDecoratorTest extends TestCase
{
public function testEmailIsMasked(): void
{
$logs = [];
$mockLogger = $this->createMock(LoggerInterface::class);
$mockLogger->method('log')
->willReturnCallback(function ($level, $message, $context) use (&$logs) {
$logs[] = ['level' => $level, 'message' => $message, 'context' => $context];
});
$processor = new GdprProcessor([
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => '[email]',
]);
$decorator = new GdprLoggerDecorator($mockLogger, $processor);
$decorator->info('Contact: john@example.com');
$this->assertCount(1, $logs);
$this->assertStringContainsString('[email]', $logs[0]['message']);
$this->assertStringNotContainsString('john@example.com', $logs[0]['message']);
}
}
```
## See Also
- [Symfony Integration](symfony-integration.md)
- [Framework Examples](framework-examples.md)

264
docs/symfony-integration.md Normal file
View File

@@ -0,0 +1,264 @@
# Symfony Integration Guide
This guide explains how to integrate the Monolog GDPR Filter with Symfony applications.
## Installation
```bash
composer require ivuorinen/monolog-gdpr-filter
```
## Basic Service Configuration
Add the GDPR processor as a service in `config/services.yaml`:
```yaml
services:
App\Logging\GdprProcessor:
class: Ivuorinen\MonologGdprFilter\GdprProcessor
arguments:
$patterns: '%gdpr.patterns%'
$fieldPaths: '%gdpr.field_paths%'
$customCallbacks: []
$auditLogger: null
$maxDepth: 100
$dataTypeMasks: []
$conditionalRules: []
```
## Parameters Configuration
Define GDPR patterns in `config/services.yaml` or a dedicated parameters file:
```yaml
parameters:
gdpr.patterns:
'/\b\d{3}-\d{2}-\d{4}\b/': '***-**-****' # US SSN
'/\b[A-Z]{2}\d{2}[A-Z0-9]{4}\d{7}([A-Z0-9]?){0,16}\b/': '****' # IBAN
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/': '[email]' # Email
'/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/': '****-****-****-****' # Credit Card
gdpr.field_paths:
'user.password': '***REMOVED***'
'user.ssn': '***-**-****'
'payment.card_number': '****-****-****-****'
```
## Monolog Handler Configuration
Configure Monolog to use the GDPR processor in `config/packages/monolog.yaml`:
```yaml
monolog:
handlers:
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!event"]
formatter: monolog.formatter.json
processor: ['@App\Logging\GdprProcessor']
# For production with file rotation
production:
type: rotating_file
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: info
max_files: 14
processor: ['@App\Logging\GdprProcessor']
```
## Environment-Specific Configuration
Create environment-specific configurations:
### config/packages/dev/monolog.yaml
```yaml
monolog:
handlers:
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
# In dev, you might want less aggressive masking
```
### config/packages/prod/monolog.yaml
```yaml
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
buffer_size: 50
nested:
type: rotating_file
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: info
max_files: 14
processor: ['@App\Logging\GdprProcessor']
```
## Advanced Configuration with Audit Logging
Enable audit logging for compliance tracking:
```yaml
services:
App\Logging\AuditLogger:
class: Ivuorinen\MonologGdprFilter\RateLimitedAuditLogger
arguments:
$auditLogger: '@App\Logging\AuditCallback'
$maxRequestsPerMinute: 100
$windowSeconds: 60
App\Logging\AuditCallback:
class: Closure
factory: ['App\Logging\AuditCallbackFactory', 'create']
arguments:
$logger: '@monolog.logger.audit'
App\Logging\GdprProcessor:
class: Ivuorinen\MonologGdprFilter\GdprProcessor
arguments:
$patterns: '%gdpr.patterns%'
$fieldPaths: '%gdpr.field_paths%'
$auditLogger: '@App\Logging\AuditLogger'
```
Create the factory class:
```php
<?php
// src/Logging/AuditCallbackFactory.php
namespace App\Logging;
use Psr\Log\LoggerInterface;
class AuditCallbackFactory
{
public static function create(LoggerInterface $logger): callable
{
return function (string $path, mixed $original, mixed $masked) use ($logger): void {
$logger->info('GDPR masking applied', [
'path' => $path,
'original_type' => gettype($original),
'masked_preview' => substr((string) $masked, 0, 20) . '...',
]);
};
}
}
```
## Conditional Masking by Environment
Apply different masking rules based on log level or channel:
```yaml
services:
App\Logging\ConditionalRuleFactory:
class: App\Logging\ConditionalRuleFactory
App\Logging\GdprProcessor:
class: Ivuorinen\MonologGdprFilter\GdprProcessor
arguments:
$conditionalRules:
error_only: '@=service("App\\Logging\\ConditionalRuleFactory").createErrorOnlyRule()'
```
```php
<?php
// src/Logging/ConditionalRuleFactory.php
namespace App\Logging;
use Monolog\Level;
use Monolog\LogRecord;
class ConditionalRuleFactory
{
public function createErrorOnlyRule(): callable
{
return fn(LogRecord $record): bool =>
$record->level->value >= Level::Error->value;
}
public function createChannelRule(array $channels): callable
{
return fn(LogRecord $record): bool =>
in_array($record->channel, $channels, true);
}
}
```
## Testing in Symfony
Create a test to verify GDPR filtering works:
```php
<?php
// tests/Logging/GdprProcessorTest.php
namespace App\Tests\Logging;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Monolog\Level;
use Monolog\LogRecord;
use PHPUnit\Framework\TestCase;
use DateTimeImmutable;
class GdprProcessorTest extends TestCase
{
public function testEmailMasking(): void
{
$processor = new GdprProcessor([
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => '[email]',
]);
$record = new LogRecord(
datetime: new DateTimeImmutable(),
channel: 'test',
level: Level::Info,
message: 'User logged in: user@example.com',
context: []
);
$result = $processor($record);
$this->assertStringContainsString('[email]', $result->message);
$this->assertStringNotContainsString('user@example.com', $result->message);
}
}
```
## Troubleshooting
### Patterns Not Matching
1. Verify regex patterns are valid: `preg_match('/your-pattern/', 'test-string')`
2. Check pattern escaping in YAML (may need quotes)
3. Enable debug mode to see which patterns are applied
### Performance Issues
1. Use the rate-limited audit logger
2. Consider caching pattern validation results
3. Profile with Symfony profiler
### Memory Issues
1. Set appropriate `maxDepth` to prevent deep recursion
2. Monitor rate limiter statistics
3. Use cleanup intervals for long-running processes
## See Also
- [PSR-3 Decorator Guide](psr3-decorator.md)
- [Framework Examples](framework-examples.md)
- [Docker Development](docker-development.md)

530
docs/troubleshooting.md Normal file
View File

@@ -0,0 +1,530 @@
# Troubleshooting Guide
This guide helps diagnose and resolve common issues with the Monolog GDPR Filter library.
## Table of Contents
- [Installation Issues](#installation-issues)
- [Pattern Matching Problems](#pattern-matching-problems)
- [Performance Issues](#performance-issues)
- [Memory Problems](#memory-problems)
- [Integration Issues](#integration-issues)
- [Audit Logging Issues](#audit-logging-issues)
- [Error Messages Reference](#error-messages-reference)
## Installation Issues
### Composer Installation Fails
**Symptom:** `composer require` fails with dependency conflicts.
**Solution:**
```bash
# Check PHP version
php -v # Must be 8.4 or higher
# Clear Composer cache
composer clear-cache
# Update Composer
composer self-update
# Try again with verbose output
composer require ivuorinen/monolog-gdpr-filter -vvv
```
### Class Not Found Errors
**Symptom:** `Class 'Ivuorinen\MonologGdprFilter\GdprProcessor' not found`
**Solutions:**
1. Regenerate autoloader:
```bash
composer dump-autoload
```
2. Verify installation:
```bash
composer show ivuorinen/monolog-gdpr-filter
```
3. Check namespace in your code:
```php
<?php
// Correct
use Ivuorinen\MonologGdprFilter\GdprProcessor;
// Wrong
use MonologGdprFilter\GdprProcessor;
```
## Pattern Matching Problems
### Pattern Not Matching Expected Data
**Symptom:** Sensitive data is not being masked.
**Diagnostic steps:**
```php
<?php
use Ivuorinen\MonologGdprFilter\PatternValidator;
$validator = new PatternValidator();
$pattern = '/your-pattern-here/';
// Test 1: Validate pattern syntax
$result = $validator->validate($pattern);
if (!$result['valid']) {
echo "Invalid pattern: " . $result['error'] . "\n";
}
// Test 2: Test pattern directly
$testData = 'your test data with sensitive@email.com';
if (preg_match($pattern, $testData, $matches)) {
echo "Pattern matches: " . print_r($matches, true);
} else {
echo "Pattern does not match\n";
}
// Test 3: Test with processor
$processor = new GdprProcessor([$pattern => '[MASKED]']);
$record = [
'message' => $testData,
'context' => [],
'level' => 200,
'level_name' => 'INFO',
'channel' => 'app',
'datetime' => new DateTimeImmutable(),
'extra' => [],
];
$result = $processor($record);
echo "Result: " . $result['message'] . "\n";
```
### Pattern Matches Too Much
**Symptom:** Non-sensitive data is being masked.
**Solutions:**
1. Add word boundaries:
```php
<?php
// Too broad
$pattern = '/\d{4}/'; // Matches any 4 digits
// Better - with boundaries
$pattern = '/\b\d{4}\b/'; // Matches standalone 4-digit numbers
```
2. Use more specific patterns:
```php
<?php
// Too broad for credit cards
$pattern = '/\d{16}/';
// Better - credit card format
$pattern = '/\b(?:\d{4}[-\s]?){3}\d{4}\b/';
```
3. Add negative lookahead/lookbehind:
```php
<?php
// Avoid matching dates that look like years
$pattern = '/(?<!\d{2}\/)\b\d{4}\b(?!\/\d{2})/';
```
### Special Characters in Patterns
**Symptom:** Pattern with special characters fails.
**Solution:** Escape special regex characters:
```php
<?php
// Wrong - unescaped special chars
$pattern = '/user.name@domain.com/';
// Correct - escaped dots
$pattern = '/user\.name@domain\.com/';
// Using preg_quote for dynamic patterns
$email = 'user.name@domain.com';
$pattern = '/' . preg_quote($email, '/') . '/';
```
## Performance Issues
### Slow Processing
**Symptom:** Log processing is slower than expected.
**Diagnostic:**
```php
<?php
$start = microtime(true);
for ($i = 0; $i < 1000; $i++) {
$processor($record);
}
$elapsed = microtime(true) - $start;
echo "1000 records: {$elapsed}s\n";
```
**Solutions:**
1. Reduce pattern count:
```php
<?php
// Only include patterns you need
$patterns = DefaultPatterns::emails() + DefaultPatterns::creditCards();
```
2. Simplify complex patterns:
```php
<?php
// Slow: Complex pattern with many alternatives
$slow = '/(january|february|march|april|may|june|july|august|september|october|november|december)/i';
// Faster: Simpler pattern
$fast = '/\b[A-Z][a-z]{2,8}\b/';
```
3. Limit recursion depth:
```php
<?php
$processor = new GdprProcessor($patterns, [], [], null, 5); // Max depth 5
```
See [Performance Tuning Guide](performance-tuning.md) for detailed optimization strategies.
### High CPU Usage
**Symptom:** Processing causes CPU spikes.
**Solutions:**
1. Check for catastrophic backtracking:
```php
<?php
// Problematic pattern
$bad = '/.*@.*\..*/'; // Can cause backtracking
// Fixed pattern
$good = '/[^@]+@[^.]+\.[a-z]+/i';
```
2. Add pattern timeout (PHP 7.3+):
```php
<?php
// Set PCRE backtrack limit
ini_set('pcre.backtrack_limit', '100000');
```
## Memory Problems
### Out of Memory Errors
**Symptom:** `Allowed memory size exhausted`
**Solutions:**
1. Use streaming for large files:
```php
<?php
use Ivuorinen\MonologGdprFilter\Streaming\StreamingProcessor;
use Ivuorinen\MonologGdprFilter\MaskingOrchestrator;
$orchestrator = new MaskingOrchestrator($patterns);
$streaming = new StreamingProcessor($orchestrator, chunkSize: 100);
// Process file without loading entirely into memory
$lineParser = fn(string $line): array => ['message' => $line, 'context' => []];
foreach ($streaming->processFile($largefile, $lineParser) as $record) {
// Process one record at a time
}
```
2. Reduce recursion depth:
```php
<?php
$processor = new GdprProcessor($patterns, [], [], null, 3);
```
3. Disable audit logging:
```php
<?php
$processor = new GdprProcessor($patterns, [], [], null); // No audit logger
```
### Memory Leaks
**Symptom:** Memory usage grows over time in long-running processes.
**Solutions:**
1. Clear caches periodically:
```php
<?php
// In long-running workers
if ($processedCount % 10000 === 0) {
gc_collect_cycles();
}
```
2. Use fresh processor instances for batch jobs:
```php
<?php
foreach ($batches as $batch) {
$processor = new GdprProcessor($patterns); // Fresh instance
foreach ($batch as $record) {
$processor($record);
}
unset($processor); // Release memory
}
```
## Integration Issues
### Laravel Integration
**Symptom:** Processor not being applied to logs.
**Solutions:**
1. Verify service provider registration:
```php
<?php
// config/app.php
'providers' => [
Ivuorinen\MonologGdprFilter\Laravel\GdprServiceProvider::class,
],
```
2. Check logging configuration:
```php
<?php
// config/logging.php
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['gdpr'],
],
'gdpr' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'tap' => [GdprLogTap::class],
],
],
```
3. Clear config cache:
```bash
php artisan config:clear
php artisan cache:clear
```
### Monolog Integration
**Symptom:** Processor not working with Monolog logger.
**Solution:** Ensure processor is pushed to logger:
```php
<?php
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
$logger = new Logger('app');
$logger->pushHandler(new StreamHandler('app.log'));
$logger->pushProcessor(new GdprProcessor(DefaultPatterns::all()));
// Test it
$logger->info('User email: test@example.com');
```
### Symfony Integration
See [Symfony Integration Guide](symfony-integration.md) for detailed setup.
## Audit Logging Issues
### Audit Logger Not Receiving Events
**Symptom:** Audit callback never called.
**Solutions:**
1. Verify audit logger is set:
```php
<?php
$auditLogs = [];
$auditLogger = function (string $path, mixed $original, mixed $masked) use (&$auditLogs): void {
$auditLogs[] = compact('path', 'original', 'masked');
};
$processor = new GdprProcessor(
patterns: $patterns,
auditLogger: $auditLogger
);
```
2. Verify masking is actually occurring:
```php
<?php
// Audit is only called when data is actually masked
$record = ['message' => 'No sensitive data here', 'context' => []];
// This won't trigger audit because nothing is masked
```
### Rate-Limited Audit Missing Events
**Symptom:** Some audit events are being dropped.
**Solution:** Adjust rate limit settings:
```php
<?php
use Ivuorinen\MonologGdprFilter\RateLimiter;
use Ivuorinen\MonologGdprFilter\RateLimitedAuditLogger;
$rateLimiter = new RateLimiter(
maxEvents: 1000, // Increase limit
windowSeconds: 60,
burstLimit: 100 // Increase burst
);
$rateLimitedLogger = new RateLimitedAuditLogger($baseLogger, $rateLimiter);
```
## Error Messages Reference
### InvalidRegexPatternException
**Message:** `Invalid regex pattern: [pattern]`
**Cause:** The pattern has invalid regex syntax.
**Solution:**
```php
<?php
// Test pattern before using
$pattern = '/[invalid/';
if (@preg_match($pattern, '') === false) {
echo "Invalid pattern: " . preg_last_error_msg();
}
```
### RecursionDepthExceededException
**Message:** `Maximum recursion depth exceeded`
**Cause:** Nested data structure exceeds max depth.
**Solutions:**
```php
<?php
// Increase max depth
$processor = new GdprProcessor($patterns, [], [], null, 20);
// Or flatten your data before processing
$flatContext = iterator_to_array(
new RecursiveIteratorIterator(
new RecursiveArrayIterator($context)
),
false
);
```
### MaskingOperationFailedException
**Message:** `Masking operation failed: [details]`
**Cause:** An error occurred during masking.
**Solution:** Enable recovery mode:
```php
<?php
use Ivuorinen\MonologGdprFilter\Recovery\FallbackMaskStrategy;
use Ivuorinen\MonologGdprFilter\Recovery\FailureMode;
$fallback = new FallbackMaskStrategy(FailureMode::FAIL_SAFE);
// Use with your processor
```
### InvalidConfigurationException
**Message:** `Invalid configuration: [details]`
**Cause:** Invalid processor configuration.
**Solution:** Validate configuration:
```php
<?php
use Ivuorinen\MonologGdprFilter\Builder\GdprProcessorBuilder;
try {
$processor = (new GdprProcessorBuilder())
->addPattern('/valid-pattern/', '[MASKED]')
->build();
} catch (InvalidConfigurationException $e) {
echo "Configuration error: " . $e->getMessage();
}
```
## Getting Help
If you're still experiencing issues:
1. **Check the tests:** The test suite contains many usage examples:
```bash
ls tests/
```
2. **Enable debug mode:** Add verbose logging:
```php
<?php
$auditLogger = function ($path, $original, $masked): void {
error_log("GDPR Mask: $path | $original -> $masked");
};
```
3. **Report issues:** Open an issue on GitHub with:
- PHP version (`php -v`)
- Library version (`composer show ivuorinen/monolog-gdpr-filter`)
- Minimal reproduction code
- Expected vs actual behavior

View File

@@ -0,0 +1,258 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';
/**
* Conditional Masking Examples
*
* This file demonstrates various ways to use conditional masking
* to apply GDPR processing only when certain conditions are met.
*/
use Ivuorinen\MonologGdprFilter\ConditionalRuleFactory;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger;
use Monolog\LogRecord;
// Example 1: Level-based conditional masking
// Only mask sensitive data in ERROR and CRITICAL logs
echo "=== Example 1: Level-based Conditional Masking ===\n";
$levelBasedProcessor = new GdprProcessor(
['/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/' => '***EMAIL***'],
[],
[],
null,
100,
[],
[
'error_levels_only' => ConditionalRuleFactory::createLevelBasedRule(['Error', 'Critical'])
]
);
$logger = new Logger('example');
$logger->pushHandler(new StreamHandler('php://stdout', Level::Debug));
$logger->pushProcessor($levelBasedProcessor);
$logger->info('User john@example.com logged in successfully'); // Email NOT masked
$logger->error('Failed login attempt for admin@company.com'); // Email WILL be masked
echo "\n";
// Example 2: Channel-based conditional masking
// Only mask data in security and audit channels
echo "=== Example 2: Channel-based Conditional Masking ===\n";
$channelBasedProcessor = new GdprProcessor(
['/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/' => '***EMAIL***'],
[],
[],
null,
100,
[],
[
'security_channels' => ConditionalRuleFactory::createChannelBasedRule(['security', 'audit'])
]
);
$securityLogger = new Logger('security');
$securityLogger->pushHandler(new StreamHandler('php://stdout', Level::Debug));
$securityLogger->pushProcessor($channelBasedProcessor);
$appLogger = new Logger('application');
$appLogger->pushHandler(new StreamHandler('php://stdout', Level::Debug));
$appLogger->pushProcessor($channelBasedProcessor);
$securityLogger->info('Security event: user@example.com accessed admin panel'); // WILL be masked
$appLogger->info('Application event: user@example.com placed order'); // NOT masked
echo "\n";
// Example 3: Context-based conditional masking
// Only mask when specific fields are present in context
echo "=== Example 3: Context-based Conditional Masking ===\n";
$contextBasedProcessor = new GdprProcessor(
['/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/' => '***EMAIL***'],
[],
[],
null,
100,
[],
[
'gdpr_consent_required' => ConditionalRuleFactory::createContextFieldRule('user.gdpr_consent')
]
);
$contextLogger = new Logger('context');
$contextLogger->pushHandler(new StreamHandler('php://stdout', Level::Debug));
$contextLogger->pushProcessor($contextBasedProcessor);
// This will be masked because gdpr_consent field is present
$contextLogger->info('User action performed', [
'email' => 'user@example.com',
'user' => ['id' => 123, 'gdpr_consent' => true]
]);
// This will NOT be masked because gdpr_consent field is missing
$contextLogger->info('System action performed', [
'email' => 'system@example.com',
'user' => ['id' => 1]
]);
echo "\n";
// Example 4: Environment-based conditional masking
// Only mask in production environment
echo "=== Example 4: Environment-based Conditional Masking ===\n";
$envBasedProcessor = new GdprProcessor(
['/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/' => '***EMAIL***'],
[],
[],
null,
100,
[],
[
'production_only' => ConditionalRuleFactory::createContextValueRule('env', 'production')
]
);
$envLogger = new Logger('env');
$envLogger->pushHandler(new StreamHandler('php://stdout', Level::Debug));
$envLogger->pushProcessor($envBasedProcessor);
// This will be masked because env=production
$envLogger->info('Production log entry', [
'email' => 'prod@example.com',
'env' => 'production'
]);
// This will NOT be masked because env=development
$envLogger->info('Development log entry', [
'email' => 'dev@example.com',
'env' => 'development'
]);
echo "\n";
// Example 5: Multiple conditional rules (AND logic)
// Only mask when ALL conditions are met
echo "=== Example 5: Multiple Conditional Rules ===\n";
$multiRuleProcessor = new GdprProcessor(
['/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/' => '***EMAIL***'],
[],
[],
null,
100,
[],
[
'error_level' => ConditionalRuleFactory::createLevelBasedRule(['Error', 'Critical']),
'production_env' => ConditionalRuleFactory::createContextValueRule('env', 'production'),
'security_channel' => ConditionalRuleFactory::createChannelBasedRule(['security'])
]
);
$multiLogger = new Logger('security');
$multiLogger->pushHandler(new StreamHandler('php://stdout', Level::Debug));
$multiLogger->pushProcessor($multiRuleProcessor);
// This WILL be masked - all conditions met: Error level + production env + security channel
$multiLogger->error('Security error in production', [
'email' => 'admin@example.com',
'env' => 'production'
]);
// This will NOT be masked - wrong level (Info instead of Error)
$multiLogger->info('Security info in production', [
'email' => 'admin@example.com',
'env' => 'production'
]);
echo "\n";
// Example 6: Custom conditional rule
// Create a custom rule based on complex logic
echo "=== Example 6: Custom Conditional Rule ===\n";
$customRule = function (LogRecord $record): bool {
// Only mask for high-privilege users (user_id > 1000) during business hours
$context = $record->context;
$isHighPrivilegeUser = isset($context['user_id']) && $context['user_id'] > 1000;
$isBusinessHours = (int)date('H') >= 9 && (int)date('H') <= 17;
return $isHighPrivilegeUser && $isBusinessHours;
};
$customProcessor = new GdprProcessor(
['/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/' => '***EMAIL***'],
[],
[],
null,
100,
[],
[
'high_privilege_business_hours' => $customRule
]
);
$customLogger = new Logger('custom');
$customLogger->pushHandler(new StreamHandler('php://stdout', Level::Debug));
$customLogger->pushProcessor($customProcessor);
// This will be masked if user_id > 1000 AND it's business hours
$customLogger->info('High privilege user action', [
'email' => 'admin@example.com',
'user_id' => 1001,
'action' => 'delete_user'
]);
// This will NOT be masked (user_id <= 1000)
$customLogger->info('Regular user action', [
'email' => 'user@example.com',
'user_id' => 500,
'action' => 'view_profile'
]);
echo "\n";
// Example 7: Combining conditional masking with data type masking
echo "=== Example 7: Conditional + Data Type Masking ===\n";
$combinedProcessor = new GdprProcessor(
['/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/' => '***EMAIL***'],
[],
[],
null,
100,
[
'integer' => '***INT***',
'string' => '***STRING***'
],
[
'error_level' => ConditionalRuleFactory::createLevelBasedRule(['Error'])
]
);
$combinedLogger = new Logger('combined');
$combinedLogger->pushHandler(new StreamHandler('php://stdout', Level::Debug));
$combinedLogger->pushProcessor($combinedProcessor);
// ERROR level: both regex patterns AND data type masking will be applied
$combinedLogger->error('Error occurred', [
'email' => 'error@example.com', // Will be masked by regex
'user_id' => 12345, // Will be masked by data type rule
'message' => 'Something went wrong' // Will be masked by data type rule
]);
// INFO level: no masking will be applied due to conditional rule
$combinedLogger->info('Info message', [
'email' => 'info@example.com', // Will NOT be masked
'user_id' => 67890, // Will NOT be masked
'message' => 'Everything is fine' // Will NOT be masked
]);
echo "\nConditional masking examples completed.\n";

View File

@@ -0,0 +1,417 @@
# Laravel Integration Examples
This document provides comprehensive examples for integrating the Monolog GDPR Filter with Laravel applications.
## Installation and Setup
### 1. Install the Package
```bash
composer require ivuorinen/monolog-gdpr-filter
```
### 2. Register the Service Provider
Add the service provider to your `config/app.php`:
```php
'providers' => [
// Other providers...
Ivuorinen\MonologGdprFilter\Laravel\GdprServiceProvider::class,
],
```
### 3. Add the Facade (Optional)
```php
'aliases' => [
// Other aliases...
'Gdpr' => Ivuorinen\MonologGdprFilter\Laravel\Facades\Gdpr::class,
],
```
### 4. Publish the Configuration
```bash
php artisan vendor:publish --tag=gdpr-config
```
## Configuration Examples
### Basic Configuration (`config/gdpr.php`)
```php
<?php
return [
'auto_register' => true,
'channels' => ['single', 'daily', 'stack'],
'field_paths' => [
'user.email' => '', // Mask with regex
'user.ssn' => GdprProcessor::removeField(),
'payment.card_number' => GdprProcessor::replaceWith('[CARD]'),
'request.password' => GdprProcessor::removeField(),
],
'custom_callbacks' => [
'user.ip' => fn($value) => hash('sha256', $value), // Hash IPs
'user.name' => fn($value) => strtoupper($value), // Transform names
],
'max_depth' => 100,
'audit_logging' => [
'enabled' => env('GDPR_AUDIT_ENABLED', false),
'channel' => 'gdpr-audit',
],
];
```
### Advanced Configuration
```php
<?php
use Ivuorinen\MonologGdprFilter\GdprProcessor;
return [
'patterns' => [
// Custom patterns for your application
'/\binternal-id-\d+\b/' => '***INTERNAL***',
'/\bcustomer-\d{6}\b/' => '***CUSTOMER***',
],
'field_paths' => [
// User data
'user.email' => '',
'user.phone' => GdprProcessor::replaceWith('[PHONE]'),
'user.address' => GdprProcessor::removeField(),
// Payment data
'payment.card_number' => GdprProcessor::replaceWith('[CARD]'),
'payment.cvv' => GdprProcessor::removeField(),
'payment.account_number' => GdprProcessor::replaceWith('[ACCOUNT]'),
// Request data
'request.password' => GdprProcessor::removeField(),
'request.token' => GdprProcessor::replaceWith('[TOKEN]'),
'headers.authorization' => GdprProcessor::replaceWith('[AUTH]'),
],
'custom_callbacks' => [
// Hash sensitive identifiers
'user.ip' => fn($ip) => 'ip_' . substr(hash('sha256', $ip), 0, 8),
'session.id' => fn($id) => 'sess_' . substr(hash('sha256', $id), 0, 12),
// Mask parts of identifiers
'user.username' => function($username) {
if (strlen($username) <= 3) return '***';
return substr($username, 0, 2) . str_repeat('*', strlen($username) - 2);
},
],
];
```
## Usage Examples
### 1. Using the Facade
```php
<?php
use Ivuorinen\MonologGdprFilter\Laravel\Facades\Gdpr;
// Mask a message directly
$maskedMessage = Gdpr::regExpMessage('Contact john.doe@example.com for details');
// Result: "Contact ***EMAIL*** for details"
// Get default patterns
$patterns = Gdpr::getDefaultPatterns();
// Test pattern validation
try {
Gdpr::validatePatterns(['/\btest\b/' => '***TEST***']);
echo "Pattern is valid!";
} catch (InvalidArgumentException $e) {
echo "Pattern error: " . $e->getMessage();
}
```
### 2. Manual Integration with Specific Channels
```php
<?php
// In a service provider or middleware
use Illuminate\Support\Facades\Log;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
$processor = app('gdpr.processor');
// Add to specific channel
Log::channel('api')->pushProcessor($processor);
Log::channel('audit')->pushProcessor($processor);
```
### 3. Custom Logging with GDPR Protection
```php
<?php
use Illuminate\Support\Facades\Log;
class UserService
{
public function createUser(array $userData)
{
// This will automatically be GDPR filtered
Log::info('Creating user', [
'user_data' => $userData, // Contains email, phone, etc.
'request_ip' => request()->ip(),
'timestamp' => now(),
]);
// User creation logic...
}
public function loginAttempt(string $email, bool $success)
{
Log::info('Login attempt', [
'email' => $email, // Will be masked
'success' => $success,
'ip' => request()->ip(), // Will be hashed if configured
'user_agent' => request()->userAgent(),
]);
}
}
```
## Artisan Commands
### Test Regex Patterns
```bash
# Test a pattern against sample data
php artisan gdpr:test-pattern '/\b\d{3}-\d{2}-\d{4}\b/' '***SSN***' '123-45-6789'
# With validation
php artisan gdpr:test-pattern '/\b\d{16}\b/' '***CARD***' '4111111111111111' --validate
```
### Debug Configuration
```bash
# Show current configuration
php artisan gdpr:debug --show-config
# Show all patterns
php artisan gdpr:debug --show-patterns
# Test with sample data
php artisan gdpr:debug \
--test-data='{
"message":"Email: test@example.com", "context":{"user":{"email":"user@example.com"}}
}'
```
## Middleware Integration
### HTTP Request/Response Logging
Register the middleware in `app/Http/Kernel.php`:
```php
protected $middleware = [
// Other middleware...
\Ivuorinen\MonologGdprFilter\Laravel\Middleware\GdprLogMiddleware::class,
];
```
Or apply to specific routes:
```php
Route::middleware(['gdpr.log'])->group(function () {
Route::post('/api/users', [UserController::class, 'store']);
Route::put('/api/users/{id}', [UserController::class, 'update']);
});
```
### Custom Middleware Example
```php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Ivuorinen\MonologGdprFilter\Laravel\Facades\Gdpr;
class ApiRequestLogger
{
public function handle(Request $request, Closure $next)
{
$startTime = microtime(true);
// Log request
Log::info('API Request', [
'method' => $request->method(),
'url' => $request->fullUrl(),
'headers' => $request->headers->all(),
'body' => $request->all(),
]);
$response = $next($request);
// Log response
Log::info('API Response', [
'status' => $response->getStatusCode(),
'duration' => round((microtime(true) - $startTime) * 1000, 2),
'memory' => memory_get_peak_usage(true),
]);
return $response;
}
}
```
## Testing
### Unit Testing with GDPR
```php
<?php
namespace Tests\Unit;
use Tests\TestCase;
use Ivuorinen\MonologGdprFilter\Laravel\Facades\Gdpr;
class GdprTest extends TestCase
{
public function test_email_masking()
{
$result = Gdpr::regExpMessage('Contact john@example.com');
$this->assertStringContains('***EMAIL***', $result);
}
public function test_custom_pattern()
{
$processor = new GdprProcessor([
'/\bcustomer-\d+\b/' => '***CUSTOMER***'
]);
$result = $processor->regExpMessage('Order for customer-12345');
$this->assertEquals('Order for ***CUSTOMER***', $result);
}
}
```
### Integration Testing
```php
<?php
namespace Tests\Feature;
use Tests\TestCase;
use Illuminate\Support\Facades\Log;
class GdprLoggingTest extends TestCase
{
public function test_user_creation_logging()
{
Log::shouldReceive('info')
->once()
->with('Creating user', \Mockery::on(function ($context) {
// Verify that email is masked
return str_contains($context['user_data']['email'], '***EMAIL***');
}));
$response = $this->postJson('/api/users', [
'name' => 'John Doe',
'email' => 'john@example.com',
'phone' => '+1234567890',
]);
$response->assertStatus(201);
}
}
```
## Performance Considerations
### Optimize for Large Applications
```php
<?php
// config/gdpr.php
return [
'performance' => [
'chunk_size' => 500, // Smaller chunks for memory-constrained environments
'garbage_collection_threshold' => 5000, // More frequent GC
],
// Use more specific patterns to reduce processing time
'patterns' => [
'/\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b/' => '***EMAIL***',
'/\b\d{3}-\d{2}-\d{4}\b/' => '***SSN***',
// Avoid overly broad patterns
],
// Prefer field paths over regex for known locations
'field_paths' => [
'user.email' => '',
'request.email' => '',
'customer.email_address' => '',
],
];
```
### Channel-Specific Configuration
```php
<?php
// Apply GDPR only to specific channels
'channels' => [
'single', // Local development
'daily', // Production file logs
'database', // Database logging
// Skip 'stderr' for performance-critical error logging
],
```
## Troubleshooting
### Common Issues
1. **GDPR not working**: Check if auto_register is true and channels are correctly configured
2. **Performance issues**: Reduce pattern count, use field_paths instead of regex
3. **Over-masking**: Make patterns more specific, check pattern order
4. **Memory issues**: Reduce chunk_size and garbage_collection_threshold
### Debug Steps
```bash
# Check configuration
php artisan gdpr:debug --show-config
# Test patterns
php artisan gdpr:test-pattern '/your-pattern/' '***MASKED***' 'test-string'
# View current patterns
php artisan gdpr:debug --show-patterns
```
## Best Practices
1. **Use field paths over regex** when you know the exact location of sensitive data
2. **Test patterns thoroughly** before deploying to production
3. **Monitor performance** with large datasets
4. **Use audit logging** for compliance requirements
5. **Regularly review patterns** to ensure they're not over-masking
6. **Consider data retention** policies for logged data

174
examples/rate-limiting.php Normal file
View File

@@ -0,0 +1,174 @@
<?php
/**
* Rate Limiting for Audit Logging Examples
*
* This file demonstrates how to use rate limiting to prevent
* audit log flooding while maintaining system performance.
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\RateLimitedAuditLogger;
use Monolog\Logger;
use Monolog\LogRecord;
use Monolog\Handler\StreamHandler;
use Monolog\Level;
// Example 1: Basic Rate-Limited Audit Logging
echo "=== Example 1: Basic Rate-Limited Audit Logging ===\n";
$auditLogs = [];
$baseAuditLogger = function (string $path, mixed $original, mixed $masked) use (&$auditLogs): void {
$auditLogs[] = [
'timestamp' => date('Y-m-d H:i:s'),
'path' => $path,
'original' => $original,
'masked' => $masked
];
echo sprintf('AUDIT: %s - %s -> %s%s', $path, $original, $masked, PHP_EOL);
};
// Wrap with rate limiting (100 per minute by default)
$rateLimitedLogger = new RateLimitedAuditLogger($baseAuditLogger, 5, 60); // 5 per minute for demo
$processor = new GdprProcessor(
['/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/' => '***EMAIL***'],
['user.email' => 'masked@example.com'],
[],
$rateLimitedLogger
);
$logger = new Logger('rate-limited');
$logger->pushHandler(new StreamHandler('php://stdout', Level::Debug));
$logger->pushProcessor($processor);
// Simulate high-volume logging that would exceed rate limits
for ($i = 0; $i < 10; $i++) {
$logger->info('User activity', [
'user' => ['email' => sprintf('user%d@example.com', $i)],
'action' => 'login'
]);
}
echo "\nTotal audit logs: " . count($auditLogs) . "\n";
echo "Expected: 5 regular logs + rate limit warnings\n\n";
// Example 2: Using Predefined Rate Limiting Profiles
echo "=== Example 2: Rate Limiting Profiles ===\n";
$auditLogs2 = [];
/** @psalm-suppress DeprecatedMethod - Example demonstrates deprecated factory method */
$baseLogger2 = GdprProcessor::createArrayAuditLogger($auditLogs2, false);
// Available profiles: 'strict', 'default', 'relaxed', 'testing'
$strictLogger = RateLimitedAuditLogger::create($baseLogger2, 'strict'); // 50/min
$relaxedLogger = RateLimitedAuditLogger::create($baseLogger2, 'relaxed'); // 200/min
$testingLogger = RateLimitedAuditLogger::create($baseLogger2, 'testing'); // 1000/min
echo "Strict profile: " . ($strictLogger->isOperationAllowed('general_operations')
? 'Available' : 'Rate limited') . "\n";
echo "Relaxed profile: " . ($relaxedLogger->isOperationAllowed('general_operations')
? 'Available' : 'Rate limited') . "\n";
echo "Testing profile: " . ($testingLogger->isOperationAllowed('general_operations')
? 'Available' : 'Rate limited') . "\n\n";
// Example 3: Using GdprProcessor Helper Methods
echo "=== Example 3: GdprProcessor Helper Methods ===\n";
$auditLogs3 = [];
// Create rate-limited logger using GdprProcessor helper
/** @psalm-suppress DeprecatedMethod - Example demonstrates deprecated factory methods */
$rateLimitedAuditLogger = GdprProcessor::createRateLimitedAuditLogger(
GdprProcessor::createArrayAuditLogger($auditLogs3, false),
'default'
);
$processor3 = new GdprProcessor(
['/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/' => '***EMAIL***'],
['sensitive_data' => '***REDACTED***'],
[],
$rateLimitedAuditLogger
);
// Process some logs
for ($i = 0; $i < 3; $i++) {
$logRecord = new LogRecord(
new DateTimeImmutable(),
'app',
Level::Info,
sprintf('Processing user%d@example.com', $i),
['sensitive_data' => 'secret_value_' . $i]
);
$result = $processor3($logRecord);
echo "Processed: " . $result->message . "\n";
}
echo "Audit logs generated: " . count($auditLogs3) . "\n\n";
// Example 4: Rate Limit Statistics and Monitoring
echo "=== Example 4: Rate Limit Statistics ===\n";
$rateLimitedLogger4 = new RateLimitedAuditLogger($baseAuditLogger, 10, 60);
// Generate some activity
for ($i = 0; $i < 5; $i++) {
$rateLimitedLogger4('test_operation', 'value_' . $i, 'masked_' . $i);
}
// Check statistics
$stats = $rateLimitedLogger4->getRateLimitStats();
echo "Rate Limit Statistics:\n";
foreach ($stats as $operationType => $stat) {
if ($stat['current_requests'] > 0) {
echo " {$operationType}:\n";
echo sprintf(' Current requests: %d%s', $stat['current_requests'], PHP_EOL);
echo sprintf(' Remaining requests: %d%s', $stat['remaining_requests'], PHP_EOL);
echo " Time until reset: {$stat['time_until_reset']} seconds\n";
}
}
echo "\n";
// Example 5: Different Operation Types
echo "=== Example 5: Operation Type Classification ===\n";
$rateLimitedLogger5 = new RateLimitedAuditLogger($baseAuditLogger, 2, 60); // Very restrictive
echo "Testing different operation types (2 per minute limit):\n";
// These will be classified into different operation types
$rateLimitedLogger5('json_masked', '{"key": "value"}', '{"key": "***MASKED***"}');
$rateLimitedLogger5('conditional_skip', 'skip_reason', 'Level not matched');
$rateLimitedLogger5('regex_error', '/invalid[/', 'Pattern compilation failed');
$rateLimitedLogger5('preg_replace_error', 'input', 'PCRE error occurred');
// Try to exceed limits for each type
echo "\nTesting rate limiting per operation type:\n";
$rateLimitedLogger5('json_encode_error', 'data', 'JSON encoding failed'); // json_operations
$rateLimitedLogger5('json_decode_error', 'data', 'JSON decoding failed'); // json_operations (should be limited)
$rateLimitedLogger5('conditional_error', 'rule', 'Rule evaluation failed'); // conditional_operations
$rateLimitedLogger5('regex_validation', 'pattern', 'Pattern is invalid'); // regex_operations
echo "\nOperation type stats:\n";
$stats5 = $rateLimitedLogger5->getRateLimitStats();
foreach ($stats5 as $type => $stat) {
if ($stat['current_requests'] > 0) {
$current = $stat['current_requests'];
$all = $stat['current_requests'] + $stat['remaining_requests'];
echo " {$type}: {$current}/{$all} used\n";
}
}
echo "\n=== Rate Limiting Examples Completed ===\n";
echo "\nKey Benefits:\n";
echo "• Prevents audit log flooding during high-volume operations\n";
echo "• Maintains system performance by limiting resource usage\n";
echo "• Provides configurable rate limits for different environments\n";
echo "• Separate rate limits for different operation types\n";
echo "• Built-in statistics and monitoring capabilities\n";
echo "• Graceful degradation with rate limit warnings\n";

View File

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

109
phpstan.neon Normal file
View File

@@ -0,0 +1,109 @@
includes: []
parameters:
level: 6
paths:
- src
- tests
- examples
- config
# Conservative settings
reportUnmatchedIgnoredErrors: false
treatPhpDocTypesAsCertain: false
# Ignore specific patterns that are acceptable
ignoreErrors:
# Allow mixed types for backward compatibility
- '#Parameter \#\d+ \$\w+ of method .* expects .*, mixed given#'
- '#Method .* return type has no value type specified in iterable type array#'
- '#Property .* type has no value type specified in iterable type array#'
# Allow callable types validated at runtime
- '#Cannot call callable .* on .* type callable#'
- '#Parameter \#\d+ .* expects callable.*: callable given#'
# Allow reflection patterns in tests
- '#Call to method .* on an unknown class ReflectionClass#'
- '#Access to an undefined property ReflectionClass::\$.*#'
- '#Call to an undefined method ReflectionMethod::.*#'
# Allow PHPUnit patterns
- '#Call to an undefined method PHPUnit\\Framework\\.*::(assert.*|expect.*)#'
- '#Parameter \#\d+ \$.*Test::.* expects .*, .* given#'
# Allow Laravel function calls
- '#Function config not found#'
- '#Function app not found#'
- '#Function now not found#'
- '#Function config_path not found#'
- '#Function env not found#'
# Allow configuration array access patterns
- '#Offset .* does not exist on array#'
- '#Cannot access offset .* on mixed#'
# Allow intentional mixed usage in flexible APIs
- '#Argument of an invalid type mixed supplied for foreach#'
- '#Parameter \#\d+ .* expects .*, mixed given#'
- '#Cannot call method .* on mixed#'
# Allow string manipulation patterns
- '#Binary operation .* between .* and .* results in an error#'
# Allow test-specific patterns
- '#Call to function not_callable#'
- '#Method DateTimeImmutable::offsetGet\(\) invoked with \d+ parameter#'
# Allow complex return types in GdprProcessor
- '#Method Ivuorinen\\MonologGdprFilter\\GdprProcessor::getDefaultPatterns\(\) should return array.* but returns array.*#'
# Allow intentional validation test failures
- '#Parameter .* of (method|class) Ivuorinen\\MonologGdprFilter\\(GdprProcessor|RateLimitedAuditLogger).*(constructor|__construct).* expects .*, .* given#'
- '#Parameter \#1 \$patterns of static method Ivuorinen\\MonologGdprFilter\\InputValidator::validatePatterns\(\) expects array<string, string>, array.* given#'
- '#Parameter \#1 \$fieldPaths of static method Ivuorinen\\MonologGdprFilter\\InputValidator::validateFieldPaths\(\) expects .*, array.* given#'
- '#Parameter \#1 \$customCallbacks of static method Ivuorinen\\MonologGdprFilter\\InputValidator::validateCustomCallbacks\(\) expects .*, array.* given#'
- '#Parameter \#1 \$auditLogger of static method Ivuorinen\\MonologGdprFilter\\InputValidator::validateAuditLogger\(\) expects .*, .* given#'
- '#Parameter \#1 \$dataTypeMasks of static method Ivuorinen\\MonologGdprFilter\\InputValidator::validateDataTypeMasks\(\) expects array<string, string>, array.* given#'
- '#Parameter \#1 \$conditionalRules of static method Ivuorinen\\MonologGdprFilter\\InputValidator::validateConditionalRules\(\) expects .*, array.* given#'
- '#Parameter \#1 \$typeMasks of class Ivuorinen\\MonologGdprFilter\\Strategies\\DataTypeMaskingStrategy constructor expects array<string, string>, array.* given#'
- '#Parameter \#1 \$fieldConfigs of class Ivuorinen\\MonologGdprFilter\\Strategies\\FieldPathMaskingStrategy constructor expects .*, array.* given#'
# Allow test helper methods in anonymous classes (AbstractMaskingStrategyTest)
- '#Call to an undefined method Ivuorinen\\MonologGdprFilter\\Strategies\\AbstractMaskingStrategy::test.*#'
- '#Method Ivuorinen\\MonologGdprFilter\\Strategies\\AbstractMaskingStrategy@anonymous/.* has parameter .* with no value type specified in iterable type array#'
# Allow test assertions that intentionally validate known types
- '#Call to method PHPUnit\\Framework\\Assert::(assertIsArray|assertIsInt|assertTrue|assertContainsOnlyInstancesOf)\(\) .* will always evaluate to true#'
- '#Call to method PHPUnit\\Framework\\Assert::(assertIsString|assertIsFloat|assertIsBool)\(\) with .* will always evaluate to true#'
# Allow PHPUnit attributes with named arguments
- '#Attribute class PHPUnit\\Framework\\Attributes\\.*#'
# Allow intentional static method calls in tests
- '#Static call to instance method#'
- '#Method .* invoked with \d+ parameter.*, \d+ required#'
# Allow nullsafe operator usage
- '#Using nullsafe method call on non-nullable type#'
# Allow unused test constants (used by trait)
- '#Constant Tests\\.*::.* is unused#'
# PHP version for analysis
phpVersion: 80400
# Stub files for missing functions/classes
stubFiles: []
# Bootstrap files
bootstrapFiles: []
# Exclude analysis paths
excludePaths:
- vendor/*
- .phpunit.cache/*
- src/Laravel/*
# Custom rules (none for now)
customRulesetUsed: false

View File

@@ -21,8 +21,4 @@
</source>
<coverage/>
<php>
<ini name="xdebug.mode" value="coverage"/>
</php>
</phpunit>

137
psalm.xml
View File

@@ -1,23 +1,138 @@
<?xml version="1.0"?>
<?xml version="1.0" ?>
<psalm
errorLevel="3"
errorLevel="5"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
phpVersion="8.2"
noCache="true"
ensureOverrideAttribute="false"
restrictReturnTypes="true"
phpVersion="8.4"
noCache="false"
findUnusedPsalmSuppress="true"
skipChecksOnUnresolvableIncludes="true"
allowPhpStormGenerics="true"
allowStringToStandInForClass="true"
memoizeMethodCallResults="true"
hoistConstants="true"
addParamTypehint="false"
checkForThrowsDocblock="false"
checkForThrowsInGlobalScope="false"
sealAllMethods="false"
sealAllProperties="false"
>
<projectFiles>
<directory name="src"/>
<directory name="src" />
<directory name="examples" />
<directory name="config" />
<directory name="tests" />
<ignoreFiles>
<directory name="vendor"/>
<directory name="vendor" />
<directory name="src/Laravel" />
</ignoreFiles>
</projectFiles>
<plugins>
<pluginClass class="Psalm\PhpUnitPlugin\Plugin"/>
<pluginClass class="Orklah\StrictEquality\Plugin"/>
<pluginClass class="Guuzen\PsalmEnumPlugin\Plugin"/>
<pluginClass class="Psalm\PhpUnitPlugin\Plugin" />
</plugins>
<issueHandlers>
<!-- Laravel function compatibility -->
<UndefinedFunction>
<errorLevel type="suppress">
<referencedFunction name="config" />
<referencedFunction name="app" />
<referencedFunction name="now" />
<referencedFunction name="config_path" />
<referencedFunction name="env" />
</errorLevel>
</UndefinedFunction>
<!-- Complex return type issues in GdprProcessor -->
<InvalidReturnType>
<errorLevel type="suppress">
<file name="src/GdprProcessor.php" />
</errorLevel>
</InvalidReturnType>
<!-- Override attributes - suppress for now to avoid breaking changes -->
<MissingOverrideAttribute errorLevel="suppress" />
<!-- Class finalization - suppress to avoid API breaking changes -->
<ClassMustBeFinal errorLevel="suppress" />
<!-- Mixed types - necessary for flexible APIs -->
<MixedArgument errorLevel="suppress" />
<MixedAssignment errorLevel="suppress" />
<MixedMethodCall errorLevel="suppress" />
<MixedPropertyFetch errorLevel="suppress" />
<MixedArrayAccess errorLevel="suppress" />
<!-- Missing type annotations - backward compatibility -->
<MissingReturnType errorLevel="suppress" />
<MissingParamType errorLevel="suppress" />
<MissingPropertyType errorLevel="suppress" />
<!-- Prevent Psalm from adding complex nested return types -->
<MismatchingDocblockReturnType errorLevel="suppress" />
<MoreSpecificReturnType errorLevel="suppress" />
<LessSpecificReturnStatement errorLevel="suppress" />
<!-- Test-specific suppressions -->
<!-- Redundant test assertions - provide defensive runtime validation -->
<RedundantCondition>
<errorLevel type="suppress">
<directory name="tests" />
</errorLevel>
</RedundantCondition>
<RedundantConditionGivenDocblockType>
<errorLevel type="suppress">
<directory name="tests" />
</errorLevel>
</RedundantConditionGivenDocblockType>
<ArgumentTypeCoercion>
<errorLevel type="suppress">
<directory name="tests" />
</errorLevel>
</ArgumentTypeCoercion>
<!-- Test validation issues -->
<InvalidArgument>
<errorLevel type="suppress">
<directory name="tests" />
</errorLevel>
</InvalidArgument>
<!-- Test helper methods in anonymous classes -->
<UndefinedMethod>
<errorLevel type="suppress">
<file name="tests/Strategies/AbstractMaskingStrategyTest.php" />
</errorLevel>
</UndefinedMethod>
<!-- Test function calls -->
<UndefinedFunction>
<errorLevel type="suppress">
<directory name="tests" />
</errorLevel>
</UndefinedFunction>
<!-- Laravel-specific patterns -->
<!-- (Laravel directory is excluded from scanning) -->
<!-- Intentional design choices -->
<PropertyNotSetInConstructor errorLevel="suppress" />
<PossiblyUnusedMethod errorLevel="suppress" />
<PossiblyUnusedProperty errorLevel="suppress" />
<!-- Array access patterns for configuration -->
<PossiblyUndefinedArrayOffset errorLevel="suppress" />
<PossiblyInvalidArrayOffset errorLevel="suppress" />
<!-- Closure patterns in callbacks -->
<UnusedClosureParam errorLevel="suppress" />
<MissingClosureParamType errorLevel="suppress" />
<MissingClosureReturnType errorLevel="suppress" />
<!-- String manipulation patterns -->
<PossiblyInvalidCast errorLevel="suppress" />
</issueHandlers>
</psalm>

View File

@@ -3,26 +3,69 @@
declare(strict_types=1);
use Rector\Config\RectorConfig;
use Rector\Exception\Configuration\InvalidConfigurationException;
use Rector\Set\ValueObject\SetList;
use Rector\Php82\Rector\Class_\ReadOnlyClassRector;
use Rector\TypeDeclaration\Rector\Property\TypedPropertyFromStrictConstructorRector;
use Rector\CodeQuality\Rector\FuncCall\SimplifyRegexPatternRector;
try {
return RectorConfig::configure()
->withPaths([
__DIR__ . '/src',
__DIR__ . '/tests',
])
->withPhpVersion(80200)
->withPhpSets(php82: true)
->withComposerBased(phpunit: true)
->withImportNames(removeUnusedImports: true)
->withPreparedSets(
deadCode: true,
codeQuality: true,
codingStyle: true,
earlyReturn: true,
phpunitCodeQuality: true
);
} catch (InvalidConfigurationException $e) {
echo "Configuration error: " . $e->getMessage() . PHP_EOL;
exit(1);
}
return RectorConfig::configure()
->withPaths([
__DIR__ . '/src',
__DIR__ . '/tests',
__DIR__ . '/examples',
__DIR__ . '/config',
])
->withPhpSets(
php82: true,
)
->withSets([
// Only use very conservative, safe rule sets
SetList::CODE_QUALITY, // Safe code quality improvements
SetList::TYPE_DECLARATION, // Type declarations (generally safe)
])
->withSkip([
// Skip risky transformations that can break existing functionality
// Skip readonly class conversion - can break existing usage
ReadOnlyClassRector::class,
// Skip automatic property typing - can break existing flexibility
TypedPropertyFromStrictConstructorRector::class,
// Skip regex pattern simplification - can break regex behavior ([0-9] vs \d with unicode)
SimplifyRegexPatternRector::class,
// Skip entire directories for certain transformations
'*/tests/*' => [
// Don't modify test methods or assertions - they have specific requirements
],
// Skip specific files that are sensitive
__DIR__ . '/src/GdprProcessor.php' => [
// Don't modify the main processor class structure
],
// Skip Laravel integration files - they have specific requirements
__DIR__ . '/src/Laravel/*' => [
// Don't modify Laravel-specific code
],
])
->withImportNames(
importNames: true,
importDocBlockNames: false, // Don't modify docblock imports - can break documentation
importShortClasses: false, // Don't import short class names - can cause conflicts
removeUnusedImports: true, // This is generally safe
)
// Conservative PHP version targeting
->withPhpVersion(80400)
// Don't use prepared sets - they're too aggressive
->withPreparedSets(
deadCode: false, // Disable dead code removal
codingStyle: false, // Disable coding style changes
earlyReturn: false, // Disable early return changes
phpunitCodeQuality: false, // Disable PHPUnit modifications
strictBooleans: false, // Disable strict boolean changes
privatization: false, // Disable privatization changes
naming: false, // Disable naming changes
typeDeclarations: false, // Disable type declaration changes
);

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Anonymization;
/**
* Represents a generalization strategy for k-anonymity.
*
* @api
*/
final class GeneralizationStrategy
{
/**
* @var callable(mixed):string
*/
private $generalizer;
/**
* @param callable(mixed):string $generalizer Function that generalizes a value
* @param string $type Type identifier for the strategy
*/
public function __construct(
callable $generalizer,
private readonly string $type = 'custom'
) {
$this->generalizer = $generalizer;
}
/**
* Apply the generalization to a value.
*
* @param mixed $value The value to generalize
* @return string The generalized value
*/
public function generalize(mixed $value): string
{
return ($this->generalizer)($value);
}
/**
* Get the strategy type.
*/
public function getType(): string
{
return $this->type;
}
}

View File

@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Anonymization;
/**
* K-Anonymity implementation for GDPR compliance.
*
* K-anonymity is a privacy model ensuring that each record in a dataset
* is indistinguishable from at least k-1 other records with respect to
* certain identifying attributes (quasi-identifiers).
*
* Common use cases:
* - Age generalization (25 -> "20-29")
* - Location generalization (specific address -> region)
* - Date generalization (specific date -> month/year)
*
* @api
*/
final class KAnonymizer
{
/**
* @var array<string,GeneralizationStrategy>
*/
private array $strategies = [];
/**
* @var callable(string,mixed,mixed):void|null
*/
private $auditLogger;
/**
* @param callable(string,mixed,mixed):void|null $auditLogger Optional audit logger
*/
public function __construct(?callable $auditLogger = null)
{
$this->auditLogger = $auditLogger;
}
/**
* Register a generalization strategy for a field.
*/
public function registerStrategy(string $field, GeneralizationStrategy $strategy): self
{
$this->strategies[$field] = $strategy;
return $this;
}
/**
* Register an age generalization strategy.
*
* @param int $rangeSize Size of age ranges (e.g., 10 for 20-29, 30-39)
*/
public function registerAgeStrategy(string $field, int $rangeSize = 10): self
{
$this->strategies[$field] = new GeneralizationStrategy(
static function (mixed $value) use ($rangeSize): string {
$age = (int) $value;
$lowerBound = (int) floor($age / $rangeSize) * $rangeSize;
$upperBound = $lowerBound + $rangeSize - 1;
return "{$lowerBound}-{$upperBound}";
},
'age'
);
return $this;
}
/**
* Register a date generalization strategy.
*
* @param string $precision 'year', 'month', 'quarter'
*/
public function registerDateStrategy(string $field, string $precision = 'month'): self
{
$this->strategies[$field] = new GeneralizationStrategy(
static function (mixed $value) use ($precision): string {
if (!$value instanceof \DateTimeInterface) {
$value = new \DateTimeImmutable((string) $value);
}
return match ($precision) {
'year' => $value->format('Y'),
'quarter' => $value->format('Y') . '-Q' . (int) ceil((int) $value->format('n') / 3),
default => $value->format('Y-m'),
};
},
'date'
);
return $this;
}
/**
* Register a location/ZIP code generalization strategy.
*
* @param int $prefixLength Number of characters to keep
*/
public function registerLocationStrategy(string $field, int $prefixLength = 3): self
{
$this->strategies[$field] = new GeneralizationStrategy(
static function (mixed $value) use ($prefixLength): string {
$value = (string) $value;
if (strlen($value) <= $prefixLength) {
return $value;
}
return substr($value, 0, $prefixLength) . str_repeat('*', strlen($value) - $prefixLength);
},
'location'
);
return $this;
}
/**
* Register a numeric range generalization strategy.
*
* @param int $rangeSize Size of numeric ranges
*/
public function registerNumericRangeStrategy(string $field, int $rangeSize = 10): self
{
$this->strategies[$field] = new GeneralizationStrategy(
static function (mixed $value) use ($rangeSize): string {
$num = (int) $value;
$lowerBound = (int) floor($num / $rangeSize) * $rangeSize;
$upperBound = $lowerBound + $rangeSize - 1;
return "{$lowerBound}-{$upperBound}";
},
'numeric_range'
);
return $this;
}
/**
* Register a custom generalization strategy.
*
* @param callable(mixed):string $generalizer
*/
public function registerCustomStrategy(string $field, callable $generalizer): self
{
$this->strategies[$field] = new GeneralizationStrategy($generalizer, 'custom');
return $this;
}
/**
* Anonymize a single record.
*
* @param array<string,mixed> $record The record to anonymize
* @return array<string,mixed> The anonymized record
*/
public function anonymize(array $record): array
{
foreach ($this->strategies as $field => $strategy) {
if (isset($record[$field])) {
$original = $record[$field];
$record[$field] = $strategy->generalize($original);
if ($this->auditLogger !== null && $record[$field] !== $original) {
($this->auditLogger)(
"k-anonymity.{$field}",
$original,
$record[$field]
);
}
}
}
return $record;
}
/**
* Anonymize a batch of records.
*
* @param list<array<string,mixed>> $records
* @return list<array<string,mixed>>
*/
public function anonymizeBatch(array $records): array
{
return array_map($this->anonymize(...), $records);
}
/**
* Get registered strategies.
*
* @return array<string,GeneralizationStrategy>
*/
public function getStrategies(): array
{
return $this->strategies;
}
/**
* Set the audit logger.
*
* @param callable(string,mixed,mixed):void|null $auditLogger
*/
public function setAuditLogger(?callable $auditLogger): void
{
$this->auditLogger = $auditLogger;
}
/**
* Create a pre-configured anonymizer for common GDPR scenarios.
*/
public static function createGdprDefault(?callable $auditLogger = null): self
{
return (new self($auditLogger))
->registerAgeStrategy('age')
->registerDateStrategy('birth_date', 'year')
->registerDateStrategy('created_at', 'month')
->registerLocationStrategy('zip_code', 3)
->registerLocationStrategy('postal_code', 3);
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\ArrayAccessor;
use Ivuorinen\MonologGdprFilter\Contracts\ArrayAccessorInterface;
/**
* Factory for creating ArrayAccessor instances.
*
* This factory allows dependency injection of the accessor creation logic,
* enabling easy swapping of implementations for testing or alternative libraries.
*
* @api
*/
class ArrayAccessorFactory
{
/**
* @var class-string<ArrayAccessorInterface>|callable(array<string, mixed>): ArrayAccessorInterface
*/
private $accessorClass;
/**
* @param class-string<ArrayAccessorInterface>|callable(array<string, mixed>): ArrayAccessorInterface|null $accessorClass
*/
public function __construct(string|callable|null $accessorClass = null)
{
$this->accessorClass = $accessorClass ?? DotArrayAccessor::class;
}
/**
* Create a new ArrayAccessor instance for the given data.
*
* @param array<string, mixed> $data Data array to wrap
*/
public function create(array $data): ArrayAccessorInterface
{
if (is_callable($this->accessorClass)) {
return ($this->accessorClass)($data);
}
$class = $this->accessorClass;
return new $class($data);
}
/**
* Create a factory with the default Dot implementation.
*/
public static function default(): self
{
return new self(DotArrayAccessor::class);
}
/**
* Create a factory with a custom accessor class.
*
* @param class-string<ArrayAccessorInterface> $accessorClass
*/
public static function withClass(string $accessorClass): self
{
return new self($accessorClass);
}
/**
* Create a factory with a custom callable.
*
* @param callable(array<string, mixed>): ArrayAccessorInterface $factory
*/
public static function withCallable(callable $factory): self
{
return new self($factory);
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\ArrayAccessor;
use Adbar\Dot;
use Ivuorinen\MonologGdprFilter\Contracts\ArrayAccessorInterface;
/**
* ArrayAccessor implementation using Adbar\Dot library.
*
* This class wraps the Adbar\Dot library to implement ArrayAccessorInterface,
* allowing the library to be swapped without affecting consuming code.
*
* @api
*/
final class DotArrayAccessor implements ArrayAccessorInterface
{
/** @var Dot<array-key, mixed> */
private readonly Dot $dot;
/**
* @param array<string, mixed> $data Initial data array
*/
public function __construct(array $data = [])
{
$this->dot = new Dot($data);
}
/**
* Create accessor from an existing array.
*
* @param array<string, mixed> $data Data array
*/
public static function fromArray(array $data): self
{
return new self($data);
}
#[\Override]
public function has(string $path): bool
{
return $this->dot->has($path);
}
#[\Override]
public function get(string $path, mixed $default = null): mixed
{
return $this->dot->get($path, $default);
}
#[\Override]
public function set(string $path, mixed $value): void
{
$this->dot->set($path, $value);
}
#[\Override]
public function delete(string $path): void
{
$this->dot->delete($path);
}
#[\Override]
public function all(): array
{
return $this->dot->all();
}
/**
* Get the underlying Dot instance for advanced operations.
*
* @return Dot<array-key, mixed>
*/
public function getDot(): Dot
{
return $this->dot;
}
}

216
src/Audit/AuditContext.php Normal file
View File

@@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Audit;
/**
* Structured context for audit log entries.
*
* Provides a standardized format for tracking masking operations,
* including timing, retry attempts, and error information.
*
* @api
*/
final readonly class AuditContext
{
public const STATUS_SUCCESS = 'success';
public const STATUS_FAILED = 'failed';
public const STATUS_RECOVERED = 'recovered';
public const STATUS_SKIPPED = 'skipped';
public const OP_REGEX = 'regex';
public const OP_FIELD_PATH = 'field_path';
public const OP_CALLBACK = 'callback';
public const OP_DATA_TYPE = 'data_type';
public const OP_JSON = 'json';
public const OP_CONDITIONAL = 'conditional';
/**
* @param string $operationType Type of masking operation performed
* @param string $status Operation result status
* @param string|null $correlationId Unique ID linking related operations
* @param int $attemptNumber Retry attempt number (1 = first attempt)
* @param float $durationMs Operation duration in milliseconds
* @param ErrorContext|null $error Error details if operation failed
* @param array<string, mixed> $metadata Additional context information
*/
public function __construct(
public string $operationType,
public string $status = self::STATUS_SUCCESS,
public ?string $correlationId = null,
public int $attemptNumber = 1,
public float $durationMs = 0.0,
public ?ErrorContext $error = null,
public array $metadata = [],
) {
}
/**
* Create a success audit context.
*
* @param string $operationType The type of masking operation
* @param float $durationMs Operation duration in milliseconds
* @param array<string, mixed> $metadata Additional context
*/
public static function success(
string $operationType,
float $durationMs = 0.0,
array $metadata = []
): self {
return new self(
operationType: $operationType,
status: self::STATUS_SUCCESS,
durationMs: $durationMs,
metadata: $metadata,
);
}
/**
* Create a failed audit context.
*
* @param string $operationType The type of masking operation
* @param ErrorContext $error The error that occurred
* @param int $attemptNumber Which attempt this was
* @param float $durationMs Operation duration in milliseconds
* @param array<string, mixed> $metadata Additional context
*/
public static function failed(
string $operationType,
ErrorContext $error,
int $attemptNumber = 1,
float $durationMs = 0.0,
array $metadata = []
): self {
return new self(
operationType: $operationType,
status: self::STATUS_FAILED,
attemptNumber: $attemptNumber,
durationMs: $durationMs,
error: $error,
metadata: $metadata,
);
}
/**
* Create a recovered audit context (after retry/fallback).
*
* @param string $operationType The type of masking operation
* @param int $attemptNumber Final attempt number before success
* @param float $durationMs Total duration including retries
* @param array<string, mixed> $metadata Additional context
*/
public static function recovered(
string $operationType,
int $attemptNumber,
float $durationMs = 0.0,
array $metadata = []
): self {
return new self(
operationType: $operationType,
status: self::STATUS_RECOVERED,
attemptNumber: $attemptNumber,
durationMs: $durationMs,
metadata: $metadata,
);
}
/**
* Create a skipped audit context (conditional rule prevented masking).
*
* @param string $operationType The type of masking operation
* @param string $reason Why the operation was skipped
* @param array<string, mixed> $metadata Additional context
*/
public static function skipped(
string $operationType,
string $reason,
array $metadata = []
): self {
return new self(
operationType: $operationType,
status: self::STATUS_SKIPPED,
metadata: array_merge($metadata, ['skip_reason' => $reason]),
);
}
/**
* Create a copy with a correlation ID.
*/
public function withCorrelationId(string $correlationId): self
{
return new self(
operationType: $this->operationType,
status: $this->status,
correlationId: $correlationId,
attemptNumber: $this->attemptNumber,
durationMs: $this->durationMs,
error: $this->error,
metadata: $this->metadata,
);
}
/**
* Create a copy with additional metadata.
*
* @param array<string, mixed> $additionalMetadata
*/
public function withMetadata(array $additionalMetadata): self
{
return new self(
operationType: $this->operationType,
status: $this->status,
correlationId: $this->correlationId,
attemptNumber: $this->attemptNumber,
durationMs: $this->durationMs,
error: $this->error,
metadata: array_merge($this->metadata, $additionalMetadata),
);
}
/**
* Check if the operation succeeded.
*/
public function isSuccess(): bool
{
return $this->status === self::STATUS_SUCCESS
|| $this->status === self::STATUS_RECOVERED;
}
/**
* Convert to array for serialization/logging.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
$data = [
'operation_type' => $this->operationType,
'status' => $this->status,
'attempt_number' => $this->attemptNumber,
'duration_ms' => round($this->durationMs, 3),
];
if ($this->correlationId !== null) {
$data['correlation_id'] = $this->correlationId;
}
if ($this->error instanceof ErrorContext) {
$data['error'] = $this->error->toArray();
}
if ($this->metadata !== []) {
$data['metadata'] = $this->metadata;
}
return $data;
}
/**
* Generate a unique correlation ID for tracking related operations.
*/
public static function generateCorrelationId(): string
{
return bin2hex(random_bytes(8));
}
}

147
src/Audit/ErrorContext.php Normal file
View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Audit;
use Throwable;
/**
* Standardized error information for audit logging.
*
* Captures error details in a structured format while ensuring
* sensitive information is sanitized before logging.
*
* @api
*/
final readonly class ErrorContext
{
/**
* @param string $errorType The type/class of error that occurred
* @param string $message Sanitized error message (sensitive data removed)
* @param int $code Error code if available
* @param string|null $file File where error occurred (optional)
* @param int|null $line Line number where error occurred (optional)
* @param array<string, mixed> $metadata Additional error metadata
*/
public function __construct(
public string $errorType,
public string $message,
public int $code = 0,
public ?string $file = null,
public ?int $line = null,
public array $metadata = [],
) {
}
/**
* Create an ErrorContext from a Throwable.
*
* @param Throwable $throwable The exception/error to capture
* @param bool $includeSensitive Whether to include potentially sensitive details
*/
public static function fromThrowable(
Throwable $throwable,
bool $includeSensitive = false
): self {
$message = $includeSensitive
? $throwable->getMessage()
: self::sanitizeMessage($throwable->getMessage());
$metadata = [];
if ($includeSensitive) {
$metadata['trace'] = array_slice($throwable->getTrace(), 0, 5);
}
return new self(
errorType: $throwable::class,
message: $message,
code: (int) $throwable->getCode(),
file: $includeSensitive ? $throwable->getFile() : null,
line: $includeSensitive ? $throwable->getLine() : null,
metadata: $metadata,
);
}
/**
* Create an ErrorContext for a generic error.
*
* @param string $errorType The type of error
* @param string $message The error message
* @param array<string, mixed> $metadata Additional context
*/
public static function create(
string $errorType,
string $message,
array $metadata = []
): self {
return new self(
errorType: $errorType,
message: self::sanitizeMessage($message),
metadata: $metadata,
);
}
/**
* Sanitize an error message to remove potentially sensitive information.
*
* @param string $message The original error message
*/
private static function sanitizeMessage(string $message): string
{
$patterns = [
// Passwords and secrets
'/password[=:]\s*[^\s,;]+/i' => 'password=[REDACTED]',
'/secret[=:]\s*[^\s,;]+/i' => 'secret=[REDACTED]',
'/api[_-]?key[=:]\s*[^\s,;]+/i' => 'api_key=[REDACTED]',
'/token[=:]\s*[^\s,;]+/i' => 'token=[REDACTED]',
'/bearer\s+\S+/i' => 'bearer [REDACTED]',
// Connection strings
'/:[^@]+@/' => ':[REDACTED]@',
'/user[=:]\s*[^\s,;@]+/i' => 'user=[REDACTED]',
'/host[=:]\s*[^\s,;]+/i' => 'host=[REDACTED]',
// File paths (partial - keep filename)
'/\/(?:var|home|etc|usr|opt)\/[^\s:]+/' => '/[PATH_REDACTED]',
];
$sanitized = $message;
foreach ($patterns as $pattern => $replacement) {
$result = preg_replace($pattern, $replacement, $sanitized);
if ($result !== null) {
$sanitized = $result;
}
}
return $sanitized;
}
/**
* Convert to array for serialization/logging.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
$data = [
'error_type' => $this->errorType,
'message' => $this->message,
'code' => $this->code,
];
if ($this->file !== null) {
$data['file'] = $this->file;
}
if ($this->line !== null) {
$data['line'] = $this->line;
}
if ($this->metadata !== []) {
$data['metadata'] = $this->metadata;
}
return $data;
}
}

View File

@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Audit;
use Ivuorinen\MonologGdprFilter\RateLimitedAuditLogger;
/**
* Enhanced audit logger wrapper with structured context support.
*
* Wraps a base audit logger (callable or RateLimitedAuditLogger) and
* provides structured context information for better audit trails.
*
* @api
*/
final class StructuredAuditLogger
{
/** @var callable(string, mixed, mixed): void */
private $wrappedLogger;
/**
* @param callable|RateLimitedAuditLogger $auditLogger Base logger to wrap
* @param bool $includeTimestamp Whether to include timestamp in metadata
* @param bool $includeDuration Whether to include operation duration
*/
public function __construct(
callable|RateLimitedAuditLogger $auditLogger,
private readonly bool $includeTimestamp = true,
private readonly bool $includeDuration = true
) {
$this->wrappedLogger = $auditLogger;
}
/**
* Create a structured audit logger from a base logger.
*
* @param callable|RateLimitedAuditLogger $auditLogger Base logger
*/
public static function wrap(
callable|RateLimitedAuditLogger $auditLogger
): self {
return new self($auditLogger);
}
/**
* Log an audit entry with structured context.
*
* @param string $path The field path being masked
* @param mixed $original The original value
* @param mixed $masked The masked value
* @param AuditContext|null $context Structured audit context
*/
public function log(
string $path,
mixed $original,
mixed $masked,
?AuditContext $context = null
): void {
$enrichedContext = $context;
if ($enrichedContext instanceof AuditContext) {
$metadata = [];
if ($this->includeTimestamp) {
$metadata['timestamp'] = time();
$metadata['timestamp_micro'] = microtime(true);
}
if ($this->includeDuration && $enrichedContext->durationMs > 0) {
$metadata['duration_ms'] = $enrichedContext->durationMs;
}
if ($metadata !== []) {
$enrichedContext = $enrichedContext->withMetadata($metadata);
}
}
// Call the wrapped logger
// The wrapped logger may be a simple callable (3 params) or enhanced (4 params)
($this->wrappedLogger)($path, $original, $masked);
// If we have context and the wrapped logger doesn't handle it,
// we store it separately (could be extended to log to a separate channel)
if ($enrichedContext instanceof AuditContext) {
$this->logContext($path, $enrichedContext);
}
}
/**
* Log a success operation.
*
* @param string $path The field path
* @param mixed $original The original value
* @param mixed $masked The masked value
* @param string $operationType Type of masking operation
* @param float $durationMs Duration in milliseconds
*/
public function logSuccess(
string $path,
mixed $original,
mixed $masked,
string $operationType,
float $durationMs = 0.0
): void {
$context = AuditContext::success($operationType, $durationMs, [
'path' => $path,
]);
$this->log($path, $original, $masked, $context);
}
/**
* Log a failed operation.
*
* @param string $path The field path
* @param mixed $original The original value
* @param string $operationType Type of masking operation
* @param ErrorContext $error Error information
* @param int $attemptNumber Which attempt failed
*/
public function logFailure(
string $path,
mixed $original,
string $operationType,
ErrorContext $error,
int $attemptNumber = 1
): void {
$context = AuditContext::failed(
$operationType,
$error,
$attemptNumber,
0.0,
['path' => $path]
);
// For failures, the "masked" value indicates the failure
$this->log($path, $original, '[MASKING_FAILED]', $context);
}
/**
* Log a recovered operation (after retry/fallback).
*
* @param string $path The field path
* @param mixed $original The original value
* @param mixed $masked The masked value (from recovery)
* @param string $operationType Type of masking operation
* @param int $attemptNumber Final successful attempt number
* @param float $totalDurationMs Total duration including retries
*/
public function logRecovery(
string $path,
mixed $original,
mixed $masked,
string $operationType,
int $attemptNumber,
float $totalDurationMs = 0.0
): void {
$context = AuditContext::recovered(
$operationType,
$attemptNumber,
$totalDurationMs,
['path' => $path]
);
$this->log($path, $original, $masked, $context);
}
/**
* Log a skipped operation.
*
* @param string $path The field path
* @param mixed $value The value that was not masked
* @param string $operationType Type of masking operation
* @param string $reason Why masking was skipped
*/
public function logSkipped(
string $path,
mixed $value,
string $operationType,
string $reason
): void {
$context = AuditContext::skipped($operationType, $reason, [
'path' => $path,
]);
$this->log($path, $value, $value, $context);
}
/**
* Start timing an operation.
*
* @return float Start time in microseconds
*/
public function startTimer(): float
{
return microtime(true);
}
/**
* Calculate elapsed time since start.
*
* @param float $startTime From startTimer()
* @return float Duration in milliseconds
*/
public function elapsed(float $startTime): float
{
return (microtime(true) - $startTime) * 1000.0;
}
/**
* Log structured context (for extended audit trails).
*
* Override this method to send context to a separate logging channel.
*/
protected function logContext(string $path, AuditContext $context): void
{
// Default implementation does nothing extra
// Subclasses can override to log to a separate channel
unset($path, $context);
}
/**
* Get the wrapped logger for direct access if needed.
*
* @return callable
*/
public function getWrappedLogger(): callable
{
return $this->wrappedLogger;
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Builder;
use Ivuorinen\MonologGdprFilter\ArrayAccessor\ArrayAccessorFactory;
use Ivuorinen\MonologGdprFilter\Builder\Traits\CallbackConfigurationTrait;
use Ivuorinen\MonologGdprFilter\Builder\Traits\FieldPathConfigurationTrait;
use Ivuorinen\MonologGdprFilter\Builder\Traits\PatternConfigurationTrait;
use Ivuorinen\MonologGdprFilter\Builder\Traits\PluginConfigurationTrait;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
/**
* Fluent builder for GdprProcessor configuration.
*
* Provides a clean, chainable API for configuring GdprProcessor instances
* with support for plugins, patterns, field paths, and callbacks.
*
* @api
*/
final class GdprProcessorBuilder
{
use PatternConfigurationTrait;
use FieldPathConfigurationTrait;
use CallbackConfigurationTrait;
use PluginConfigurationTrait;
/**
* @var callable(string,mixed,mixed):void|null
*/
private $auditLogger = null;
private int $maxDepth = 100;
private ?ArrayAccessorFactory $arrayAccessorFactory = null;
/**
* Create a new builder instance.
*/
public static function create(): self
{
return new self();
}
/**
* Set the audit logger.
*
* @param callable(string,mixed,mixed):void $auditLogger Audit logger callback
*/
public function withAuditLogger(callable $auditLogger): self
{
$this->auditLogger = $auditLogger;
return $this;
}
/**
* Set the maximum recursion depth.
*/
public function withMaxDepth(int $maxDepth): self
{
$this->maxDepth = $maxDepth;
return $this;
}
/**
* Set the array accessor factory.
*/
public function withArrayAccessorFactory(ArrayAccessorFactory $factory): self
{
$this->arrayAccessorFactory = $factory;
return $this;
}
/**
* Build the GdprProcessor with all configured options.
*
* @throws \InvalidArgumentException When configuration is invalid
*/
public function build(): GdprProcessor
{
// Apply plugin configurations
$this->applyPluginConfigurations();
return new GdprProcessor(
$this->patterns,
$this->fieldPaths,
$this->customCallbacks,
$this->auditLogger,
$this->maxDepth,
$this->dataTypeMasks,
$this->conditionalRules,
$this->arrayAccessorFactory
);
}
/**
* Build a GdprProcessor wrapped with plugin hooks.
*
* Returns a PluginAwareProcessor if plugins are registered,
* otherwise returns a standard GdprProcessor.
*
* @throws \InvalidArgumentException When configuration is invalid
*/
public function buildWithPlugins(): GdprProcessor|PluginAwareProcessor
{
$processor = $this->build();
if ($this->plugins === []) {
return $processor;
}
// Sort plugins by priority
usort($this->plugins, fn($a, $b): int => $a->getPriority() <=> $b->getPriority());
return new PluginAwareProcessor($processor, $this->plugins);
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Builder;
use Ivuorinen\MonologGdprFilter\Contracts\MaskingPluginInterface;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;
/**
* Wrapper that adds plugin hook support to GdprProcessor.
*
* Executes plugin pre/post processing hooks around the standard
* GdprProcessor masking operations.
*
* @api
*/
final class PluginAwareProcessor implements ProcessorInterface
{
/**
* @param GdprProcessor $processor The underlying processor
* @param list<MaskingPluginInterface> $plugins Registered plugins (sorted by priority)
*/
public function __construct(
private readonly GdprProcessor $processor,
private readonly array $plugins
) {
}
/**
* Process a log record with plugin hooks.
*
* @param LogRecord $record The log record to process
* @return LogRecord The processed log record
*/
#[\Override]
public function __invoke(LogRecord $record): LogRecord
{
// Pre-process message through plugins
$message = $record->message;
foreach ($this->plugins as $plugin) {
$message = $plugin->preProcessMessage($message);
}
// Pre-process context through plugins
$context = $record->context;
foreach ($this->plugins as $plugin) {
$context = $plugin->preProcessContext($context);
}
// Create modified record for main processor
$modifiedRecord = $record->with(message: $message, context: $context);
// Apply main processor
$processedRecord = ($this->processor)($modifiedRecord);
// Post-process message through plugins (reverse order)
$message = $processedRecord->message;
foreach (array_reverse($this->plugins) as $plugin) {
$message = $plugin->postProcessMessage($message);
}
// Post-process context through plugins (reverse order)
$context = $processedRecord->context;
foreach (array_reverse($this->plugins) as $plugin) {
$context = $plugin->postProcessContext($context);
}
return $processedRecord->with(message: $message, context: $context);
}
/**
* Get the underlying GdprProcessor.
*/
public function getProcessor(): GdprProcessor
{
return $this->processor;
}
/**
* Get registered plugins.
*
* @return list<MaskingPluginInterface>
*/
public function getPlugins(): array
{
return $this->plugins;
}
/**
* Delegate regExpMessage to underlying processor.
*/
public function regExpMessage(string $message = ''): string
{
return $this->processor->regExpMessage($message);
}
/**
* Delegate recursiveMask to underlying processor.
*
* @param array<mixed>|string $data
* @param int $currentDepth
* @return array<mixed>|string
*/
public function recursiveMask(array|string $data, int $currentDepth = 0): array|string
{
return $this->processor->recursiveMask($data, $currentDepth);
}
/**
* Delegate setAuditLogger to underlying processor.
*
* @param callable(string,mixed,mixed):void|null $auditLogger
*/
public function setAuditLogger(?callable $auditLogger): void
{
$this->processor->setAuditLogger($auditLogger);
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Builder\Traits;
use Monolog\LogRecord;
/**
* Provides callback configuration methods for GdprProcessorBuilder.
*
* Handles custom callbacks, data type masks, and conditional masking rules
* for advanced masking scenarios.
*/
trait CallbackConfigurationTrait
{
/**
* @var array<string,callable(mixed):string>
*/
private array $customCallbacks = [];
/**
* @var array<string,string>
*/
private array $dataTypeMasks = [];
/**
* @var array<string,callable(LogRecord):bool>
*/
private array $conditionalRules = [];
/**
* Add a custom callback for a field path.
*
* @param string $path Dot-notation path
* @param callable(mixed):string $callback Transformation callback
*/
public function addCallback(string $path, callable $callback): self
{
$this->customCallbacks[$path] = $callback;
return $this;
}
/**
* Add multiple custom callbacks.
*
* @param array<string,callable(mixed):string> $callbacks Path => callback
*/
public function addCallbacks(array $callbacks): self
{
$this->customCallbacks = array_merge($this->customCallbacks, $callbacks);
return $this;
}
/**
* Add a data type mask.
*
* @param string $type Data type (e.g., 'integer', 'double', 'boolean')
* @param string $mask Replacement mask
*/
public function addDataTypeMask(string $type, string $mask): self
{
$this->dataTypeMasks[$type] = $mask;
return $this;
}
/**
* Add multiple data type masks.
*
* @param array<string,string> $masks Type => mask
*/
public function addDataTypeMasks(array $masks): self
{
$this->dataTypeMasks = array_merge($this->dataTypeMasks, $masks);
return $this;
}
/**
* Add a conditional masking rule.
*
* @param string $name Rule name
* @param callable(LogRecord):bool $condition Condition callback
*/
public function addConditionalRule(string $name, callable $condition): self
{
$this->conditionalRules[$name] = $condition;
return $this;
}
/**
* Add multiple conditional rules.
*
* @param array<string,callable(LogRecord):bool> $rules Name => condition
*/
public function addConditionalRules(array $rules): self
{
$this->conditionalRules = array_merge($this->conditionalRules, $rules);
return $this;
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Builder\Traits;
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
/**
* Provides field path configuration methods for GdprProcessorBuilder.
*
* Handles field path management for masking specific fields in log context
* using dot notation (e.g., "user.email").
*/
trait FieldPathConfigurationTrait
{
/**
* @var array<string,FieldMaskConfig|string>
*/
private array $fieldPaths = [];
/**
* Add a field path to mask.
*
* @param string $path Dot-notation path
* @param FieldMaskConfig|string $config Mask configuration or replacement string
*/
public function addFieldPath(string $path, FieldMaskConfig|string $config): self
{
$this->fieldPaths[$path] = $config;
return $this;
}
/**
* Add multiple field paths.
*
* @param array<string,FieldMaskConfig|string> $fieldPaths Path => config
*/
public function addFieldPaths(array $fieldPaths): self
{
$this->fieldPaths = array_merge($this->fieldPaths, $fieldPaths);
return $this;
}
/**
* Set all field paths (replaces existing).
*
* @param array<string,FieldMaskConfig|string> $fieldPaths Path => config
*/
public function setFieldPaths(array $fieldPaths): self
{
$this->fieldPaths = $fieldPaths;
return $this;
}
/**
* Get the current field paths configuration.
*
* @return array<string,FieldMaskConfig|string>
*/
public function getFieldPaths(): array
{
return $this->fieldPaths;
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Builder\Traits;
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
/**
* Provides pattern configuration methods for GdprProcessorBuilder.
*
* Handles regex pattern management including adding, setting, and retrieving patterns
* used for masking sensitive data in log records.
*/
trait PatternConfigurationTrait
{
/**
* @var array<string,string>
*/
private array $patterns = [];
/**
* Add a regex pattern.
*
* @param string $pattern Regex pattern
* @param string $replacement Replacement string
*/
public function addPattern(string $pattern, string $replacement): self
{
$this->patterns[$pattern] = $replacement;
return $this;
}
/**
* Add multiple patterns.
*
* @param array<string,string> $patterns Regex pattern => replacement
*/
public function addPatterns(array $patterns): self
{
$this->patterns = array_merge($this->patterns, $patterns);
return $this;
}
/**
* Set all patterns (replaces existing).
*
* @param array<string,string> $patterns Regex pattern => replacement
*/
public function setPatterns(array $patterns): self
{
$this->patterns = $patterns;
return $this;
}
/**
* Get the current patterns configuration.
*
* @return array<string,string>
*/
public function getPatterns(): array
{
return $this->patterns;
}
/**
* Start with default GDPR patterns.
*/
public function withDefaultPatterns(): self
{
$this->patterns = array_merge($this->patterns, DefaultPatterns::get());
return $this;
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Builder\Traits;
use Ivuorinen\MonologGdprFilter\Contracts\MaskingPluginInterface;
/**
* Provides plugin configuration methods for GdprProcessorBuilder.
*
* Handles registration and management of masking plugins that can extend
* the processor's functionality with custom patterns and field paths.
*/
trait PluginConfigurationTrait
{
/**
* @var list<MaskingPluginInterface>
*/
private array $plugins = [];
/**
* Register a masking plugin.
*/
public function addPlugin(MaskingPluginInterface $plugin): self
{
$this->plugins[] = $plugin;
return $this;
}
/**
* Register multiple masking plugins.
*
* @param list<MaskingPluginInterface> $plugins
*/
public function addPlugins(array $plugins): self
{
foreach ($plugins as $plugin) {
$this->plugins[] = $plugin;
}
return $this;
}
/**
* Get registered plugins.
*
* @return list<MaskingPluginInterface>
*/
public function getPlugins(): array
{
return $this->plugins;
}
/**
* Apply plugin patterns and field paths to the builder configuration.
*/
private function applyPluginConfigurations(): void
{
// Sort plugins by priority before applying
usort($this->plugins, fn($a, $b): int => $a->getPriority() <=> $b->getPriority());
foreach ($this->plugins as $plugin) {
$this->patterns = array_merge($this->patterns, $plugin->getPatterns());
$this->fieldPaths = array_merge($this->fieldPaths, $plugin->getFieldPaths());
}
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter;
use Ivuorinen\MonologGdprFilter\ArrayAccessor\ArrayAccessorFactory;
use Closure;
use Monolog\LogRecord;
/**
* Factory for creating conditional masking rules.
*
* This class provides methods to create various types of
* conditional rules that determine when masking should be applied.
*
* Can be used as an instance (for DI) or via static methods (backward compatible).
*/
final class ConditionalRuleFactory
{
private readonly ArrayAccessorFactory $accessorFactory;
public function __construct(?ArrayAccessorFactory $accessorFactory = null)
{
$this->accessorFactory = $accessorFactory ?? ArrayAccessorFactory::default();
}
/**
* Create a conditional rule based on log level.
*
* @param array<string> $levels Log levels that should trigger masking
*
* @psalm-return Closure(LogRecord):bool
*/
public static function createLevelBasedRule(array $levels): Closure
{
return fn(LogRecord $record): bool => in_array($record->level->name, $levels, true);
}
/**
* Create a conditional rule based on context field presence.
*
* @param string $fieldPath Dot-notation path to check
*
* @psalm-return Closure(LogRecord):bool
*/
public static function createContextFieldRule(string $fieldPath): Closure
{
$factory = ArrayAccessorFactory::default();
return function (LogRecord $record) use ($fieldPath, $factory): bool {
$accessor = $factory->create($record->context);
return $accessor->has($fieldPath);
};
}
/**
* Create a conditional rule based on context field value.
*
* @param string $fieldPath Dot-notation path to check
* @param mixed $expectedValue Expected value
*
* @psalm-return Closure(LogRecord):bool
*/
public static function createContextValueRule(string $fieldPath, mixed $expectedValue): Closure
{
$factory = ArrayAccessorFactory::default();
return function (LogRecord $record) use ($fieldPath, $expectedValue, $factory): bool {
$accessor = $factory->create($record->context);
return $accessor->get($fieldPath) === $expectedValue;
};
}
/**
* Create a conditional rule based on channel name.
*
* @param array<string> $channels Channel names that should trigger masking
*
* @psalm-return Closure(LogRecord):bool
*/
public static function createChannelBasedRule(array $channels): Closure
{
return fn(LogRecord $record): bool => in_array($record->channel, $channels, true);
}
/**
* Instance method: Create a context field presence rule.
*
* @param string $fieldPath Dot-notation path to check
*
* @psalm-return Closure(LogRecord):bool
*/
public function contextFieldRule(string $fieldPath): Closure
{
$factory = $this->accessorFactory;
return function (LogRecord $record) use ($fieldPath, $factory): bool {
$accessor = $factory->create($record->context);
return $accessor->has($fieldPath);
};
}
/**
* Instance method: Create a context field value rule.
*
* @param string $fieldPath Dot-notation path to check
* @param mixed $expectedValue Expected value
*
* @psalm-return Closure(LogRecord):bool
*/
public function contextValueRule(string $fieldPath, mixed $expectedValue): Closure
{
$factory = $this->accessorFactory;
return function (LogRecord $record) use ($fieldPath, $expectedValue, $factory): bool {
$accessor = $factory->create($record->context);
return $accessor->get($fieldPath) === $expectedValue;
};
}
}

171
src/ContextProcessor.php Normal file
View File

@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter;
use Ivuorinen\MonologGdprFilter\Contracts\ArrayAccessorInterface;
use Throwable;
/**
* Handles context field processing operations for GDPR masking.
*
* This class extracts field-level masking logic from GdprProcessor
* to reduce the main class's method count and improve separation of concerns.
*
* @internal This class is for internal use within the GDPR processor
*/
class ContextProcessor
{
/**
* @param array<string,FieldMaskConfig|string> $fieldPaths Dot-notation path => FieldMaskConfig
* @param array<string,callable(mixed):string> $customCallbacks Dot-notation path => callback
* @param callable(string,mixed,mixed):void|null $auditLogger Optional audit logger callback
* @param \Closure(string):string $regexProcessor Function to process strings with regex patterns
*/
public function __construct(
private readonly array $fieldPaths,
private readonly array $customCallbacks,
private $auditLogger,
private readonly \Closure $regexProcessor
) {
}
/**
* Mask field paths in the context using the configured field masks.
*
* @param ArrayAccessorInterface $accessor
* @return string[] Array of processed field paths
* @psalm-return list<string>
*/
public function maskFieldPaths(ArrayAccessorInterface $accessor): array
{
$processedFields = [];
foreach ($this->fieldPaths as $path => $config) {
if (!$accessor->has($path)) {
continue;
}
$value = $accessor->get($path, "");
$action = $this->maskValue($path, $value, $config);
if ($action['remove'] ?? false) {
$accessor->delete($path);
$this->logAudit($path, $value, null);
$processedFields[] = $path;
continue;
}
$masked = $action['masked'];
if ($masked !== null && $masked !== $value) {
$accessor->set($path, $masked);
$this->logAudit($path, $value, $masked);
}
$processedFields[] = $path;
}
return $processedFields;
}
/**
* Process custom callbacks on context fields.
*
* @param ArrayAccessorInterface $accessor
* @return string[] Array of processed field paths
* @psalm-return list<string>
*/
public function processCustomCallbacks(ArrayAccessorInterface $accessor): array
{
$processedFields = [];
foreach ($this->customCallbacks as $path => $callback) {
if (!$accessor->has($path)) {
continue;
}
$value = $accessor->get($path);
try {
$masked = $callback($value);
if ($masked !== $value) {
$accessor->set($path, $masked);
$this->logAudit($path, $value, $masked);
}
$processedFields[] = $path;
} catch (Throwable $e) {
// Log callback error but continue processing
$sanitized = SecuritySanitizer::sanitizeErrorMessage($e->getMessage());
$errorMsg = 'Callback failed: ' . $sanitized;
$this->logAudit($path . '_callback_error', $value, $errorMsg);
$processedFields[] = $path;
}
}
return $processedFields;
}
/**
* Mask a single value according to config or callback.
* Returns an array: ['masked' => value|null, 'remove' => bool]
*
* @psalm-return array{masked: mixed, remove: bool}
* @psalm-param mixed $value
*/
public function maskValue(string $path, mixed $value, FieldMaskConfig|string|null $config): array
{
$result = ['masked' => null, 'remove' => false];
if (array_key_exists($path, $this->customCallbacks)) {
$callback = $this->customCallbacks[$path];
$result['masked'] = $callback($value);
return $result;
}
if ($config instanceof FieldMaskConfig) {
switch ($config->type) {
case FieldMaskConfig::MASK_REGEX:
$result['masked'] = ($this->regexProcessor)((string) $value);
break;
case FieldMaskConfig::REMOVE:
$result['masked'] = null;
$result['remove'] = true;
break;
case FieldMaskConfig::REPLACE:
$result['masked'] = $config->replacement;
break;
default:
// Return the type as string for unknown types
$result['masked'] = $config->type;
break;
}
} else {
// Backward compatibility: treat string as replacement
$result['masked'] = $config;
}
return $result;
}
/**
* Audit logger helper.
*
* @param string $path Dot-notation path of the field
* @param mixed $original Original value before masking
* @param null|string $masked Masked value after processing, or null if removed
*/
public function logAudit(string $path, mixed $original, string|null $masked): void
{
if (is_callable($this->auditLogger) && $original !== $masked) {
// Only log if the value was actually changed
call_user_func($this->auditLogger, $path, $original, $masked);
}
}
/**
* Set the audit logger callable.
*
* @param callable(string,mixed,mixed):void|null $auditLogger
*/
public function setAuditLogger(?callable $auditLogger): void
{
$this->auditLogger = $auditLogger;
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Contracts;
/**
* Interface for dot-notation array access.
*
* This abstraction allows swapping the underlying implementation
* (e.g., Adbar\Dot) without modifying consuming code.
*
* @api
*/
interface ArrayAccessorInterface
{
/**
* Check if a key exists using dot notation.
*
* @param string $path Dot-notation path (e.g., "user.email")
*/
public function has(string $path): bool;
/**
* Get a value using dot notation.
*
* @param string $path Dot-notation path (e.g., "user.email")
* @param mixed $default Default value if path doesn't exist
* @return mixed The value at the path or default
*/
public function get(string $path, mixed $default = null): mixed;
/**
* Set a value using dot notation.
*
* @param string $path Dot-notation path (e.g., "user.email")
* @param mixed $value Value to set
*/
public function set(string $path, mixed $value): void;
/**
* Delete a value using dot notation.
*
* @param string $path Dot-notation path (e.g., "user.email")
*/
public function delete(string $path): void;
/**
* Get all data as an array.
*
* @return array<string, mixed> The complete data array
*/
public function all(): array;
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Contracts;
/**
* Interface for masking plugins that can extend GdprProcessor functionality.
*
* Plugins can hook into the masking process at various points to add
* custom masking logic, transformations, or integrations.
*
* @api
*/
interface MaskingPluginInterface
{
/**
* Get the unique plugin identifier.
*/
public function getName(): string;
/**
* Process context data before standard masking is applied.
*
* @param array<string,mixed> $context The context data
* @return array<string,mixed> The modified context data
*/
public function preProcessContext(array $context): array;
/**
* Process context data after standard masking is applied.
*
* @param array<string,mixed> $context The masked context data
* @return array<string,mixed> The modified context data
*/
public function postProcessContext(array $context): array;
/**
* Process message before standard masking is applied.
*
* @param string $message The original message
* @return string The modified message
*/
public function preProcessMessage(string $message): string;
/**
* Process message after standard masking is applied.
*
* @param string $message The masked message
* @return string The modified message
*/
public function postProcessMessage(string $message): string;
/**
* Get additional patterns to add to the processor.
*
* @return array<string,string> Regex pattern => replacement
*/
public function getPatterns(): array;
/**
* Get additional field paths to mask.
*
* @return array<string,\Ivuorinen\MonologGdprFilter\FieldMaskConfig|string>
*/
public function getFieldPaths(): array;
/**
* Get the plugin's priority (lower = earlier execution).
*/
public function getPriority(): int;
}

195
src/DataTypeMasker.php Normal file
View File

@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter;
use Ivuorinen\MonologGdprFilter\MaskConstants as Mask;
/**
* Handles data type-based masking of values.
*
* This class applies masking based on PHP data types
* according to configured masking rules.
*/
final class DataTypeMasker
{
/**
* @param array<string,string> $dataTypeMasks Type-based masking: type => mask pattern
* @param callable(string, mixed, mixed):void|null $auditLogger
*/
public function __construct(
private readonly array $dataTypeMasks,
private $auditLogger = null
) {}
/**
* Get default data type masking configuration.
*
* @return string[]
*
* @psalm-return array{
* integer: '***INT***',
* double: '***FLOAT***',
* string: '***STRING***',
* boolean: '***BOOL***',
* NULL: '***NULL***',
* array: '***ARRAY***',
* object: '***OBJECT***',
* resource: '***RESOURCE***'
* }
*/
public static function getDefaultMasks(): array
{
return [
'integer' => Mask::MASK_INT,
'double' => Mask::MASK_FLOAT,
'string' => Mask::MASK_STRING,
'boolean' => Mask::MASK_BOOL,
'NULL' => Mask::MASK_NULL,
'array' => Mask::MASK_ARRAY,
'object' => Mask::MASK_OBJECT,
'resource' => Mask::MASK_RESOURCE,
];
}
/**
* Apply data type-based masking to a value.
*
* @param mixed $value The value to mask.
* @param (callable(array<mixed>|string, int=):(array<mixed>|string))|null $recursiveMaskCallback
* @return mixed The masked value.
*
* @psalm-param mixed $value The value to mask.
*/
public function applyMasking(mixed $value, ?callable $recursiveMaskCallback = null): mixed
{
if ($this->dataTypeMasks === []) {
return $value;
}
$type = gettype($value);
if (!isset($this->dataTypeMasks[$type])) {
return $value;
}
$mask = $this->dataTypeMasks[$type];
// Special handling for different types
return match ($type) {
'integer' => is_numeric($mask) ? (int)$mask : $mask,
'double' => is_numeric($mask) ? (float)$mask : $mask,
'boolean' => $this->maskBoolean($mask, $value),
'NULL' => $mask === 'preserve' ? null : $mask,
'array' => $this->maskArray($mask, $value, $recursiveMaskCallback),
'object' => (object) ['masked' => $mask, 'original_class' => $value::class],
default => $mask,
};
}
/**
* Mask a boolean value.
*/
private function maskBoolean(string $mask, bool $value): bool|string
{
if ($mask === 'preserve') {
return $value;
}
if ($mask === 'true') {
return true;
}
if ($mask === 'false') {
return false;
}
return $mask;
}
/**
* Mask an array value.
*
* @param array<mixed> $value
* @param (callable(array<mixed>|string, int=):(array<mixed>|string))|null $recursiveMaskCallback
* @return array<mixed>|string
*/
private function maskArray(string $mask, array $value, ?callable $recursiveMaskCallback): array|string
{
// For arrays, we can return a masked indicator or process recursively
if ($mask === 'recursive' && $recursiveMaskCallback !== null) {
return $recursiveMaskCallback($value, 0);
}
return [$mask];
}
/**
* Apply data type masking to an entire context structure.
*
* @param array<mixed> $context
* @param array<string> $processedFields Array of field paths already processed
* @param string $currentPath Current dot-notation path for nested processing
* @param (callable(array<mixed>|string, int=):(array<mixed>|string))|null $recursiveMaskCallback
* @return array<mixed>
*/
public function applyToContext(
array $context,
array $processedFields = [],
string $currentPath = '',
?callable $recursiveMaskCallback = null
): array {
$result = [];
foreach ($context as $key => $value) {
$fieldPath = $currentPath === '' ? (string)$key : $currentPath . '.' . $key;
// Skip fields that have already been processed by field paths or custom callbacks
if (in_array($fieldPath, $processedFields, true)) {
$result[$key] = $value;
continue;
}
$result[$key] = $this->processFieldValue(
$value,
$fieldPath,
$processedFields,
$recursiveMaskCallback
);
}
return $result;
}
/**
* Process a single field value, applying masking if applicable.
*
* @param mixed $value
* @param string $fieldPath
* @param array<string> $processedFields
* @param (callable(array<mixed>|string, int=):(array<mixed>|string))|null $recursiveMaskCallback
* @return mixed
*/
private function processFieldValue(
mixed $value,
string $fieldPath,
array $processedFields,
?callable $recursiveMaskCallback
): mixed {
if (is_array($value)) {
return $this->applyToContext($value, $processedFields, $fieldPath, $recursiveMaskCallback);
}
$type = gettype($value);
if (!isset($this->dataTypeMasks[$type])) {
return $value;
}
$masked = $this->applyMasking($value, $recursiveMaskCallback);
if ($masked !== $value && $this->auditLogger !== null) {
($this->auditLogger)($fieldPath, $value, $masked);
}
return $masked;
}
}

81
src/DefaultPatterns.php Normal file
View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter;
use Ivuorinen\MonologGdprFilter\MaskConstants as Mask;
/**
* Provides default GDPR regex patterns for common sensitive data types.
*/
final class DefaultPatterns
{
/**
* Get default GDPR regex patterns. Non-exhaustive, should be extended with your own.
*
* @return array<string, string>
*/
public static function get(): array
{
return [
// Finnish SSN (HETU)
'/\b\d{6}[-+A]?\d{3}[A-Z]\b/u' => Mask::MASK_HETU,
// US Social Security Number (strict: 3-2-4 digits)
'/^\d{3}-\d{2}-\d{4}$/' => Mask::MASK_USSSN,
// IBAN (strictly match Finnish IBAN with or without spaces, only valid groupings)
'/^FI\d{2}(?: ?\d{4}){3} ?\d{2}$/u' => Mask::MASK_IBAN,
// Also match fully compact Finnish IBAN (no spaces)
'/^FI\d{16}$/u' => Mask::MASK_IBAN,
// International phone numbers (E.164, +countrycode...)
'/^\+\d{1,3}[\s-]?\d{1,4}[\s-]?\d{1,4}[\s-]?\d{1,9}$/' => Mask::MASK_PHONE,
// Email address
'/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/' => Mask::MASK_EMAIL,
// Date of birth (YYYY-MM-DD)
'/^(19|20)\d{2}-[01]\d\-[0-3]\d$/' => Mask::MASK_DOB,
// Date of birth (DD/MM/YYYY)
'/^[0-3]\d\/[01]\d\/(19|20)\d{2}$/' => Mask::MASK_DOB,
// Passport numbers (A followed by 6 digits)
'/^A\d{6}$/' => Mask::MASK_PASSPORT,
// Credit card numbers (Visa, MC, Amex, Discover test numbers)
'/^(4111 1111 1111 1111|5500-0000-0000-0004|340000000000009|6011000000000004)$/' => Mask::MASK_CC,
// Generic 16-digit credit card (for test compatibility)
'/\b[0-9]{16}\b/u' => Mask::MASK_CC,
// Bearer tokens (JWT, at least 10 chars after Bearer)
'/^Bearer [A-Za-z0-9\-\._~\+\/]{10,}$/' => Mask::MASK_TOKEN,
// API keys (Stripe-like, 20+ chars, or sk_live|sk_test)
'/^(sk_(live|test)_[A-Za-z0-9]{16,}|[A-Za-z0-9\-_]{20,})$/' => Mask::MASK_APIKEY,
// MAC addresses
'/^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/' => Mask::MASK_MAC,
// IP Addresses
// IPv4 address (dotted decimal notation)
'/\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b/' => '***IPv4***',
// Vehicle Registration Numbers (more specific patterns)
// US License plates (specific formats: ABC-1234, ABC1234)
'/\b[A-Z]{2,3}[-\s]?\d{3,4}\b/' => Mask::MASK_VEHICLE,
// Reverse format (123-ABC)
'/\b\d{3,4}[-\s]?[A-Z]{2,3}\b/' => Mask::MASK_VEHICLE,
// National ID Numbers
// UK National Insurance Number (2 letters, 6 digits, 1 letter)
'/\b[A-Z]{2}\d{6}[A-Z]\b/' => Mask::MASK_UKNI,
// Canadian Social Insurance Number (3-3-3 format)
'/\b\d{3}[-\s]\d{3}[-\s]\d{3}\b/' => Mask::MASK_CASIN,
// UK Sort Code + Account (6 digits + 8 digits)
'/\b\d{6}[-\s]\d{8}\b/' => Mask::MASK_UKBANK,
// Canadian Transit + Account (5 digits + 7-12 digits)
'/\b\d{5}[-\s]\d{7,12}\b/' => Mask::MASK_CABANK,
// Health Insurance Numbers
// US Medicare number (various formats)
'/\b\d{3}[-\s]\d{2}[-\s]\d{4}\b/' => Mask::MASK_MEDICARE,
// European Health Insurance Card (starts with country code)
'/\b\d{2}[-\s]\d{4}[-\s]\d{4}[-\s]\d{4}[-\s]\d{1,4}\b/' => Mask::MASK_EHIC,
// IPv6 address (specific pattern with colons)
'/\b[0-9a-fA-F]{1,4}:[0-9a-fA-F:]{7,35}\b/' => '***IPv6***',
];
}
}

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Exceptions;
use Throwable;
/**
* Exception thrown when audit logging operations fail.
*
* This exception is thrown when:
* - An audit logger callback throws an exception
* - Audit log data cannot be serialized
* - Rate-limited audit logging encounters errors
* - Audit logger configuration is invalid
*
* @api
*/
class AuditLoggingException extends GdprProcessorException
{
/**
* Create an exception for a failed audit logging callback.
*
* @param string $path The field path being audited
* @param mixed $original The original value
* @param mixed $masked The masked value
* @param string $reason The reason for the failure
* @param Throwable|null $previous Previous exception for chaining
*/
public static function callbackFailed(
string $path,
mixed $original,
mixed $masked,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf("Audit logging callback failed for path '%s': %s", $path, $reason);
return self::withContext($message, [
'audit_type' => 'callback_failure',
'path' => $path,
'original_type' => gettype($original),
'masked_type' => gettype($masked),
'original_preview' => self::getValuePreview($original),
'masked_preview' => self::getValuePreview($masked),
'reason' => $reason,
], 0, $previous);
}
/**
* Create an exception for audit data serialization failure.
*
* @param string $path The field path being audited
* @param mixed $value The value that failed to serialize
* @param string $reason The reason for the serialization failure
* @param Throwable|null $previous Previous exception for chaining
*/
public static function serializationFailed(
string $path,
mixed $value,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf("Audit data serialization failed for path '%s': %s", $path, $reason);
return self::withContext($message, [
'audit_type' => 'serialization_failure',
'path' => $path,
'value_type' => gettype($value),
'reason' => $reason,
], 0, $previous);
}
/**
* Create an exception for rate-limited audit logging failures.
*
* @param string $operationType The operation type being rate limited
* @param int $currentRequests Current number of requests
* @param int $maxRequests Maximum allowed requests
* @param string $reason The reason for the failure
* @param Throwable|null $previous Previous exception for chaining
*/
public static function rateLimitingFailed(
string $operationType,
int $currentRequests,
int $maxRequests,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf("Rate-limited audit logging failed for operation '%s': %s", $operationType, $reason);
return self::withContext($message, [
'audit_type' => 'rate_limiting_failure',
'operation_type' => $operationType,
'current_requests' => $currentRequests,
'max_requests' => $maxRequests,
'reason' => $reason,
], 0, $previous);
}
/**
* Create an exception for invalid audit logger configuration.
*
* @param string $configurationIssue Description of the configuration issue
* @param array<string, mixed> $config The invalid configuration
* @param Throwable|null $previous Previous exception for chaining
*/
public static function invalidConfiguration(
string $configurationIssue,
array $config,
?Throwable $previous = null
): static {
$message = 'Invalid audit logger configuration: ' . $configurationIssue;
return self::withContext($message, [
'audit_type' => 'configuration_error',
'configuration_issue' => $configurationIssue,
'config' => $config,
], 0, $previous);
}
/**
* Create an exception for audit logger creation failure.
*
* @param string $loggerType The type of logger being created
* @param string $reason The reason for the creation failure
* @param Throwable|null $previous Previous exception for chaining
*/
public static function loggerCreationFailed(
string $loggerType,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf("Audit logger creation failed for type '%s': %s", $loggerType, $reason);
return self::withContext($message, [
'audit_type' => 'logger_creation_failure',
'logger_type' => $loggerType,
'reason' => $reason,
], 0, $previous);
}
/**
* Get a safe preview of a value for logging.
*
* @param mixed $value The value to preview
* @return string Safe preview string
*/
private static function getValuePreview(mixed $value): string
{
if (is_string($value)) {
return substr($value, 0, 100) . (strlen($value) > 100 ? '...' : '');
}
if (is_array($value) || is_object($value)) {
$json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR);
if ($json === false) {
return '[Unable to serialize]';
}
return substr($json, 0, 100) . (strlen($json) > 100 ? '...' : '');
}
return (string) $value;
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Exceptions;
use Throwable;
/**
* Exception thrown when command execution fails.
*
* This exception is thrown when:
* - Artisan commands encounter runtime errors
* - Command input validation fails
* - Command operations fail during execution
* - Command result processing fails
* - File operations within commands fail
*
* @api
*/
class CommandExecutionException extends GdprProcessorException
{
/**
* Create an exception for command input validation failure.
*
* @param string $commandName The command that failed
* @param string $inputName The input parameter that failed validation
* @param mixed $inputValue The invalid input value
* @param string $reason The reason for validation failure
* @param Throwable|null $previous Previous exception for chaining
*/
public static function forInvalidInput(
string $commandName,
string $inputName,
mixed $inputValue,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf(
"Command '%s' failed: invalid input '%s' - %s",
$commandName,
$inputName,
$reason
);
return self::withContext($message, [
'command_name' => $commandName,
'input_name' => $inputName,
'input_value' => $inputValue,
'reason' => $reason,
'category' => 'input_validation',
], 0, $previous);
}
/**
* Create an exception for command operation failure.
*
* @param string $commandName The command that failed
* @param string $operation The operation that failed
* @param string $reason The reason for failure
* @param Throwable|null $previous Previous exception for chaining
*/
public static function forOperation(
string $commandName,
string $operation,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf(
"Command '%s' failed during operation '%s': %s",
$commandName,
$operation,
$reason
);
return self::withContext($message, [
'command_name' => $commandName,
'operation' => $operation,
'reason' => $reason,
'category' => 'operation_failure',
], 0, $previous);
}
/**
* Create an exception for pattern testing failure.
*
* @param string $pattern The pattern that failed testing
* @param string $testString The test string used
* @param string $reason The reason for test failure
* @param Throwable|null $previous Previous exception for chaining
*/
public static function forPatternTest(
string $pattern,
string $testString,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf("Pattern test failed for '%s': %s", $pattern, $reason);
return self::withContext($message, [
'pattern' => $pattern,
'test_string' => $testString,
'reason' => $reason,
'category' => 'pattern_test',
], 0, $previous);
}
/**
* Create an exception for JSON processing failure in commands.
*
* @param string $commandName The command that failed
* @param string $jsonData The JSON data being processed
* @param string $reason The reason for JSON processing failure
* @param Throwable|null $previous Previous exception for chaining
*/
public static function forJsonProcessing(
string $commandName,
string $jsonData,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf(
"Command '%s' failed to process JSON data: %s",
$commandName,
$reason
);
return self::withContext($message, [
'command_name' => $commandName,
'json_data' => $jsonData,
'reason' => $reason,
'category' => 'json_processing',
], 0, $previous);
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Exceptions;
use Throwable;
use Exception;
/**
* Base exception class for all GDPR processor related errors.
*
* This serves as the parent class for all specific GDPR processing exceptions,
* allowing consumers to catch all GDPR-related errors with a single catch block.
*
* @api
*/
class GdprProcessorException extends Exception
{
/**
* Create a new GDPR processor exception.
*
* @param string $message The exception message
* @param int $code The exception code (default: 0)
* @param Throwable|null $previous Previous exception for chaining
*/
public function __construct(string $message = '', int $code = 0, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
/**
* Create an exception with additional context information.
*
* @param string $message The base exception message
* @param array<string, mixed> $context Additional context data
* @param int $code The exception code (default: 0)
* @param Throwable|null $previous Previous exception for chaining
*/
public static function withContext(
string $message,
array $context,
int $code = 0,
?Throwable $previous = null
): static {
$contextString = '';
if ($context !== []) {
$contextParts = [];
foreach ($context as $key => $value) {
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES);
$contextParts[] = $key . ': ' . ($encoded === false ? '[unserializable]' : $encoded);
}
$contextString = ' [Context: ' . implode(', ', $contextParts) . ']';
}
/**
* @psalm-suppress UnsafeInstantiation
* @phpstan-ignore new.static
*/
return new static($message . $contextString, $code, $previous);
}
}

View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Exceptions;
use Throwable;
/**
* Exception thrown when GDPR processor configuration is invalid.
*
* This exception is thrown when:
* - Invalid field paths are provided
* - Invalid data type masks are specified
* - Invalid conditional rules are configured
* - Configuration values are out of acceptable ranges
* - Configuration structure is malformed
*
* @api
*/
class InvalidConfigurationException extends GdprProcessorException
{
/**
* Create an exception for an invalid field path.
*
* @param string $fieldPath The invalid field path
* @param string $reason The reason why the field path is invalid
* @param Throwable|null $previous Previous exception for chaining
*/
public static function forFieldPath(
string $fieldPath,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf("Invalid field path '%s': %s", $fieldPath, $reason);
return self::withContext($message, [
'field_path' => $fieldPath,
'reason' => $reason,
], 0, $previous);
}
/**
* Create an exception for an invalid data type mask.
*
* @param string $dataType The invalid data type
* @param mixed $mask The invalid mask value
* @param string $reason The reason why the mask is invalid
* @param Throwable|null $previous Previous exception for chaining
*/
public static function forDataTypeMask(
string $dataType,
mixed $mask,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf("Invalid data type mask for '%s': %s", $dataType, $reason);
return self::withContext($message, [
'data_type' => $dataType,
'mask' => $mask,
'reason' => $reason,
], 0, $previous);
}
/**
* Create an exception for an invalid conditional rule.
*
* @param string $ruleName The invalid rule name
* @param string $reason The reason why the rule is invalid
* @param Throwable|null $previous Previous exception for chaining
*/
public static function forConditionalRule(
string $ruleName,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf("Invalid conditional rule '%s': %s", $ruleName, $reason);
return self::withContext($message, [
'rule_name' => $ruleName,
'reason' => $reason,
], 0, $previous);
}
/**
* Create an exception for an invalid configuration value.
*
* @param string $parameter The parameter name
* @param mixed $value The invalid value
* @param string $reason The reason why the value is invalid
* @param Throwable|null $previous Previous exception for chaining
*/
public static function forParameter(
string $parameter,
mixed $value,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf("Invalid configuration parameter '%s': %s", $parameter, $reason);
return self::withContext($message, [
'parameter' => $parameter,
'value' => $value,
'reason' => $reason,
], 0, $previous);
}
/**
* Create an exception for an empty or null required value.
*
* @param string $parameter The parameter name that cannot be empty
* @param Throwable|null $previous Previous exception for chaining
*/
public static function emptyValue(
string $parameter,
?Throwable $previous = null
): static {
$message = sprintf("%s cannot be empty", ucfirst($parameter));
return self::withContext($message, [
'parameter' => $parameter,
], 0, $previous);
}
/**
* Create an exception for a value that exceeds maximum allowed length.
*
* @param string $parameter The parameter name
* @param int $actualLength The actual length
* @param int $maxLength The maximum allowed length
* @param Throwable|null $previous Previous exception for chaining
*/
public static function exceedsMaxLength(
string $parameter,
int $actualLength,
int $maxLength,
?Throwable $previous = null
): static {
$message = sprintf(
"%s length (%d) exceeds maximum allowed length (%d)",
ucfirst($parameter),
$actualLength,
$maxLength
);
return self::withContext($message, [
'parameter' => $parameter,
'actual_length' => $actualLength,
'max_length' => $maxLength,
], 0, $previous);
}
/**
* Create an exception for an invalid type.
*
* @param string $parameter The parameter name
* @param string $expectedType The expected type
* @param string $actualType The actual type
* @param Throwable|null $previous Previous exception for chaining
*/
public static function invalidType(
string $parameter,
string $expectedType,
string $actualType,
?Throwable $previous = null
): static {
$message = sprintf(
"%s must be of type %s, got %s",
ucfirst($parameter),
$expectedType,
$actualType
);
return self::withContext($message, [
'parameter' => $parameter,
'expected_type' => $expectedType,
'actual_type' => $actualType,
], 0, $previous);
}
}

View File

@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Exceptions;
use Throwable;
/**
* Exception thrown when rate limiter configuration is invalid.
*
* This exception is thrown when:
* - Maximum requests value is invalid
* - Time window value is invalid
* - Cleanup interval value is invalid
* - Rate limiting key is invalid or contains forbidden characters
*
* @api
*/
class InvalidRateLimitConfigurationException extends GdprProcessorException
{
/**
* Create an exception for an invalid maximum requests value.
*
* @param int|float|string $value The invalid value
* @param Throwable|null $previous Previous exception for chaining
*/
public static function invalidMaxRequests(
int|float|string $value,
?Throwable $previous = null
): static {
$message = sprintf('Maximum requests must be a positive integer, got: %s', $value);
return self::withContext($message, [
'parameter' => 'max_requests',
'value' => $value,
], 0, $previous);
}
/**
* Create an exception for an invalid time window value.
*
* @param int|float|string $value The invalid value
* @param Throwable|null $previous Previous exception for chaining
*/
public static function invalidTimeWindow(
int|float|string $value,
?Throwable $previous = null
): static {
$message = sprintf(
'Time window must be a positive integer representing seconds, got: %s',
$value
);
return self::withContext($message, [
'parameter' => 'time_window',
'value' => $value,
], 0, $previous);
}
/**
* Create an exception for an invalid cleanup interval.
*
* @param int|float|string $value The invalid value
* @param Throwable|null $previous Previous exception for chaining
*/
public static function invalidCleanupInterval(
int|float|string $value,
?Throwable $previous = null
): static {
$message = sprintf('Cleanup interval must be a positive integer, got: %s', $value);
return self::withContext($message, [
'parameter' => 'cleanup_interval',
'value' => $value,
], 0, $previous);
}
/**
* Create an exception for a time window that is too short.
*
* @param int $value The time window value
* @param int $minimum The minimum allowed value
* @param Throwable|null $previous Previous exception for chaining
*/
public static function timeWindowTooShort(
int $value,
int $minimum,
?Throwable $previous = null
): static {
$message = sprintf(
'Time window (%d seconds) is too short, minimum is %d seconds',
$value,
$minimum
);
return self::withContext($message, [
'parameter' => 'time_window',
'value' => $value,
'minimum' => $minimum,
], 0, $previous);
}
/**
* Create an exception for a cleanup interval that is too short.
*
* @param int $value The cleanup interval value
* @param int $minimum The minimum allowed value
* @param Throwable|null $previous Previous exception for chaining
*/
public static function cleanupIntervalTooShort(
int $value,
int $minimum,
?Throwable $previous = null
): static {
$message = sprintf(
'Cleanup interval (%d seconds) is too short, minimum is %d seconds',
$value,
$minimum
);
return self::withContext($message, [
'parameter' => 'cleanup_interval',
'value' => $value,
'minimum' => $minimum,
], 0, $previous);
}
/**
* Create an exception for an empty rate limiting key.
*
* @param Throwable|null $previous Previous exception for chaining
*/
public static function emptyKey(?Throwable $previous = null): static
{
return self::withContext('Rate limiting key cannot be empty', [
'parameter' => 'key',
], 0, $previous);
}
/**
* Create an exception for a rate limiting key that is too long.
*
* @param string $key The key that is too long
* @param int $maxLength The maximum allowed length
* @param Throwable|null $previous Previous exception for chaining
*/
public static function keyTooLong(
string $key,
int $maxLength,
?Throwable $previous = null
): static {
$message = sprintf(
'Rate limiting key length (%d) exceeds maximum (%d characters)',
strlen($key),
$maxLength
);
return self::withContext($message, [
'parameter' => 'key',
'key_length' => strlen($key),
'max_length' => $maxLength,
], 0, $previous);
}
/**
* Create an exception for a rate limiting key containing invalid characters.
*
* @param string $reason The reason why the key is invalid
* @param Throwable|null $previous Previous exception for chaining
*/
public static function invalidKeyFormat(
string $reason,
?Throwable $previous = null
): static {
return self::withContext($reason, [
'parameter' => 'key',
], 0, $previous);
}
/**
* Create an exception for a generic parameter validation failure.
*
* @param string $parameter The parameter name
* @param mixed $value The invalid value
* @param string $reason The reason why the value is invalid
* @param Throwable|null $previous Previous exception for chaining
*/
public static function forParameter(
string $parameter,
mixed $value,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf("Invalid rate limit parameter '%s': %s", $parameter, $reason);
return self::withContext($message, [
'parameter' => $parameter,
'value' => $value,
'reason' => $reason,
], 0, $previous);
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Exceptions;
use Throwable;
/**
* Exception thrown when a regex pattern is invalid or cannot be compiled.
*
* This exception is thrown when:
* - A regex pattern has invalid syntax
* - A regex pattern cannot be compiled by PHP's PCRE engine
* - A regex pattern is detected as potentially vulnerable to ReDoS attacks
* - A regex pattern compilation results in a PCRE error
*
* @api
*/
class InvalidRegexPatternException extends GdprProcessorException
{
/**
* Create an exception for an invalid regex pattern.
*
* @param string $pattern The invalid regex pattern
* @param string $reason The reason why the pattern is invalid
* @param int $pcreError Optional PCRE error code
* @param Throwable|null $previous Previous exception for chaining
*/
public static function forPattern(
string $pattern,
string $reason,
int $pcreError = 0,
?Throwable $previous = null
): static {
$message = sprintf("Invalid regex pattern '%s': %s", $pattern, $reason);
if ($pcreError !== 0) {
$pcreErrorMessage = self::getPcreErrorMessage($pcreError);
$message .= sprintf(' (PCRE Error: %s)', $pcreErrorMessage);
}
return self::withContext($message, [
'pattern' => $pattern,
'reason' => $reason,
'pcre_error' => $pcreError,
'pcre_error_message' => $pcreError !== 0 ? self::getPcreErrorMessage($pcreError) : null,
], $pcreError, $previous);
}
/**
* Create an exception for a pattern that failed compilation.
*
* @param string $pattern The pattern that failed to compile
* @param int $pcreError The PCRE error code
* @param Throwable|null $previous Previous exception for chaining
*/
public static function compilationFailed(
string $pattern,
int $pcreError,
?Throwable $previous = null
): static {
return self::forPattern($pattern, 'Pattern compilation failed', $pcreError, $previous);
}
/**
* Create an exception for a pattern detected as vulnerable to ReDoS.
*
* @param string $pattern The potentially vulnerable pattern
* @param string $vulnerability Description of the vulnerability
* @param Throwable|null $previous Previous exception for chaining
*
* @return InvalidRegexPatternException&static
*/
public static function redosVulnerable(
string $pattern,
string $vulnerability,
?Throwable $previous = null
): static {
return self::forPattern($pattern, 'Potential ReDoS vulnerability: ' . $vulnerability, 0, $previous);
}
/**
* Get a human-readable error message for a PCRE error code.
*
* @param int $errorCode The PCRE error code
*
* @return string Human-readable error message
* @psalm-return non-empty-string
*/
private static function getPcreErrorMessage(int $errorCode): string
{
return match ($errorCode) {
PREG_NO_ERROR => 'No error',
PREG_INTERNAL_ERROR => 'Internal PCRE error',
PREG_BACKTRACK_LIMIT_ERROR => 'Backtrack limit exceeded',
PREG_RECURSION_LIMIT_ERROR => 'Recursion limit exceeded',
PREG_BAD_UTF8_ERROR => 'Invalid UTF-8 data',
PREG_BAD_UTF8_OFFSET_ERROR => 'Invalid UTF-8 offset',
PREG_JIT_STACKLIMIT_ERROR => 'JIT stack limit exceeded',
default => sprintf('Unknown PCRE error (code: %s)', $errorCode),
};
}
}

View File

@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Exceptions;
use Throwable;
/**
* Exception thrown when a masking operation fails unexpectedly.
*
* This exception is thrown when:
* - A regex replacement operation fails
* - A field path masking operation encounters an error
* - A custom callback masking function throws an exception
* - Data type masking fails due to type conversion issues
* - JSON masking fails due to malformed JSON structures
*
* @api
*/
class MaskingOperationFailedException extends GdprProcessorException
{
/**
* Create an exception for a failed regex masking operation.
*
* @param string $pattern The regex pattern that failed
* @param string $input The input string being processed
* @param string $reason The reason for the failure
* @param Throwable|null $previous Previous exception for chaining
*/
public static function regexMaskingFailed(
string $pattern,
string $input,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf("Regex masking failed for pattern '%s': %s", $pattern, $reason);
return self::withContext($message, [
'operation_type' => 'regex_masking',
'pattern' => $pattern,
'input_length' => strlen($input),
'input_preview' => substr($input, 0, 100) . (strlen($input) > 100 ? '...' : ''),
'reason' => $reason,
], 0, $previous);
}
/**
* Create an exception for a failed field path masking operation.
*
* @param string $fieldPath The field path that failed
* @param mixed $value The value being masked
* @param string $reason The reason for the failure
* @param Throwable|null $previous Previous exception for chaining
*/
public static function fieldPathMaskingFailed(
string $fieldPath,
mixed $value,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf("Field path masking failed for path '%s': %s", $fieldPath, $reason);
return self::withContext($message, [
'operation_type' => 'field_path_masking',
'field_path' => $fieldPath,
'value_type' => gettype($value),
'value_preview' => self::getValuePreview($value),
'reason' => $reason,
], 0, $previous);
}
/**
* Create an exception for a failed custom callback masking operation.
*
* @param string $fieldPath The field path with the custom callback
* @param mixed $value The value being processed
* @param string $reason The reason for the failure
* @param Throwable|null $previous Previous exception for chaining
*/
public static function customCallbackFailed(
string $fieldPath,
mixed $value,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf("Custom callback masking failed for path '%s': %s", $fieldPath, $reason);
return self::withContext($message, [
'operation_type' => 'custom_callback',
'field_path' => $fieldPath,
'value_type' => gettype($value),
'value_preview' => self::getValuePreview($value),
'reason' => $reason,
], 0, $previous);
}
/**
* Create an exception for a failed data type masking operation.
*
* @param string $dataType The data type being masked
* @param mixed $value The value being masked
* @param string $reason The reason for the failure
* @param Throwable|null $previous Previous exception for chaining
*/
public static function dataTypeMaskingFailed(
string $dataType,
mixed $value,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf("Data type masking failed for type '%s': %s", $dataType, $reason);
return self::withContext($message, [
'operation_type' => 'data_type_masking',
'expected_type' => $dataType,
'actual_type' => gettype($value),
'value_preview' => self::getValuePreview($value),
'reason' => $reason,
], 0, $previous);
}
/**
* Create an exception for a failed JSON masking operation.
*
* @param string $jsonString The JSON string that failed to be processed
* @param string $reason The reason for the failure
* @param int $jsonError Optional JSON error code
* @param Throwable|null $previous Previous exception for chaining
*/
public static function jsonMaskingFailed(
string $jsonString,
string $reason,
int $jsonError = 0,
?Throwable $previous = null
): static {
$message = 'JSON masking failed: ' . $reason;
if ($jsonError !== 0) {
$jsonErrorMessage = json_last_error_msg();
$message .= sprintf(' (JSON Error: %s)', $jsonErrorMessage);
}
return self::withContext($message, [
'operation_type' => 'json_masking',
'json_preview' => substr($jsonString, 0, 200) . (strlen($jsonString) > 200 ? '...' : ''),
'json_length' => strlen($jsonString),
'reason' => $reason,
'json_error' => $jsonError,
'json_error_message' => $jsonError !== 0 ? json_last_error_msg() : null,
], 0, $previous);
}
/**
* Get a safe preview of a value for logging.
*
* @param mixed $value The value to preview
* @return string Safe preview string
*/
private static function getValuePreview(mixed $value): string
{
if (is_string($value)) {
return substr($value, 0, 100) . (strlen($value) > 100 ? '...' : '');
}
if (is_array($value) || is_object($value)) {
$json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR);
if ($json === false) {
return '[Unable to serialize]';
}
return substr($json, 0, 100) . (strlen($json) > 100 ? '...' : '');
}
return (string) $value;
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Exceptions;
use Throwable;
/**
* Exception thrown when pattern validation fails.
*
* This exception is thrown when:
* - Regex patterns are invalid or malformed
* - Pattern security validation fails
* - Pattern syntax is incorrect
* - Pattern validation methods encounter errors
*
* @api
*/
class PatternValidationException extends GdprProcessorException
{
/**
* Create an exception for a failed pattern validation.
*
* @param string $pattern The pattern that failed validation
* @param string $reason The reason why validation failed
* @param Throwable|null $previous Previous exception for chaining
*/
public static function forPattern(
string $pattern,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf("Pattern validation failed for '%s': %s", $pattern, $reason);
return self::withContext($message, [
'pattern' => $pattern,
'reason' => $reason,
], 0, $previous);
}
/**
* Create an exception for multiple pattern validation failures.
*
* @param array<string, string> $failedPatterns Array of pattern => error reason
* @param Throwable|null $previous Previous exception for chaining
*/
public static function forMultiplePatterns(
array $failedPatterns,
?Throwable $previous = null
): static {
$count = count($failedPatterns);
$message = sprintf("Pattern validation failed for %d pattern(s)", $count);
return self::withContext($message, [
'failed_patterns' => $failedPatterns,
'failure_count' => $count,
], 0, $previous);
}
/**
* Create an exception for pattern security validation failure.
*
* @param string $pattern The potentially unsafe pattern
* @param string $securityReason The security concern
* @param Throwable|null $previous Previous exception for chaining
*/
public static function securityValidationFailed(
string $pattern,
string $securityReason,
?Throwable $previous = null
): static {
$message = sprintf("Pattern security validation failed for '%s': %s", $pattern, $securityReason);
return self::withContext($message, [
'pattern' => $pattern,
'security_reason' => $securityReason,
'category' => 'security',
], 0, $previous);
}
/**
* Create an exception for pattern syntax errors.
*
* @param string $pattern The pattern with syntax errors
* @param string $syntaxError The syntax error details
* @param Throwable|null $previous Previous exception for chaining
*/
public static function syntaxError(
string $pattern,
string $syntaxError,
?Throwable $previous = null
): static {
$message = sprintf("Pattern syntax error in '%s': %s", $pattern, $syntaxError);
return self::withContext($message, [
'pattern' => $pattern,
'syntax_error' => $syntaxError,
'category' => 'syntax',
], 0, $previous);
}
}

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Exceptions;
use Throwable;
/**
* Exception thrown when the maximum recursion depth is exceeded during processing.
*
* This exception is thrown when:
* - The recursion depth limit is exceeded while processing nested structures
* - Circular references are detected in data structures
* - Extremely deep nesting threatens stack overflow
* - The configured maxDepth parameter is reached
*
* @api
*/
class RecursionDepthExceededException extends GdprProcessorException
{
/**
* Create an exception for exceeded recursion depth.
*
* @param int $currentDepth The current recursion depth when the exception occurred
* @param int $maxDepth The maximum allowed recursion depth
* @param string $path The field path where the depth was exceeded
* @param Throwable|null $previous Previous exception for chaining
*/
public static function depthExceeded(
int $currentDepth,
int $maxDepth,
string $path,
?Throwable $previous = null
): static {
$message = sprintf(
"Maximum recursion depth of %d exceeded (current: %d) at path '%s'",
$maxDepth,
$currentDepth,
$path
);
return self::withContext($message, [
'error_type' => 'depth_exceeded',
'current_depth' => $currentDepth,
'max_depth' => $maxDepth,
'field_path' => $path,
'safety_measure' => 'Processing stopped to prevent stack overflow',
], 0, $previous);
}
/**
* Create an exception for potential circular reference detection.
*
* @param string $path The field path where circular reference was detected
* @param int $currentDepth The current recursion depth
* @param int $maxDepth The maximum allowed recursion depth
* @param Throwable|null $previous Previous exception for chaining
*/
public static function circularReferenceDetected(
string $path,
int $currentDepth,
int $maxDepth,
?Throwable $previous = null
): static {
$message = sprintf(
"Potential circular reference detected at path '%s' (depth: %d/%d)",
$path,
$currentDepth,
$maxDepth
);
return self::withContext($message, [
'error_type' => 'circular_reference',
'field_path' => $path,
'current_depth' => $currentDepth,
'max_depth' => $maxDepth,
'safety_measure' => 'Processing stopped to prevent infinite recursion',
], 0, $previous);
}
/**
* Create an exception for extremely deep nesting scenarios.
*
* @param string $dataType The type of data structure causing deep nesting
* @param int $currentDepth The current recursion depth
* @param int $maxDepth The maximum allowed recursion depth
* @param string $path The field path with deep nesting
* @param Throwable|null $previous Previous exception for chaining
*/
public static function extremeNesting(
string $dataType,
int $currentDepth,
int $maxDepth,
string $path,
?Throwable $previous = null
): static {
$message = sprintf(
"Extremely deep nesting detected in %s at path '%s' (depth: %d/%d)",
$dataType,
$path,
$currentDepth,
$maxDepth
);
return self::withContext($message, [
'error_type' => 'extreme_nesting',
'data_type' => $dataType,
'field_path' => $path,
'current_depth' => $currentDepth,
'max_depth' => $maxDepth,
'suggestion' => 'Consider flattening the data structure or increasing maxDepth parameter',
], 0, $previous);
}
/**
* Create an exception for invalid depth configuration.
*
* @param int $invalidDepth The invalid depth value provided
* @param string $reason The reason why the depth is invalid
* @param Throwable|null $previous Previous exception for chaining
*/
public static function invalidDepthConfiguration(
int $invalidDepth,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf('Invalid recursion depth configuration: %d (%s)', $invalidDepth, $reason);
return self::withContext($message, [
'error_type' => 'invalid_configuration',
'invalid_depth' => $invalidDepth,
'reason' => $reason,
'valid_range' => 'Depth must be a positive integer between 1 and 1000',
], 0, $previous);
}
/**
* Create an exception with recommendations for handling deep structures.
*
* @param int $currentDepth The current recursion depth
* @param int $maxDepth The maximum allowed recursion depth
* @param string $path The field path where the issue occurred
* @param array<string> $recommendations List of recommendations
* @param Throwable|null $previous Previous exception for chaining
*/
public static function withRecommendations(
int $currentDepth,
int $maxDepth,
string $path,
array $recommendations,
?Throwable $previous = null
): static {
$message = sprintf(
"Recursion depth limit reached at path '%s' (depth: %d/%d)",
$path,
$currentDepth,
$maxDepth
);
return self::withContext($message, [
'error_type' => 'depth_with_recommendations',
'current_depth' => $currentDepth,
'max_depth' => $maxDepth,
'field_path' => $path,
'recommendations' => $recommendations,
], 0, $previous);
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Exceptions;
use Throwable;
/**
* Exception thrown when rule execution fails.
*
* This exception is thrown when:
* - Conditional rules fail during execution
* - Rule callbacks throw errors
* - Rule evaluation encounters runtime errors
* - Custom masking logic fails
* - Rule processing exceeds limits
*
* @api
*/
class RuleExecutionException extends GdprProcessorException
{
/**
* Create an exception for conditional rule execution failure.
*
* @param string $ruleName The rule that failed
* @param string $reason The reason for failure
* @param mixed $context Additional context about the failure
* @param Throwable|null $previous Previous exception for chaining
*/
public static function forConditionalRule(
string $ruleName,
string $reason,
mixed $context = null,
?Throwable $previous = null
): static {
$message = sprintf("Conditional rule '%s' execution failed: %s", $ruleName, $reason);
$contextData = [
'rule_name' => $ruleName,
'reason' => $reason,
'category' => 'conditional_rule',
];
if ($context !== null) {
$contextData['context'] = $context;
}
return self::withContext($message, $contextData, 0, $previous);
}
/**
* Create an exception for callback execution failure.
*
* @param string $callbackName The callback that failed
* @param string $fieldPath The field path being processed
* @param string $reason The reason for failure
* @param Throwable|null $previous Previous exception for chaining
*/
public static function forCallback(
string $callbackName,
string $fieldPath,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf(
"Callback '%s' failed for field path '%s': %s",
$callbackName,
$fieldPath,
$reason
);
return self::withContext($message, [
'callback_name' => $callbackName,
'field_path' => $fieldPath,
'reason' => $reason,
'category' => 'callback_execution',
], 0, $previous);
}
/**
* Create an exception for rule timeout.
*
* @param string $ruleName The rule that timed out
* @param float $timeoutSeconds The timeout threshold in seconds
* @param float $actualTime The actual execution time
* @param Throwable|null $previous Previous exception for chaining
*/
public static function forTimeout(
string $ruleName,
float $timeoutSeconds,
float $actualTime,
?Throwable $previous = null
): static {
$message = sprintf(
"Rule '%s' execution timed out after %.3f seconds (limit: %.3f seconds)",
$ruleName,
$actualTime,
$timeoutSeconds
);
return self::withContext($message, [
'rule_name' => $ruleName,
'timeout_seconds' => $timeoutSeconds,
'actual_time' => $actualTime,
'category' => 'timeout',
], 0, $previous);
}
/**
* Create an exception for rule evaluation error.
*
* @param string $ruleName The rule that failed evaluation
* @param mixed $inputData The input data being evaluated
* @param string $reason The reason for evaluation failure
* @param Throwable|null $previous Previous exception for chaining
*/
public static function forEvaluation(
string $ruleName,
mixed $inputData,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf("Rule '%s' evaluation failed: %s", $ruleName, $reason);
return self::withContext($message, [
'rule_name' => $ruleName,
'input_data' => $inputData,
'reason' => $reason,
'category' => 'evaluation',
], 0, $previous);
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Exceptions;
use Throwable;
/**
* Exception thrown when Laravel service registration fails.
*
* This exception is thrown when:
* - Service provider fails to register GDPR processor
* - Configuration publishing fails
* - Logging channel registration fails
* - Artisan command registration fails
* - Service binding or resolution fails
*
* @api
*/
class ServiceRegistrationException extends GdprProcessorException
{
/**
* Create an exception for channel registration failure.
*
* @param string $channelName The channel that failed to register
* @param string $reason The reason for failure
* @param Throwable|null $previous Previous exception for chaining
*/
public static function forChannel(
string $channelName,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf("Failed to register GDPR processor with channel '%s': %s", $channelName, $reason);
return self::withContext($message, [
'channel_name' => $channelName,
'reason' => $reason,
'category' => 'channel_registration',
], 0, $previous);
}
/**
* Create an exception for service binding failure.
*
* @param string $serviceName The service that failed to bind
* @param string $reason The reason for failure
* @param Throwable|null $previous Previous exception for chaining
*/
public static function forServiceBinding(
string $serviceName,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf("Failed to bind service '%s': %s", $serviceName, $reason);
return self::withContext($message, [
'service_name' => $serviceName,
'reason' => $reason,
'category' => 'service_binding',
], 0, $previous);
}
/**
* Create an exception for configuration publishing failure.
*
* @param string $configPath The configuration path that failed
* @param string $reason The reason for failure
* @param Throwable|null $previous Previous exception for chaining
*/
public static function forConfigPublishing(
string $configPath,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf("Failed to publish configuration to '%s': %s", $configPath, $reason);
return self::withContext($message, [
'config_path' => $configPath,
'reason' => $reason,
'category' => 'config_publishing',
], 0, $previous);
}
/**
* Create an exception for command registration failure.
*
* @param string $commandClass The command class that failed to register
* @param string $reason The reason for failure
* @param Throwable|null $previous Previous exception for chaining
*/
public static function forCommandRegistration(
string $commandClass,
string $reason,
?Throwable $previous = null
): static {
$message = sprintf("Failed to register command '%s': %s", $commandClass, $reason);
return self::withContext($message, [
'command_class' => $commandClass,
'reason' => $reason,
'category' => 'command_registration',
], 0, $previous);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Exceptions;
use Throwable;
/**
* Exception thrown when streaming operations fail.
*
* This exception is thrown when file operations related to streaming
* log processing fail, such as inability to open input or output files.
*
* @api
*/
class StreamingOperationFailedException extends GdprProcessorException
{
/**
* Create an exception for when an input file cannot be opened.
*
* @param string $filePath Path to the file that could not be opened
* @param Throwable|null $previous Previous exception for chaining
*/
public static function cannotOpenInputFile(string $filePath, ?Throwable $previous = null): static
{
return self::withContext(
"Cannot open input file for streaming: {$filePath}",
['operation' => 'open_input_file', 'file' => $filePath],
0,
$previous
);
}
/**
* Create an exception for when an output file cannot be opened.
*
* @param string $filePath Path to the file that could not be opened
* @param Throwable|null $previous Previous exception for chaining
*/
public static function cannotOpenOutputFile(string $filePath, ?Throwable $previous = null): static
{
return self::withContext(
"Cannot open output file for streaming: {$filePath}",
['operation' => 'open_output_file', 'file' => $filePath],
0,
$previous
);
}
}

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Factory;
use Closure;
use Ivuorinen\MonologGdprFilter\RateLimitedAuditLogger;
/**
* Factory for creating audit logger instances.
*
* This class provides factory methods for creating various types of
* audit loggers, including rate-limited and array-based loggers.
*
* @api
*/
final class AuditLoggerFactory
{
/**
* Create a rate-limited audit logger wrapper.
*
* @param callable(string,mixed,mixed):void $auditLogger The underlying audit logger
* @param string $profile Rate limiting profile: 'strict', 'default', 'relaxed', or 'testing'
*/
public function createRateLimited(
callable $auditLogger,
string $profile = 'default'
): RateLimitedAuditLogger {
return RateLimitedAuditLogger::create($auditLogger, $profile);
}
/**
* Create a simple audit logger that logs to an array (useful for testing).
*
* @param array<array-key, mixed> $logStorage Reference to array for storing logs
* @psalm-param array<array{path: string, original: mixed, masked: mixed}> $logStorage
* @psalm-param-out array<array{path: string, original: mixed, masked: mixed, timestamp: int<1, max>}> $logStorage
* @phpstan-param-out array<array-key, mixed> $logStorage
* @param bool $rateLimited Whether to apply rate limiting (default: false for testing)
*
* @psalm-return RateLimitedAuditLogger|Closure(string, mixed, mixed):void
* @psalm-suppress ReferenceConstraintViolation
*/
public function createArrayLogger(
array &$logStorage,
bool $rateLimited = false
): Closure|RateLimitedAuditLogger {
$baseLogger = function (string $path, mixed $original, mixed $masked) use (&$logStorage): void {
$logStorage[] = [
'path' => $path,
'original' => $original,
'masked' => $masked,
'timestamp' => time()
];
};
return $rateLimited
? $this->createRateLimited($baseLogger, 'testing')
: $baseLogger;
}
/**
* Create a null audit logger that does nothing.
*
* @return Closure(string, mixed, mixed):void
*/
public function createNullLogger(): Closure
{
return function (string $path, mixed $original, mixed $masked): void {
// Intentionally do nothing - null object pattern
unset($path, $original, $masked);
};
}
/**
* Create a callback-based logger.
*
* @param callable(string, mixed, mixed):void $callback The callback to invoke
* @return Closure(string, mixed, mixed):void
*/
public function createCallbackLogger(callable $callback): Closure
{
return function (string $path, mixed $original, mixed $masked) use ($callback): void {
$callback($path, $original, $masked);
};
}
/**
* Static factory method for convenience.
*/
public static function create(): self
{
return new self();
}
/**
* Static method: Create a rate-limited audit logger wrapper.
*
* @param callable(string,mixed,mixed):void $auditLogger The underlying audit logger
* @param string $profile Rate limiting profile
* @deprecated Use instance method createRateLimited() instead
*/
public static function rateLimited(
callable $auditLogger,
string $profile = 'default'
): RateLimitedAuditLogger {
return (new self())->createRateLimited($auditLogger, $profile);
}
/**
* Static method: Create a simple audit logger that logs to an array.
*
* @param array<array-key, mixed> $logStorage Reference to array for storing logs
* @param bool $rateLimited Whether to apply rate limiting
* @deprecated Use instance method createArrayLogger() instead
*
* @psalm-return RateLimitedAuditLogger|Closure(string, mixed, mixed):void
*/
public static function arrayLogger(
array &$logStorage,
bool $rateLimited = false
): Closure|RateLimitedAuditLogger {
return (new self())->createArrayLogger($logStorage, $rateLimited);
}
}

View File

@@ -1,11 +1,19 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter;
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidConfigurationException;
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidRegexPatternException;
use Ivuorinen\MonologGdprFilter\MaskConstants as Mask;
/**
* FieldMaskConfig: config for masking/removal per field path
* FieldMaskConfig: configuration for masking/removal per field path.
*
* @api
*/
final class FieldMaskConfig
final readonly class FieldMaskConfig
{
public const MASK_REGEX = 'mask_regex';
@@ -13,7 +21,206 @@ final class FieldMaskConfig
public const REPLACE = 'replace';
public function __construct(public string $type, public ?string $replacement = null)
public function __construct(
public string $type,
public ?string $replacement = null
) {}
/**
* Create a configuration for field removal.
*/
public static function remove(): self
{
return new self(self::REMOVE);
}
/**
* Create a configuration for static replacement.
*
* @param string $replacement The replacement value
*/
public static function replace(string $replacement): self
{
return new self(self::REPLACE, $replacement);
}
/**
* Create a configuration that uses the processor's global regex patterns.
* This is a shorthand for indicating "apply regex masking from the processor".
*/
public static function useProcessorPatterns(): self
{
return new self(self::MASK_REGEX);
}
/**
* Create a configuration for regex-based masking.
*
* @param string $pattern The regex pattern
* @param string $replacement The replacement string (default: '***MASKED***')
*
* @throws InvalidConfigurationException|InvalidRegexPatternException When pattern
* is empty or invalid, or replacement is empty
*/
public static function regexMask(string $pattern, string $replacement = Mask::MASK_MASKED): self
{
// Validate pattern is not empty
if (trim($pattern) === '') {
throw InvalidConfigurationException::emptyValue('regex pattern');
}
// Validate replacement is not empty
if (trim($replacement) === '') {
throw InvalidConfigurationException::emptyValue('replacement string');
}
// Validate regex pattern syntax
if (!self::isValidRegexPattern($pattern)) {
throw InvalidRegexPatternException::forPattern($pattern, 'Invalid regex pattern syntax');
}
return new self(self::MASK_REGEX, $pattern . '::' . $replacement);
}
/**
* Check if this configuration should remove the field.
*/
public function shouldRemove(): bool
{
return $this->type === self::REMOVE;
}
/**
* Check if this configuration has a regex pattern.
*/
public function hasRegexPattern(): bool
{
return $this->type === self::MASK_REGEX;
}
/**
* Get the regex pattern from a regex mask configuration.
*
* @return string|null The regex pattern or null if not a regex mask
*/
public function getRegexPattern(): ?string
{
if ($this->type !== self::MASK_REGEX || $this->replacement === null) {
return null;
}
$parts = explode('::', $this->replacement, 2);
return $parts[0] ?? null;
}
/**
* Get the replacement value.
*
* @return string|null The replacement value
*/
public function getReplacement(): ?string
{
if ($this->type === self::MASK_REGEX && $this->replacement !== null) {
$parts = explode('::', $this->replacement, 2);
return $parts[1] ?? Mask::MASK_MASKED;
}
return $this->replacement;
}
/**
* Convert to array representation.
*
* @return (null|string)[]
*
* @psalm-return array{type: string, replacement: null|string}
*/
public function toArray(): array
{
return [
'type' => $this->type,
'replacement' => $this->replacement,
];
}
/**
* Create from array representation.
*
* @param array<string, mixed> $data
*
* @throws InvalidConfigurationException|InvalidRegexPatternException When data contains invalid values
*/
public static function fromArray(array $data): self
{
$type = $data['type'] ?? self::REPLACE;
$replacement = $data['replacement'] ?? null;
// Validate type
$validTypes = [self::MASK_REGEX, self::REMOVE, self::REPLACE];
if (!in_array($type, $validTypes, true)) {
$validList = implode(', ', $validTypes);
throw InvalidConfigurationException::forParameter(
'type',
$type,
sprintf("Must be one of: %s", $validList)
);
}
// Validate replacement for REPLACE type - only when explicitly provided
if (
$type === self::REPLACE &&
array_key_exists('replacement', $data) &&
($replacement === null || trim($replacement) === '')
) {
throw InvalidConfigurationException::forParameter(
'replacement',
null,
'Cannot be null or empty for REPLACE type'
);
}
return new self($type, $replacement);
}
/**
* Validate if a regex pattern is syntactically correct.
*
* @param string $pattern The regex pattern to validate
* @return bool True if valid, false otherwise
*/
private static function isValidRegexPattern(string $pattern): bool
{
// Suppress warnings for invalid patterns
$previousErrorReporting = error_reporting(E_ERROR);
try {
// Test the pattern by attempting to use it
/** @psalm-suppress ArgumentTypeCoercion - Pattern validated by caller */
$result = @preg_match($pattern, '');
// Check if preg_match succeeded (returns 0 or 1) or failed (returns false)
$isValid = $result !== false;
// Additional check for PREG errors
if ($isValid && preg_last_error() !== PREG_NO_ERROR) {
$isValid = false;
}
// Additional validation for effectively empty patterns
// Check for patterns that are effectively empty (like '//' or '/\s*/')
// Extract the pattern content between delimiters
if ($isValid && preg_match('/^(.)(.*?)\1[gimuxXs]*$/', $pattern, $matches)) {
$patternContent = $matches[2];
// Reject patterns that are empty or only whitespace-based
if ($patternContent === '' || trim($patternContent) === '' || $patternContent === '\s*') {
$isValid = false;
}
}
return $isValid;
} finally {
// Restore previous error reporting level
error_reporting($previousErrorReporting);
}
}
}

View File

@@ -2,7 +2,12 @@
namespace Ivuorinen\MonologGdprFilter;
use Adbar\Dot;
use Ivuorinen\MonologGdprFilter\ArrayAccessor\ArrayAccessorFactory;
use Ivuorinen\MonologGdprFilter\Exceptions\PatternValidationException;
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidRegexPatternException;
use Ivuorinen\MonologGdprFilter\Factory\AuditLoggerFactory;
use Closure;
use Throwable;
use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;
@@ -10,86 +15,105 @@ use Monolog\Processor\ProcessorInterface;
* GdprProcessor is a Monolog processor that masks sensitive information in log messages
* according to specified regex patterns and field paths.
*
* This class serves as a Monolog adapter, delegating actual masking work to MaskingOrchestrator.
*
* @psalm-api
*/
class GdprProcessor implements ProcessorInterface
{
private readonly MaskingOrchestrator $orchestrator;
/**
* @var callable(string,mixed,mixed):void|null
*/
private $auditLogger;
/**
* @param array<string,string> $patterns Regex pattern => replacement
* @param array<string,FieldMaskConfig>|string[] $fieldPaths Dot-notation path => FieldMaskConfig
* @param array<string,?callable> $customCallbacks Dot-notation path => callback(value): string
* @param callable|null $auditLogger Opt. audit logger callback:
* @param array<string,FieldMaskConfig|string> $fieldPaths Dot-notation path => FieldMaskConfig
* @param array<string,callable(mixed):string> $customCallbacks Dot-notation path => callback(value): string
* @param callable(string,mixed,mixed):void|null $auditLogger Opt. audit logger callback:
* fn(string $path, mixed $original, mixed $masked)
* @param int $maxDepth Maximum recursion depth for nested structures (default: 100)
* @param array<string,string> $dataTypeMasks Type-based masking: type => mask pattern
* @param array<string,callable(LogRecord):bool> $conditionalRules Conditional masking rules:
* rule_name => condition_callback
* @param ArrayAccessorFactory|null $arrayAccessorFactory Factory for creating array accessors
*
* @throws \InvalidArgumentException When any parameter is invalid
*/
public function __construct(
private readonly array $patterns,
private readonly array $fieldPaths = [],
private readonly array $customCallbacks = [],
private $auditLogger = null
array $fieldPaths = [],
array $customCallbacks = [],
$auditLogger = null,
int $maxDepth = 100,
array $dataTypeMasks = [],
private readonly array $conditionalRules = [],
?ArrayAccessorFactory $arrayAccessorFactory = null
) {
$this->auditLogger = $auditLogger;
// Validate all constructor parameters using InputValidator
InputValidator::validateAll(
$patterns,
$fieldPaths,
$customCallbacks,
$auditLogger,
$maxDepth,
$dataTypeMasks,
$conditionalRules
);
// Pre-validate and cache patterns for better performance
/** @psalm-suppress DeprecatedMethod - Internal use of caching mechanism */
PatternValidator::cachePatterns($patterns);
// Create orchestrator to handle actual masking work
$this->orchestrator = new MaskingOrchestrator(
$patterns,
$fieldPaths,
$customCallbacks,
$auditLogger,
$maxDepth,
$dataTypeMasks,
$arrayAccessorFactory
);
}
/**
* FieldMaskConfig: config for masking/removal per field path using regex.
*/
public static function maskWithRegex(): FieldMaskConfig
{
return new FieldMaskConfig(FieldMaskConfig::MASK_REGEX);
}
/**
* FieldMaskConfig: Remove field from context.
*/
public static function removeField(): FieldMaskConfig
{
return new FieldMaskConfig(FieldMaskConfig::REMOVE);
}
/**
* FieldMaskConfig: Replace field value with a static string.
*/
public static function replaceWith(string $replacement): FieldMaskConfig
{
return new FieldMaskConfig(FieldMaskConfig::REPLACE, $replacement);
}
/**
* Default GDPR regex patterns. Non-exhaustive, should be extended with your own.
* Create a rate-limited audit logger wrapper.
*
* @return array<array-key, string>
* @param callable(string,mixed,mixed):void $auditLogger The underlying audit logger
* @param string $profile Rate limiting profile: 'strict', 'default', 'relaxed', or 'testing'
*
* @deprecated Use AuditLoggerFactory::create()->createRateLimited() instead
*/
public static function getDefaultPatterns(): array
{
return [
// Finnish SSN (HETU)
'/\b\d{6}[-+A]?\d{3}[A-Z]\b/u' => '***HETU***',
// US Social Security Number (strict: 3-2-4 digits)
'/^\d{3}-\d{2}-\d{4}$/' => '***USSSN***',
// IBAN (strictly match Finnish IBAN with or without spaces, only valid groupings)
'/^FI\d{2}(?: ?\d{4}){3} ?\d{2}$/u' => '***IBAN***',
// Also match fully compact Finnish IBAN (no spaces)
'/^FI\d{16}$/u' => '***IBAN***',
// International phone numbers (E.164, +countrycode...)
'/^\+\d{1,3}[\s-]?\d{1,4}[\s-]?\d{1,4}[\s-]?\d{1,9}$/' => '***PHONE***',
// Email address
'/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/' => '***EMAIL***',
// Date of birth (YYYY-MM-DD)
'/^(19|20)\d{2}-[01]\d\-[0-3]\d$/' => '***DOB***',
// Date of birth (DD/MM/YYYY)
'/^[0-3]\d\/[01]\d\/(19|20)\d{2}$/' => '***DOB***',
// Passport numbers (A followed by 6 digits)
'/^A\d{6}$/' => '***PASSPORT***',
// Credit card numbers (Visa, MC, Amex, Discover test numbers)
'/^(4111 1111 1111 1111|5500-0000-0000-0004|340000000000009|6011000000000004)$/' => '***CC***',
// Generic 16-digit credit card (for test compatibility)
'/\b[0-9]{16}\b/u' => '***CC***',
// Bearer tokens (JWT, at least 10 chars after Bearer)
'/^Bearer [A-Za-z0-9\-\._~\+\/]{10,}$/' => '***TOKEN***',
// API keys (Stripe-like, 20+ chars, or sk_live|sk_test)
'/^(sk_(live|test)_[A-Za-z0-9]{16,}|[A-Za-z0-9\-_]{20,})$/' => '***APIKEY***',
// MAC addresses
'/^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/' => '***MAC***',
];
public static function createRateLimitedAuditLogger(
callable $auditLogger,
string $profile = 'default'
): RateLimitedAuditLogger {
return AuditLoggerFactory::create()->createRateLimited($auditLogger, $profile);
}
/**
* Create a simple audit logger that logs to an array (useful for testing).
*
* @param array<array-key, mixed> $logStorage Reference to array for storing logs
* @psalm-param array<array{path: string, original: mixed, masked: mixed}> $logStorage
* @psalm-param-out array<array{path: string, original: mixed, masked: mixed, timestamp: int<1, max>}> $logStorage
* @phpstan-param-out array<array-key, mixed> $logStorage
* @param bool $rateLimited Whether to apply rate limiting (default: false for testing)
*
* @psalm-return RateLimitedAuditLogger|Closure(string, mixed, mixed):void
*
* @deprecated Use AuditLoggerFactory::create()->createArrayLogger() instead
*/
public static function createArrayAuditLogger(
array &$logStorage,
bool $rateLimited = false
): Closure|RateLimitedAuditLogger {
return AuditLoggerFactory::create()->createArrayLogger($logStorage, $rateLimited);
}
/**
@@ -97,149 +121,81 @@ class GdprProcessor implements ProcessorInterface
*
* @param LogRecord $record The log record to process
* @return LogRecord The processed log record with masked message and context
*
* @psalm-suppress MissingOverrideAttribute Override is available from PHP 8.3
*/
#[\Override]
public function __invoke(LogRecord $record): LogRecord
{
$message = $this->regExpMessage($record->message);
$context = $record->context;
$accessor = new Dot($context);
if ($this->fieldPaths !== []) {
$this->maskFieldPaths($accessor);
$context = $accessor->all();
} else {
$context = $this->recursiveMask($context);
// Check conditional rules first - if any rule returns false, skip masking
if (!$this->shouldApplyMasking($record)) {
return $record;
}
return $record->with(message: $message, context: $context);
// Delegate to orchestrator
$result = $this->orchestrator->process($record->message, $record->context);
return $record->with(message: $result['message'], context: $result['context']);
}
/**
* Mask a string using all regex patterns sequentially.
* Check if masking should be applied based on conditional rules.
*/
public function regExpMessage(string $message = ''): string
private function shouldApplyMasking(LogRecord $record): bool
{
foreach ($this->patterns as $regex => $replacement) {
/**
* @var array<array-key, non-empty-string> $regex
*/
$result = @preg_replace($regex, $replacement, $message);
if ($result === null) {
if (is_callable($this->auditLogger)) {
call_user_func($this->auditLogger, 'preg_replace_error', $message, $message);
// If no conditional rules are defined, always apply masking
if ($this->conditionalRules === []) {
return true;
}
// All conditional rules must return true for masking to be applied
foreach ($this->conditionalRules as $ruleName => $ruleCallback) {
try {
if (!$ruleCallback($record)) {
// Log which rule prevented masking
if ($this->auditLogger !== null) {
($this->auditLogger)(
'conditional_skip',
$ruleName,
'Masking skipped due to conditional rule'
);
}
return false;
}
} catch (Throwable $e) {
// If a rule throws an exception, log it and default to applying masking
if ($this->auditLogger !== null) {
$sanitized = SecuritySanitizer::sanitizeErrorMessage($e->getMessage());
$errorMsg = 'Rule error: ' . $sanitized;
($this->auditLogger)('conditional_error', $ruleName, $errorMsg);
}
continue;
}
if ($result === '' || $result === '0') {
// If the result is empty, we can skip further processing
return $message;
}
$message = $result;
}
return $message;
return true;
}
/**
* Mask only specified paths in context (fieldPaths)
* Mask a string using all regex patterns with optimized caching and batch processing.
* Also handles JSON strings within the message.
*/
private function maskFieldPaths(Dot $accessor): void
public function regExpMessage(string $message = ''): string
{
foreach ($this->fieldPaths as $path => $config) {
if (!$accessor->has($path)) {
continue;
}
$value = $accessor->get($path, "");
$action = $this->maskValue($path, $value, $config);
if ($action['remove'] ?? false) {
$accessor->delete($path);
$this->logAudit($path, $value, null);
continue;
}
$masked = $action['masked'];
if ($masked !== null && $masked !== $value) {
$accessor->set($path, $masked);
$this->logAudit($path, $value, $masked);
}
}
return $this->orchestrator->regExpMessage($message);
}
/**
* Mask a single value according to config or callback
* Returns an array: ['masked' => value|null, 'remove' => bool]
* Recursively mask all string values in an array using regex patterns with depth limiting
* and memory-efficient processing for large nested structures.
*
* @psalm-return array{masked: string|null, remove: bool}
* @param array<mixed>|string $data
* @param int $currentDepth Current recursion depth
* @return array<mixed>|string
*/
private function maskValue(string $path, mixed $value, null|FieldMaskConfig|string $config): array
public function recursiveMask(array|string $data, int $currentDepth = 0): array|string
{
/** @noinspection PhpArrayIndexImmediatelyRewrittenInspection */
$result = ['masked' => null, 'remove' => false];
if (array_key_exists($path, $this->customCallbacks) && $this->customCallbacks[$path] !== null) {
$result['masked'] = call_user_func($this->customCallbacks[$path], $value);
return $result;
}
if ($config instanceof FieldMaskConfig) {
switch ($config->type) {
case FieldMaskConfig::MASK_REGEX:
$result['masked'] = $this->regExpMessage($value);
break;
case FieldMaskConfig::REMOVE:
$result['masked'] = null;
$result['remove'] = true;
break;
case FieldMaskConfig::REPLACE:
$result['masked'] = $config->replacement;
break;
default:
// Return the type as string for unknown types
$result['masked'] = $config->type;
break;
}
} else {
// Backward compatibility: treat string as replacement
$result['masked'] = $config;
}
return $result;
}
/**
* Audit logger helper
*
* @param string $path Dot-notation path of the field
* @param mixed $original Original value before masking
* @param null|string $masked Masked value after processing, or null if removed
*/
private function logAudit(string $path, mixed $original, string|null $masked): void
{
if (is_callable($this->auditLogger) && $original !== $masked) {
// Only log if the value was actually changed
call_user_func($this->auditLogger, $path, $original, $masked);
}
}
/**
* Recursively mask all string values in an array using regex patterns.
*/
protected function recursiveMask(string|array $data): string|array
{
if (is_string($data)) {
return $this->regExpMessage($data);
}
foreach ($data as $key => $value) {
$data[$key] = $this->recursiveMask($value);
}
return $data;
return $this->orchestrator->recursiveMask($data, $currentDepth);
}
/**
@@ -247,26 +203,77 @@ class GdprProcessor implements ProcessorInterface
*/
public function maskMessage(string $value = ''): string
{
/** @var array<array-key, non-empty-string> $keys */
$keys = array_keys($this->patterns);
$values = array_values($this->patterns);
$result = @preg_replace($keys, $values, $value);
if ($result === null) {
if (is_callable($this->auditLogger)) {
call_user_func($this->auditLogger, 'preg_replace_error', $value, $value);
try {
/** @psalm-suppress ArgumentTypeCoercion */
$result = preg_replace($keys, $values, $value);
if ($result === null) {
$error = preg_last_error_msg();
if ($this->auditLogger !== null) {
($this->auditLogger)('preg_replace_batch_error', $value, 'Error: ' . $error);
}
return $value;
}
return $result;
} catch (\Error $error) {
if ($this->auditLogger !== null) {
($this->auditLogger)('regex_batch_error', implode(', ', $keys), $error->getMessage());
}
return $value;
}
return $result;
}
/**
* Set the audit logger callable.
*
* @param callable(string,mixed,mixed):void|null $auditLogger
*/
public function setAuditLogger(?callable $auditLogger): void
{
$this->auditLogger = $auditLogger;
$this->orchestrator->setAuditLogger($auditLogger);
}
/**
* Get the underlying orchestrator for direct access.
*/
public function getOrchestrator(): MaskingOrchestrator
{
return $this->orchestrator;
}
/**
* Validate an array of patterns for security and syntax.
*
* @param array<string, string> $patterns Array of regex pattern => replacement
*
* @throws \Ivuorinen\MonologGdprFilter\Exceptions\PatternValidationException When patterns are invalid
*/
public static function validatePatternsArray(array $patterns): void
{
try {
/** @psalm-suppress DeprecatedMethod - Wrapper for deprecated validation */
PatternValidator::validateAll($patterns);
} catch (InvalidRegexPatternException $e) {
throw PatternValidationException::forMultiplePatterns(
['validation_error' => $e->getMessage()],
$e
);
}
}
/**
* Get default GDPR regex patterns for common sensitive data types.
*
* @return array<string, string>
*/
public static function getDefaultPatterns(): array
{
return DefaultPatterns::get();
}
}

300
src/InputValidator.php Normal file
View File

@@ -0,0 +1,300 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter;
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidConfigurationException;
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidRegexPatternException;
/**
* Validates constructor parameters for GdprProcessor.
*
* This class is responsible for validating all input parameters
* to ensure they meet the requirements before processing.
*/
final class InputValidator
{
/**
* Validate all constructor parameters for early error detection.
*
* @param array<string,string> $patterns
* @param array<string,FieldMaskConfig|string> $fieldPaths
* @param array<string,callable(mixed):string> $customCallbacks
* @param callable(string,mixed,mixed):void|null $auditLogger
* @param int $maxDepth
* @param array<string,string> $dataTypeMasks
* @param array<string,callable> $conditionalRules
*
* @throws InvalidConfigurationException When any parameter is invalid
*/
public static function validateAll(
array $patterns,
array $fieldPaths,
array $customCallbacks,
mixed $auditLogger,
int $maxDepth,
array $dataTypeMasks,
array $conditionalRules
): void {
self::validatePatterns($patterns);
self::validateFieldPaths($fieldPaths);
self::validateCustomCallbacks($customCallbacks);
self::validateAuditLogger($auditLogger);
self::validateMaxDepth($maxDepth);
self::validateDataTypeMasks($dataTypeMasks);
self::validateConditionalRules($conditionalRules);
}
/**
* Validate patterns array for proper structure and valid regex patterns.
*
* @param array<string,string> $patterns
*
* @throws InvalidConfigurationException When patterns are invalid
*/
public static function validatePatterns(array $patterns): void
{
foreach ($patterns as $pattern => $replacement) {
// Validate pattern key
/** @psalm-suppress DocblockTypeContradiction - Runtime validation for defensive programming */
if (!is_string($pattern)) {
throw InvalidConfigurationException::invalidType(
'pattern',
'string',
gettype($pattern)
);
}
if (trim($pattern) === '') {
throw InvalidConfigurationException::emptyValue('pattern');
}
// Validate replacement value
/** @psalm-suppress DocblockTypeContradiction - Runtime validation for defensive programming */
if (!is_string($replacement)) {
throw InvalidConfigurationException::invalidType(
'pattern replacement',
'string',
gettype($replacement)
);
}
// Validate regex pattern syntax
/** @psalm-suppress DeprecatedMethod - Internal validation use */
if (!PatternValidator::isValid($pattern)) {
throw InvalidRegexPatternException::forPattern(
$pattern,
'Invalid regex pattern syntax'
);
}
}
}
/**
* Validate field paths array for proper structure.
*
* @param array<string,FieldMaskConfig|string> $fieldPaths
*
* @throws InvalidConfigurationException When field paths are invalid
*/
public static function validateFieldPaths(array $fieldPaths): void
{
foreach ($fieldPaths as $path => $config) {
// Validate path key
/** @psalm-suppress DocblockTypeContradiction - Runtime validation for defensive programming */
if (!is_string($path)) {
throw InvalidConfigurationException::invalidType(
'field path',
'string',
gettype($path)
);
}
if (trim($path) === '') {
throw InvalidConfigurationException::emptyValue('field path');
}
// Validate config value
/** @psalm-suppress DocblockTypeContradiction - Runtime validation for defensive programming */
if (!($config instanceof FieldMaskConfig) && !is_string($config)) {
throw InvalidConfigurationException::invalidType(
'field path value',
'FieldMaskConfig or string',
gettype($config)
);
}
if (is_string($config) && trim($config) === '') {
throw InvalidConfigurationException::forFieldPath(
$path,
'Cannot have empty string value'
);
}
}
}
/**
* Validate custom callbacks array for proper structure.
*
* @param array<string,callable(mixed):string> $customCallbacks
*
* @throws InvalidConfigurationException When custom callbacks are invalid
*/
public static function validateCustomCallbacks(array $customCallbacks): void
{
foreach ($customCallbacks as $path => $callback) {
// Validate path key
/** @psalm-suppress DocblockTypeContradiction - Runtime validation for defensive programming */
if (!is_string($path)) {
throw InvalidConfigurationException::invalidType(
'custom callback path',
'string',
gettype($path)
);
}
if (trim($path) === '') {
throw InvalidConfigurationException::emptyValue('custom callback path');
}
// Validate callback value
if (!is_callable($callback)) {
throw InvalidConfigurationException::forParameter(
'custom callback for ' . $path,
$callback,
'Must be callable'
);
}
}
}
/**
* Validate audit logger parameter.
*
* @param callable(string,mixed,mixed):void|null $auditLogger
*
* @throws InvalidConfigurationException When audit logger is invalid
*/
public static function validateAuditLogger(mixed $auditLogger): void
{
if ($auditLogger !== null && !is_callable($auditLogger)) {
$type = gettype($auditLogger);
throw InvalidConfigurationException::invalidType(
'audit logger',
'callable or null',
$type
);
}
}
/**
* Validate max depth parameter for reasonable bounds.
*
* @throws InvalidConfigurationException When max depth is invalid
*/
public static function validateMaxDepth(int $maxDepth): void
{
if ($maxDepth <= 0) {
throw InvalidConfigurationException::forParameter(
'max_depth',
$maxDepth,
'Must be a positive integer'
);
}
if ($maxDepth > 1000) {
throw InvalidConfigurationException::forParameter(
'max_depth',
$maxDepth,
'Cannot exceed 1,000 for stack safety'
);
}
}
/**
* Validate data type masks array for proper structure.
*
* @param array<string,string> $dataTypeMasks
*
* @throws InvalidConfigurationException When data type masks are invalid
*/
public static function validateDataTypeMasks(array $dataTypeMasks): void
{
$validTypes = ['integer', 'double', 'string', 'boolean', 'NULL', 'array', 'object', 'resource'];
foreach ($dataTypeMasks as $type => $mask) {
// Validate type key
/** @psalm-suppress DocblockTypeContradiction - Runtime validation for defensive programming */
if (!is_string($type)) {
$typeGot = gettype($type);
throw InvalidConfigurationException::invalidType(
'data type mask key',
'string',
$typeGot
);
}
if (!in_array($type, $validTypes, true)) {
$validList = implode(', ', $validTypes);
throw InvalidConfigurationException::forDataTypeMask(
$type,
null,
"Must be one of: $validList"
);
}
// Validate mask value
/** @psalm-suppress DocblockTypeContradiction - Runtime validation for defensive programming */
if (!is_string($mask)) {
throw InvalidConfigurationException::invalidType(
'data type mask value',
'string',
gettype($mask)
);
}
if (trim($mask) === '') {
throw InvalidConfigurationException::forDataTypeMask(
$type,
'',
'Cannot be empty'
);
}
}
}
/**
* Validate conditional rules array for proper structure.
*
* @param array<string,callable> $conditionalRules
*
* @throws InvalidConfigurationException When conditional rules are invalid
*/
public static function validateConditionalRules(array $conditionalRules): void
{
foreach ($conditionalRules as $ruleName => $callback) {
// Validate rule name key
/** @psalm-suppress DocblockTypeContradiction - Runtime validation for defensive programming */
if (!is_string($ruleName)) {
throw InvalidConfigurationException::invalidType(
'conditional rule name',
'string',
gettype($ruleName)
);
}
if (trim($ruleName) === '') {
throw InvalidConfigurationException::emptyValue('conditional rule name');
}
// Validate callback value
if (!is_callable($callback)) {
throw InvalidConfigurationException::forConditionalRule(
$ruleName,
'Must have a callable callback'
);
}
}
}
}

227
src/JsonMasker.php Normal file
View File

@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter;
use JsonException;
/**
* Handles JSON structure detection and masking within log messages.
*
* This class provides methods to find JSON structures in strings,
* parse them, apply masking, and re-encode them.
*/
final class JsonMasker
{
/**
* @param callable(array<mixed>|string, int=):array<mixed>|string $recursiveMaskCallback
* @param callable(string, mixed, mixed):void|null $auditLogger
*/
public function __construct(
private $recursiveMaskCallback,
private $auditLogger = null
) {}
/**
* Find and process JSON structures in the message.
*/
public function processMessage(string $message): string
{
$result = '';
$length = strlen($message);
$i = 0;
while ($i < $length) {
$char = $message[$i];
if ($char === '{' || $char === '[') {
// Found potential JSON start, try to extract balanced structure
$jsonCandidate = $this->extractBalancedStructure($message, $i);
if ($jsonCandidate !== null) {
// Process the candidate
$processed = $this->processCandidate($jsonCandidate);
$result .= $processed;
$i += strlen($jsonCandidate);
continue;
}
}
$result .= $char;
$i++;
}
return $result;
}
/**
* Extract a balanced JSON structure starting from the given position.
*/
public function extractBalancedStructure(string $message, int $startPos): ?string
{
$length = strlen($message);
$startChar = $message[$startPos];
$endChar = $startChar === '{' ? '}' : ']';
$level = 0;
$inString = false;
$escaped = false;
for ($i = $startPos; $i < $length; $i++) {
$char = $message[$i];
if ($this->isEscapedCharacter($escaped)) {
$escaped = false;
continue;
}
if ($this->isEscapeStart($char, $inString)) {
$escaped = true;
continue;
}
if ($char === '"') {
$inString = !$inString;
continue;
}
if ($inString) {
continue;
}
$balancedEnd = $this->processStructureChar($char, $startChar, $endChar, $level, $message, $startPos, $i);
if ($balancedEnd !== null) {
return $balancedEnd;
}
}
// No balanced structure found
return null;
}
/**
* Check if current character is escaped.
*/
private function isEscapedCharacter(bool $escaped): bool
{
return $escaped;
}
/**
* Check if current character starts an escape sequence.
*/
private function isEscapeStart(string $char, bool $inString): bool
{
return $char === '\\' && $inString;
}
/**
* Process a structure character (bracket or brace) and update nesting level.
*
* @return string|null Returns the extracted structure if complete, null otherwise
*/
private function processStructureChar(
string $char,
string $startChar,
string $endChar,
int &$level,
string $message,
int $startPos,
int $currentPos
): ?string {
if ($char === $startChar) {
$level++;
} elseif ($char === $endChar) {
$level--;
if ($level === 0) {
// Found complete balanced structure
return substr($message, $startPos, $currentPos - $startPos + 1);
}
}
return null;
}
/**
* Process a potential JSON candidate string.
*/
public function processCandidate(string $potentialJson): string
{
try {
// Try to parse as JSON
$decoded = json_decode($potentialJson, true, 512, JSON_THROW_ON_ERROR);
// If successfully decoded, apply masking and re-encode
if ($decoded !== null) {
$masked = ($this->recursiveMaskCallback)($decoded, 0);
$reEncoded = $this->encodePreservingEmptyObjects($masked, $potentialJson);
if ($reEncoded !== false) {
// Log the operation if audit logger is available
if ($this->auditLogger !== null && $reEncoded !== $potentialJson) {
($this->auditLogger)('json_masked', $potentialJson, $reEncoded);
}
return $reEncoded;
}
}
} catch (JsonException) {
// Not valid JSON, leave as-is to be processed by regular patterns
}
return $potentialJson;
}
/**
* Encode JSON while preserving empty object structures from the original.
*
* @param array<mixed>|string $data The data to encode.
* @param string $originalJson The original JSON string.
*
* @return false|string The encoded JSON string or false on failure.
*/
public function encodePreservingEmptyObjects(array|string $data, string $originalJson): string|false
{
// Handle simple empty cases first
if (in_array($data, ['', '0', []], true)) {
if ($originalJson === '{}') {
return '{}';
}
if ($originalJson === '[]') {
return '[]';
}
}
// Encode the processed data
$encoded = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($encoded === false) {
return false;
}
// Fix empty arrays that should be empty objects by comparing with original
return $this->fixEmptyObjects($encoded, $originalJson);
}
/**
* Fix empty arrays that should be empty objects in the encoded JSON.
*/
public function fixEmptyObjects(string $encoded, string $original): string
{
// Count empty objects in original and empty arrays in encoded
$originalEmptyObjects = substr_count($original, '{}');
$encodedEmptyArrays = substr_count($encoded, '[]');
// If we lost empty objects (they became arrays), fix them
if ($originalEmptyObjects > 0 && $encodedEmptyArrays >= $originalEmptyObjects) {
// Replace empty arrays with empty objects, up to the number we had originally
for ($i = 0; $i < $originalEmptyObjects; $i++) {
$encoded = preg_replace('/\[\]/', '{}', $encoded, 1) ?? $encoded;
}
}
return $encoded;
}
}

View File

@@ -0,0 +1,216 @@
<?php
namespace Ivuorinen\MonologGdprFilter\Laravel\Commands;
use Monolog\LogRecord;
use DateTimeImmutable;
use Monolog\Level;
use JsonException;
use Illuminate\Console\Command;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\Exceptions\CommandExecutionException;
/**
* Artisan command for debugging GDPR configuration and testing.
*
* This command provides information about the current GDPR configuration
* and allows testing with sample log data.
*
* @api
* @psalm-suppress PropertyNotSetInConstructor
*/
class GdprDebugCommand extends Command
{
private const COMMAND_NAME = 'gdpr:debug';
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'gdpr:debug
{--test-data= : JSON string of sample data to test}
{--show-patterns : Show all configured patterns}
{--show-config : Show current configuration}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Debug GDPR configuration and test with sample data';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->info('GDPR Filter Debug Information');
$this->line('=============================');
// Show configuration if requested
if ((bool)$this->option('show-config')) {
$this->showConfiguration();
}
// Show patterns if requested
if ((bool)$this->option('show-patterns')) {
$this->showPatterns();
}
// Test with sample data if provided
$testData = (string)$this->option('test-data');
if ($testData !== '' && $testData !== '0') {
$this->testWithSampleData($testData);
}
if (!$this->option('show-config') && !$this->option('show-patterns') && !$testData) {
$this->showSummary();
}
return 0;
}
/**
* Show current GDPR configuration.
*/
protected function showConfiguration(): void
{
$this->line('');
$this->info('Current Configuration:');
$this->line('----------------------');
$config = \config('gdpr', []);
$this->line('Auto Register: ' . ($config['auto_register'] ?? true ? 'Yes' : 'No'));
$this->line('Max Depth: ' . ($config['max_depth'] ?? 100));
$this->line('Audit Logging: ' . (($config['audit_logging']['enabled'] ?? false) ? 'Enabled' : 'Disabled'));
$channels = $config['channels'] ?? [];
$this->line('Channels: ' . (empty($channels) ? 'None' : implode(', ', $channels)));
$fieldPaths = $config['field_paths'] ?? [];
$this->line('Field Paths: ' . count($fieldPaths) . ' configured');
$customCallbacks = $config['custom_callbacks'] ?? [];
$this->line('Custom Callbacks: ' . count($customCallbacks) . ' configured');
}
/**
* Show all configured patterns.
*/
protected function showPatterns(): void
{
$this->line('');
$this->info('Configured Patterns:');
$this->line('--------------------');
$config = \config('gdpr', []);
/**
* @var array<string, mixed>|null $patterns
*/
$patterns = $config['patterns'] ?? null;
if (count($patterns) === 0 && empty($patterns)) {
$this->line('No patterns configured - using defaults');
$patterns = GdprProcessor::getDefaultPatterns();
}
foreach ($patterns as $pattern => $replacement) {
$this->line(sprintf('%s => %s', $pattern, $replacement));
}
$this->line('');
$this->line('Total patterns: ' . count($patterns));
}
/**
* Test GDPR processing with sample data.
*/
protected function testWithSampleData(string $testData): void
{
$this->line('');
$this->info('Testing with sample data:');
$this->line('-------------------------');
try {
$data = json_decode($testData, true, 512, JSON_THROW_ON_ERROR);
$processor = \app('gdpr.processor');
// Test with a sample log record
$logRecord = new LogRecord(
datetime: new DateTimeImmutable(),
channel: 'test',
level: Level::Info,
message: $data['message'] ?? 'Test message',
context: $data['context'] ?? []
);
$result = $processor($logRecord);
$this->line('Original Message: ' . $logRecord->message);
$this->line('Processed Message: ' . $result->message);
if ($logRecord->context !== []) {
$this->line('');
$this->line('Original Context:');
$this->line((string)json_encode($logRecord->context, JSON_PRETTY_PRINT));
$this->line('Processed Context:');
$this->line((string)json_encode($result->context, JSON_PRETTY_PRINT));
}
} catch (JsonException $e) {
throw CommandExecutionException::forJsonProcessing(
self::COMMAND_NAME,
$testData,
$e->getMessage(),
$e
);
} catch (\Throwable $e) {
throw CommandExecutionException::forOperation(
self::COMMAND_NAME,
'data processing',
$e->getMessage(),
$e
);
}
}
/**
* Show summary information.
*/
protected function showSummary(): void
{
$this->line('');
$this->info('Quick Summary:');
$this->line('--------------');
try {
\app('gdpr.processor');
$this->line('<info>✓</info> GDPR processor is registered and ready');
$config = \config('gdpr', []);
$patterns = $config['patterns'] ?? GdprProcessor::getDefaultPatterns();
$this->line('Patterns configured: ' . count($patterns));
} catch (\Throwable $exception) {
throw CommandExecutionException::forOperation(
self::COMMAND_NAME,
'configuration check',
'GDPR processor is not properly configured: ' . $exception->getMessage(),
$exception
);
}
$this->line('');
$this->info('Available options:');
$this->line(' --show-config Show current configuration');
$this->line(' --show-patterns Show all regex patterns');
$this->line(' --test-data Test with JSON sample data');
$this->line('');
$this->info('Example usage:');
$this->line(' php artisan gdpr:debug --show-config');
$this->line(' php artisan gdpr:debug --test-data=\'{"message":"Email: test@example.com"}\'');
}
}

View File

@@ -0,0 +1,191 @@
<?php
namespace Ivuorinen\MonologGdprFilter\Laravel\Commands;
use Illuminate\Console\Command;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\Exceptions\PatternValidationException;
use Ivuorinen\MonologGdprFilter\Exceptions\CommandExecutionException;
/**
* Artisan command for testing GDPR regex patterns.
*
* This command allows developers to test regex patterns against sample data
* to ensure they work correctly before deploying to production.
*
* @api
* @psalm-suppress PropertyNotSetInConstructor
*/
class GdprTestPatternCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'gdpr:test-pattern
{pattern : The regex pattern to test}
{replacement : The replacement text}
{test-string : The string to test against}
{--validate : Validate the pattern for security}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Test GDPR regex patterns against sample data';
/**
* Execute the console command.
*
* @psalm-return 0|1
*/
public function handle(): int
{
$args = $this->extractAndNormalizeArguments();
$pattern = $args[0];
$replacement = $args[1];
$testString = $args[2];
$validate = $args[3];
$this->displayTestHeader($pattern, $replacement, $testString);
if ($validate && !$this->validatePattern($pattern, $replacement)) {
return 1;
}
return $this->executePatternTest($pattern, $replacement, $testString);
}
/**
* Extract and normalize command arguments.
*
* @return array{string, string, string, bool}
*/
private function extractAndNormalizeArguments(): array
{
$pattern = $this->argument('pattern');
$replacement = $this->argument('replacement');
$testString = $this->argument('test-string');
$validate = $this->option('validate');
$pattern = is_array($pattern) ? $pattern[0] : $pattern;
$replacement = is_array($replacement) ? $replacement[0] : $replacement;
$testString = is_array($testString) ? $testString[0] : $testString;
$validate = is_bool($validate) ? $validate : (bool) $validate;
return [
(string) ($pattern ?? ''),
(string) ($replacement ?? ''),
(string) ($testString ?? ''),
$validate,
];
}
/**
* Display the test header with pattern information.
*/
private function displayTestHeader(string $pattern, string $replacement, string $testString): void
{
$this->info('Testing GDPR Pattern');
$this->line('====================');
$this->line('Pattern: ' . $pattern);
$this->line('Replacement: ' . $replacement);
$this->line('Test String: ' . $testString);
$this->line('');
}
/**
* Validate the pattern if requested.
*/
private function validatePattern(string $pattern, string $replacement): bool
{
$this->info('Validating pattern...');
try {
GdprProcessor::validatePatternsArray([$pattern => $replacement]);
$this->line('<info>✓</info> Pattern is valid and secure');
} catch (PatternValidationException $e) {
$this->error('✗ Pattern validation failed: ' . $e->getMessage());
return false;
}
$this->line('');
return true;
}
/**
* Execute the pattern test.
*/
private function executePatternTest(string $pattern, string $replacement, string $testString): int
{
$this->info('Testing pattern match...');
try {
$this->validateInputs($pattern, $testString);
$processor = new GdprProcessor([$pattern => $replacement]);
$result = $processor->regExpMessage($testString);
$this->displayTestResult($result, $testString);
$this->showMatchDetails($pattern, $testString);
} catch (CommandExecutionException $exception) {
$this->error('✗ Pattern test failed: ' . $exception->getMessage());
return 1;
}
return 0;
}
/**
* Validate inputs are not empty.
*/
private function validateInputs(string $pattern, string $testString): void
{
if ($pattern === '' || $pattern === '0') {
throw CommandExecutionException::forInvalidInput(
'gdpr:test-pattern',
'pattern',
$pattern,
'Pattern cannot be empty'
);
}
if ($testString === '' || $testString === '0') {
throw CommandExecutionException::forInvalidInput(
'gdpr:test-pattern',
'test-string',
$testString,
'Test string cannot be empty'
);
}
}
/**
* Display the test result.
*/
private function displayTestResult(string $result, string $testString): void
{
if ($result === $testString) {
$this->line('<comment>-</comment> No match found - string unchanged');
} else {
$this->line('<info>✓</info> Pattern matched!');
$this->line('Result: ' . $result);
}
}
/**
* Show detailed matching information.
*/
private function showMatchDetails(string $pattern, string $testString): void
{
$matches = [];
if (preg_match($pattern, $testString, $matches)) {
$this->line('');
$this->info('Match details:');
foreach ($matches as $index => $match) {
$this->line(sprintf(' [%s]: %s', $index, $match));
}
}
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Ivuorinen\MonologGdprFilter\Laravel\Facades;
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
use Monolog\LogRecord;
use Illuminate\Support\Facades\Facade;
/**
* Laravel Facade for GDPR Processor.
*
* @method static string regExpMessage(string $message = '')
* @method static array<string, string> getDefaultPatterns()
* @method static FieldMaskConfig maskWithRegex()
* @method static FieldMaskConfig removeField()
* @method static FieldMaskConfig replaceWith(string $replacement)
* @method static void validatePatterns(array<string, string> $patterns)
* @method static void clearPatternCache()
* @method static LogRecord __invoke(LogRecord $record)
*
* @see \Ivuorinen\MonologGdprFilter\GdprProcessor
* @api
*/
class Gdpr extends Facade
{
/**
* Get the registered name of the component.
*
*
* @psalm-return 'gdpr.processor'
*/
protected static function getFacadeAccessor(): string
{
return 'gdpr.processor';
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace Ivuorinen\MonologGdprFilter\Laravel;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Carbon;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\Laravel\Commands\GdprTestPatternCommand;
use Ivuorinen\MonologGdprFilter\Laravel\Commands\GdprDebugCommand;
use Ivuorinen\MonologGdprFilter\Exceptions\ServiceRegistrationException;
/**
* Laravel Service Provider for Monolog GDPR Filter.
*
* This service provider automatically registers the GDPR processor with Laravel's logging system
* and provides configuration management and artisan commands.
*
* @api
*/
class GdprServiceProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../../config/gdpr.php', 'gdpr');
$this->app->singleton('gdpr.processor', function (Application $app): GdprProcessor {
$config = $app->make('config')->get('gdpr', []);
$patterns = $config['patterns'] ?? GdprProcessor::getDefaultPatterns();
$fieldPaths = $config['field_paths'] ?? [];
$customCallbacks = $config['custom_callbacks'] ?? [];
$maxDepth = $config['max_depth'] ?? 100;
$auditLogger = null;
if ($config['audit_logging']['enabled'] ?? false) {
$auditLogger = function (string $path, mixed $original, mixed $masked): void {
Log::channel('gdpr-audit')->info('GDPR Processing', [
'path' => $path,
'original_type' => gettype($original),
'was_masked' => $original !== $masked,
'timestamp' => Carbon::now()->toISOString(),
]);
};
}
return new GdprProcessor(
$patterns,
$fieldPaths,
$customCallbacks,
$auditLogger,
$maxDepth
);
});
$this->app->alias('gdpr.processor', GdprProcessor::class);
}
/**
* Bootstrap services.
*/
public function boot(): void
{
// Publish configuration file
$this->publishes([
__DIR__ . '/../../config/gdpr.php' => $this->app->configPath('gdpr.php'),
], 'gdpr-config');
// Register artisan commands
if ($this->app->runningInConsole()) {
$this->commands([
GdprTestPatternCommand::class,
GdprDebugCommand::class,
]);
}
// Auto-register with Laravel's logging system if enabled
if (\config('gdpr.auto_register', true)) {
$this->registerWithLogging();
}
}
/**
* Automatically register GDPR processor with Laravel's logging channels.
*/
protected function registerWithLogging(): void
{
$logger = $this->app->make('log');
$processor = $this->app->make('gdpr.processor');
// Get channels to apply GDPR processing to
$channels = \config('gdpr.channels', ['single', 'daily', 'stack']);
foreach ($channels as $channelName) {
try {
$channelLogger = $logger->channel($channelName);
if (method_exists($channelLogger, 'pushProcessor')) {
$channelLogger->pushProcessor($processor);
}
} catch (\Throwable $e) {
// Log proper service registration failure but continue with other channels
$exception = ServiceRegistrationException::forChannel(
$channelName,
$e->getMessage(),
$e
);
Log::debug('GDPR service registration warning: ' . $exception->getMessage());
}
}
}
}

View File

@@ -0,0 +1,207 @@
<?php
/**
* Laravel Middleware for GDPR-compliant logging using MonologGdprFilter.
* This middleware logs HTTP requests and responses while filtering out sensitive data
* according to GDPR guidelines.
*/
namespace Ivuorinen\MonologGdprFilter\Laravel\Middleware;
use JsonException;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\MaskConstants;
/**
* Middleware for GDPR-compliant request/response logging.
*
* This middleware automatically logs HTTP requests and responses
* with GDPR filtering applied to sensitive data.
*
* @api
*/
class GdprLogMiddleware
{
private const LOG_MESSAGE_HTTP_RESPONSE = 'HTTP Response';
protected GdprProcessor $processor;
public function __construct(GdprProcessor $processor)
{
$this->processor = $processor;
}
/**
* Handle an incoming request.
*
* @param Request $request
* @param Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
$startTime = microtime(true);
// Log the incoming request
$this->logRequest($request);
// Process the request
$response = $next($request);
// Log the response
$this->logResponse($request, $response, $startTime);
return $response;
}
/**
* Log the incoming request with GDPR filtering.
*/
protected function logRequest(Request $request): void
{
$requestData = [
'method' => $request->method(),
'url' => $request->fullUrl(),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'headers' => $this->filterHeaders($request->headers->all()),
'query' => $request->query(),
'body' => $this->getRequestBody($request),
];
// Apply GDPR filtering to the entire request data
$filteredData = $this->processor->recursiveMask($requestData);
Log::info('HTTP Request', $filteredData);
}
/**
* Log the response with GDPR filtering.
*
* @param \Illuminate\Http\Response|\Symfony\Component\HttpFoundation\Response $response
*/
protected function logResponse(Request $request, mixed $response, float $startTime): void
{
$duration = round((microtime(true) - $startTime) * 1000, 2);
$responseData = [
'status' => $response->getStatusCode(),
'duration_ms' => $duration,
'memory_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2),
'content_length' => $response->headers->get('Content-Length'),
'response_headers' => $this->filterHeaders($response->headers->all()),
];
// Only log response body for errors or if specifically configured
if ($response->getStatusCode() >= 400 && config('gdpr.log_error_responses', false)) {
$responseData['body'] = $this->getResponseBody($response);
}
// Apply GDPR filtering
$filteredData = $this->processor->recursiveMask($responseData);
$level = $response->getStatusCode() >= 500 ? 'error' : ($response->getStatusCode() >= 400 ? 'warning' : 'info');
match ($level) {
'error' => Log::error(self::LOG_MESSAGE_HTTP_RESPONSE, array_merge(
['method' => $request->method(), 'url' => $request->fullUrl()],
$filteredData
)),
'warning' => Log::warning(self::LOG_MESSAGE_HTTP_RESPONSE, array_merge(
['method' => $request->method(), 'url' => $request->fullUrl()],
$filteredData
)),
default => Log::info(self::LOG_MESSAGE_HTTP_RESPONSE, array_merge(
['method' => $request->method(), 'url' => $request->fullUrl()],
$filteredData
))
};
}
/**
* Get request body safely.
*/
protected function getRequestBody(Request $request): mixed
{
// Only log body for specific content types and methods
if (!in_array($request->method(), ['POST', 'PUT', 'PATCH', 'DELETE'])) {
return null;
}
$contentType = $request->header('Content-Type', '');
if (str_contains($contentType, 'application/json')) {
return $request->json()->all();
}
if (str_contains($contentType, 'application/x-www-form-urlencoded')) {
return $request->all();
}
if (str_contains($contentType, 'multipart/form-data')) {
// Don't log file uploads, just the form fields
return $request->except(['_token']) + ['files' => array_keys($request->allFiles())];
}
return null;
}
/**
* Get response body safely.
*
* @param \Illuminate\Http\Response|\Symfony\Component\HttpFoundation\Response $response
*/
protected function getResponseBody(mixed $response): mixed
{
if (!method_exists($response, 'getContent')) {
return null;
}
$content = $response->getContent();
// Try to decode JSON responses
if (
is_object($response) && property_exists($response, 'headers') &&
$response->headers->get('Content-Type') &&
str_contains((string) $response->headers->get('Content-Type'), 'application/json')
) {
try {
return json_decode((string) $content, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return ['error' => 'Invalid JSON response'];
}
}
// For other content types, limit length to prevent massive logs
return strlen((string) $content) > 1000 ? substr((string) $content, 0, 1000) . '...' : $content;
}
/**
* Filter sensitive headers.
*
* @param array<string, mixed> $headers
* @return array<string, mixed>
*/
protected function filterHeaders(array $headers): array
{
$sensitiveHeaders = [
'authorization',
'x-api-key',
'x-auth-token',
'cookie',
'set-cookie',
'php-auth-user',
'php-auth-pw',
];
$filtered = [];
foreach ($headers as $name => $value) {
$filtered[$name] = in_array(strtolower($name), $sensitiveHeaders) ? [MaskConstants::MASK_FILTERED] : $value;
}
return $filtered;
}
}

91
src/MaskConstants.php Normal file
View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter;
/**
* Constants for mask replacement values.
*
* This class provides standardized mask values to avoid duplication
* and ensure consistency across the codebase.
*/
final class MaskConstants
{
// Data type masks
public const MASK_INT = '***INT***';
public const MASK_FLOAT = '***FLOAT***';
public const MASK_STRING = '***STRING***';
public const MASK_BOOL = '***BOOL***';
public const MASK_NULL = '***NULL***';
public const MASK_ARRAY = '***ARRAY***';
public const MASK_OBJECT = '***OBJECT***';
public const MASK_RESOURCE = '***RESOURCE***';
// Generic masks
public const MASK_GENERIC = '***'; // Simple generic mask
public const MASK_MASKED = '***MASKED***';
public const MASK_REDACTED = '***REDACTED***';
public const MASK_FILTERED = '***FILTERED***';
public const MASK_BRACKETS = '[MASKED]';
public const MASK_REDACTED_BRACKETS = '[REDACTED]';
// Personal identifiers
public const MASK_HETU = '***HETU***'; // Finnish SSN
public const MASK_SSN = '***SSN***'; // Generic SSN
public const MASK_USSSN = '***USSSN***'; // US SSN
public const MASK_UKNI = '***UKNI***'; // UK National Insurance
public const MASK_CASIN = '***CASIN***'; // Canadian SIN
public const MASK_PASSPORT = '***PASSPORT***';
// Financial information
public const MASK_IBAN = '***IBAN***';
public const MASK_CC = '***CC***'; // Credit Card
public const MASK_CARD = '***CARD***'; // Credit Card (alternative)
public const MASK_UKBANK = '***UKBANK***';
public const MASK_CABANK = '***CABANK***';
// Contact information
public const MASK_EMAIL = '***EMAIL***';
public const MASK_PHONE = '***PHONE***';
public const MASK_IP = '***IP***';
// Security tokens and keys
public const MASK_TOKEN = '***TOKEN***';
public const MASK_APIKEY = '***APIKEY***';
public const MASK_SECRET = '***SECRET***';
// Personal data
public const MASK_DOB = '***DOB***'; // Date of Birth
public const MASK_MAC = '***MAC***'; // MAC Address
// Vehicle and identification
public const MASK_VEHICLE = '***VEHICLE***';
// Healthcare
public const MASK_MEDICARE = '***MEDICARE***';
public const MASK_EHIC = '***EHIC***'; // European Health Insurance Card
// Custom/Internal
public const MASK_INTERNAL = '***INTERNAL***';
public const MASK_CUSTOMER = '***CUSTOMER***';
public const MASK_NUMBER = '***NUMBER***';
public const MASK_ITEM = '***ITEM***';
// Custom mask patterns for partial masking
public const MASK_SSN_PATTERN = '***-**-****'; // SSN with format preserved
public const MASK_EMAIL_PATTERN = '***@***.***'; // Email with format preserved
// Error states
public const MASK_INVALID = '***INVALID***';
public const MASK_TOOLONG = '***TOOLONG***';
public const MASK_ERROR = '***ERROR***';
/**
* Prevent instantiation.
*
* @psalm-suppress UnusedConstructor
*/
private function __construct()
{}
}

291
src/MaskingOrchestrator.php Normal file
View File

@@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter;
use Error;
use Ivuorinen\MonologGdprFilter\ArrayAccessor\ArrayAccessorFactory;
/**
* Coordinates masking operations across different processors.
*
* This class orchestrates the masking workflow:
* 1. Applies regex patterns to messages
* 2. Processes field paths in context data
* 3. Executes custom callbacks
* 4. Applies data type masking
*
* Separated from GdprProcessor to enable use outside Monolog context.
*
* @api
*/
final class MaskingOrchestrator
{
private readonly DataTypeMasker $dataTypeMasker;
private readonly JsonMasker $jsonMasker;
private readonly ContextProcessor $contextProcessor;
private readonly RecursiveProcessor $recursiveProcessor;
private readonly ArrayAccessorFactory $arrayAccessorFactory;
/**
* @var callable(string,mixed,mixed):void|null
*/
private $auditLogger;
/**
* @param array<string,string> $patterns Regex pattern => replacement
* @param array<string,FieldMaskConfig|string> $fieldPaths Dot-notation path => FieldMaskConfig
* @param array<string,callable(mixed):string> $customCallbacks Dot-notation path => callback(value): string
* @param callable(string,mixed,mixed):void|null $auditLogger Optional audit logger callback
* @param int $maxDepth Maximum recursion depth for nested structures
* @param array<string,string> $dataTypeMasks Type-based masking: type => mask pattern
* @param ArrayAccessorFactory|null $arrayAccessorFactory Factory for creating array accessors
*/
public function __construct(
private readonly array $patterns,
private readonly array $fieldPaths = [],
private readonly array $customCallbacks = [],
?callable $auditLogger = null,
int $maxDepth = 100,
array $dataTypeMasks = [],
?ArrayAccessorFactory $arrayAccessorFactory = null
) {
$this->auditLogger = $auditLogger;
$this->arrayAccessorFactory = $arrayAccessorFactory ?? ArrayAccessorFactory::default();
// Initialize data type masker
$this->dataTypeMasker = new DataTypeMasker($dataTypeMasks, $auditLogger);
// Initialize recursive processor for data structure processing
$this->recursiveProcessor = new RecursiveProcessor(
$this->regExpMessage(...),
$this->dataTypeMasker,
$auditLogger,
$maxDepth
);
// Initialize JSON masker with recursive mask callback
/** @psalm-suppress InvalidArgument - recursiveMask is intentionally impure due to audit logging */
$this->jsonMasker = new JsonMasker(
$this->recursiveProcessor->recursiveMask(...),
$auditLogger
);
// Initialize context processor for field-level operations
$this->contextProcessor = new ContextProcessor(
$fieldPaths,
$customCallbacks,
$auditLogger,
$this->regExpMessage(...)
);
}
/**
* Create an orchestrator with validated parameters.
*
* @param array<string,string> $patterns Regex pattern => replacement
* @param array<string,FieldMaskConfig|string> $fieldPaths Dot-notation path => FieldMaskConfig
* @param array<string,callable(mixed):string> $customCallbacks Dot-notation path => callback
* @param callable(string,mixed,mixed):void|null $auditLogger Optional audit logger callback
* @param int $maxDepth Maximum recursion depth for nested structures
* @param array<string,string> $dataTypeMasks Type-based masking
* @param ArrayAccessorFactory|null $arrayAccessorFactory Factory for creating array accessors
*
* @throws \InvalidArgumentException When any parameter is invalid
*/
public static function create(
array $patterns,
array $fieldPaths = [],
array $customCallbacks = [],
?callable $auditLogger = null,
int $maxDepth = 100,
array $dataTypeMasks = [],
?ArrayAccessorFactory $arrayAccessorFactory = null
): self {
// Validate all parameters
InputValidator::validateAll(
$patterns,
$fieldPaths,
$customCallbacks,
$auditLogger,
$maxDepth,
$dataTypeMasks,
[]
);
// Pre-validate and cache patterns for better performance
/** @psalm-suppress DeprecatedMethod - Internal use of caching mechanism */
PatternValidator::cachePatterns($patterns);
return new self(
$patterns,
$fieldPaths,
$customCallbacks,
$auditLogger,
$maxDepth,
$dataTypeMasks,
$arrayAccessorFactory
);
}
/**
* Process data by masking sensitive information.
*
* @param string $message The message to mask
* @param array<string,mixed> $context The context data to mask
* @return array{message: string, context: array<string,mixed>}
*/
public function process(string $message, array $context): array
{
$maskedMessage = $this->regExpMessage($message);
$maskedContext = $this->processContext($context);
return [
'message' => $maskedMessage,
'context' => $maskedContext,
];
}
/**
* Process context data by masking sensitive information.
*
* @param array<string,mixed> $context The context data to mask
* @return array<string,mixed>
*/
public function processContext(array $context): array
{
$accessor = $this->arrayAccessorFactory->create($context);
$processedFields = [];
if ($this->fieldPaths !== []) {
$processedFields = array_merge($processedFields, $this->contextProcessor->maskFieldPaths($accessor));
}
if ($this->customCallbacks !== []) {
$processedFields = array_merge(
$processedFields,
$this->contextProcessor->processCustomCallbacks($accessor)
);
}
if ($this->fieldPaths !== [] || $this->customCallbacks !== []) {
$context = $accessor->all();
// Apply data type masking to the entire context after field/callback processing
return $this->dataTypeMasker->applyToContext(
$context,
$processedFields,
'',
$this->recursiveProcessor->recursiveMask(...)
);
}
return $this->recursiveProcessor->recursiveMask($context, 0);
}
/**
* Mask a string using all regex patterns with JSON support.
*/
public function regExpMessage(string $message = ''): string
{
// Early return for empty messages
if ($message === '') {
return $message;
}
// Track original message for empty result protection
$originalMessage = $message;
// Handle JSON strings and regular patterns in a coordinated way
$message = $this->maskMessageWithJsonSupport($message);
return $message === '' || $message === '0' ? $originalMessage : $message;
}
/**
* Mask message content, handling both JSON structures and regular patterns.
*/
private function maskMessageWithJsonSupport(string $message): string
{
// Use JsonMasker to process JSON structures
$result = $this->jsonMasker->processMessage($message);
// Now apply regular patterns to the entire result
foreach ($this->patterns as $regex => $replacement) {
try {
/** @psalm-suppress ArgumentTypeCoercion */
$newResult = preg_replace($regex, $replacement, $result, -1, $count);
if ($newResult === null) {
$error = preg_last_error_msg();
if ($this->auditLogger !== null) {
($this->auditLogger)('preg_replace_error', $result, 'Error: ' . $error);
}
continue;
}
if ($count > 0) {
$result = $newResult;
}
} catch (Error $e) {
if ($this->auditLogger !== null) {
($this->auditLogger)('regex_error', $regex, $e->getMessage());
}
continue;
}
}
return $result;
}
/**
* Recursively mask all string values in an array using regex patterns.
*
* @param array<mixed>|string $data
* @param int $currentDepth Current recursion depth
* @return array<mixed>|string
*/
public function recursiveMask(array|string $data, int $currentDepth = 0): array|string
{
return $this->recursiveProcessor->recursiveMask($data, $currentDepth);
}
/**
* Get the context processor for direct access.
*/
public function getContextProcessor(): ContextProcessor
{
return $this->contextProcessor;
}
/**
* Get the recursive processor for direct access.
*/
public function getRecursiveProcessor(): RecursiveProcessor
{
return $this->recursiveProcessor;
}
/**
* Get the array accessor factory.
*/
public function getArrayAccessorFactory(): ArrayAccessorFactory
{
return $this->arrayAccessorFactory;
}
/**
* Set the audit logger callable.
*
* @param callable(string,mixed,mixed):void|null $auditLogger
*/
public function setAuditLogger(?callable $auditLogger): void
{
$this->auditLogger = $auditLogger;
$this->contextProcessor->setAuditLogger($auditLogger);
$this->recursiveProcessor->setAuditLogger($auditLogger);
}
}

307
src/PatternValidator.php Normal file
View File

@@ -0,0 +1,307 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter;
use Error;
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidRegexPatternException;
/**
* Validates regex patterns for safety and correctness.
*
* This class provides pattern validation with ReDoS (Regular Expression Denial of Service)
* protection and caching for improved performance.
*
* @api
*/
final class PatternValidator
{
/**
* Instance cache for compiled regex patterns.
* @var array<string, bool>
*/
private array $instanceCache = [];
/**
* Static cache for compiled regex patterns (for backward compatibility).
* @var array<string, bool>
*/
private static array $validPatternCache = [];
/**
* Dangerous pattern checks.
* @var list<non-empty-string>
*/
private static array $dangerousPatterns = [
// Nested quantifiers (classic ReDoS patterns)
'/\([^)]*\+[^)]*\)\+/', // (a+)+ pattern
'/\([^)]*\*[^)]*\)\*/', // (a*)* pattern
'/\([^)]*\+[^)]*\)\*/', // (a+)* pattern
'/\([^)]*\*[^)]*\)\+/', // (a*)+ pattern
// Alternation with overlapping patterns
'/\([^|)]*\|[^|)]*\)\*/', // (a|a)* pattern
'/\([^|)]*\|[^|)]*\)\+/', // (a|a)+ pattern
// Complex nested structures
'/\(\([^)]*\+[^)]*\)[^)]*\)\+/', // ((a+)...)+ pattern
// Character classes with nested quantifiers
'/\[[^\]]*\]\*\*/', // [a-z]** pattern
'/\[[^\]]*\]\+\+/', // [a-z]++ pattern
'/\([^)]*\[[^\]]*\][^)]*\)\*/', // ([a-z])* pattern
'/\([^)]*\[[^\]]*\][^)]*\)\+/', // ([a-z])+ pattern
// Lookahead/lookbehind with quantifiers
'/\(\?\=[^)]*\)\([^)]*\)\+/', // (?=...)(...)+
'/\(\?\<[^)]*\)\([^)]*\)\+/', // (?<...)(...)+
// Word boundaries with dangerous quantifiers
'/\\\\w\+\*/', // \w+* pattern
'/\\\\w\*\+/', // \w*+ pattern
// Dot with dangerous quantifiers
'/\.\*\*/', // .** pattern
'/\.\+\+/', // .++ pattern
'/\(\.\*\)\+/', // (.*)+ pattern
'/\(\.\+\)\*/', // (.+)* pattern
// Legacy dangerous patterns (keeping for backward compatibility)
'/\(\?.*\*.*\+/', // (?:...*...)+
'/\(.*\*.*\).*\*/', // (...*...).*
// Overlapping alternation patterns - catastrophic backtracking
'/\(\.\*\s*\|\s*\.\*\)/', // (.*|.*) pattern - identical alternations
'/\(\.\+\s*\|\s*\.\+\)/', // (.+|.+) pattern - identical alternations
// Multiple alternations with overlapping/expanding strings causing exponential backtracking
// Matches patterns like (a|ab|abc|abcd)* where alternatives overlap/extend each other
'/\([a-zA-Z0-9]+(\s*\|\s*[a-zA-Z0-9]+){2,}\)\*/',
'/\([a-zA-Z0-9]+(\s*\|\s*[a-zA-Z0-9]+){2,}\)\+/',
];
/**
* Create a new PatternValidator instance.
*/
public function __construct()
{
// Instance cache starts empty
}
/**
* Static factory method.
*/
public static function create(): self
{
return new self();
}
/**
* Clear the instance cache.
*/
public function clearInstanceCache(): void
{
$this->instanceCache = [];
}
/**
* Validate that a regex pattern is safe and well-formed.
*/
public function validate(string $pattern): bool
{
// Check instance cache first
if (isset($this->instanceCache[$pattern])) {
return $this->instanceCache[$pattern];
}
$isValid = $this->performValidation($pattern);
$this->instanceCache[$pattern] = $isValid;
return $isValid;
}
/**
* Pre-validate patterns for better runtime performance.
*
* @param array<string, string> $patterns
*/
public function cacheAllPatterns(array $patterns): void
{
foreach (array_keys($patterns) as $pattern) {
if (!isset($this->instanceCache[$pattern])) {
$this->instanceCache[$pattern] = $this->validate($pattern);
}
}
}
/**
* Validate all patterns for security before use.
*
* @param array<string, string> $patterns
* @throws InvalidRegexPatternException If any pattern is invalid or unsafe
*/
public function validateAllPatterns(array $patterns): void
{
foreach (array_keys($patterns) as $pattern) {
if (!$this->validate($pattern)) {
throw InvalidRegexPatternException::forPattern(
$pattern,
'Pattern failed validation or is potentially unsafe'
);
}
}
}
/**
* Get the instance cache.
*
* @return array<string, bool>
*/
public function getInstanceCache(): array
{
return $this->instanceCache;
}
/**
* Perform the actual validation logic.
*/
private function performValidation(string $pattern): bool
{
// Check for basic regex structure
$firstChar = $pattern[0];
$lastDelimPos = strrpos($pattern, $firstChar);
// Consolidated validation checks - return false if any basic check fails
if (
strlen($pattern) < 3
|| $lastDelimPos === false
|| $lastDelimPos === 0
|| $this->checkDangerousPattern($pattern)
) {
return false;
}
// Test if the pattern is valid by trying to compile it
return $this->testPatternCompilation($pattern);
}
/**
* Check if a pattern contains dangerous constructs that could cause ReDoS.
*/
private function checkDangerousPattern(string $pattern): bool
{
foreach (self::$dangerousPatterns as $dangerousPattern) {
if (preg_match($dangerousPattern, $pattern)) {
return true;
}
}
return false;
}
/**
* Test if the pattern compiles successfully.
*/
private function testPatternCompilation(string $pattern): bool
{
set_error_handler(
/**
* @return true
*/
static fn(): bool => true
);
try {
/** @psalm-suppress ArgumentTypeCoercion */
$result = preg_match($pattern, '');
return $result !== false;
} catch (Error) {
return false;
} finally {
restore_error_handler();
}
}
// =========================================================================
// DEPRECATED STATIC METHODS - Use instance methods instead
// =========================================================================
/**
* Clear the pattern validation cache (useful for testing).
*
* @deprecated Use instance method clearInstanceCache() instead
*/
public static function clearCache(): void
{
self::$validPatternCache = [];
}
/**
* Validate that a regex pattern is safe and well-formed.
* This helps prevent regex injection and ReDoS attacks.
*
* @deprecated Use instance method validate() instead
*/
public static function isValid(string $pattern): bool
{
// Check cache first
if (isset(self::$validPatternCache[$pattern])) {
return self::$validPatternCache[$pattern];
}
$validator = new self();
$isValid = $validator->performValidation($pattern);
self::$validPatternCache[$pattern] = $isValid;
return $isValid;
}
/**
* Pre-validate patterns during construction for better runtime performance.
*
* @param array<string, string> $patterns
* @deprecated Use instance method cacheAllPatterns() instead
*/
public static function cachePatterns(array $patterns): void
{
foreach (array_keys($patterns) as $pattern) {
if (!isset(self::$validPatternCache[$pattern])) {
/** @psalm-suppress DeprecatedMethod - Internal self-call within deprecated method */
self::$validPatternCache[$pattern] = self::isValid($pattern);
}
}
}
/**
* Validate all patterns for security before use.
* This method can be called to validate patterns before creating a processor.
*
* @param array<string, string> $patterns
* @throws InvalidRegexPatternException If any pattern is invalid or unsafe
* @deprecated Use instance method validateAllPatterns() instead
*/
public static function validateAll(array $patterns): void
{
foreach (array_keys($patterns) as $pattern) {
/** @psalm-suppress DeprecatedMethod - Internal self-call within deprecated method */
if (!self::isValid($pattern)) {
throw InvalidRegexPatternException::forPattern(
$pattern,
'Pattern failed validation or is potentially unsafe'
);
}
}
}
/**
* Get the current pattern cache.
*
* @return array<string, bool>
* @deprecated Use instance method getInstanceCache() instead
*/
public static function getCache(): array
{
return self::$validPatternCache;
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Plugins;
use Ivuorinen\MonologGdprFilter\Contracts\MaskingPluginInterface;
/**
* Abstract base class for masking plugins.
*
* Provides default no-op implementations for all plugin methods,
* allowing plugins to override only the methods they need.
*
* @api
*/
abstract class AbstractMaskingPlugin implements MaskingPluginInterface
{
/**
* @param int $priority Plugin priority (lower = earlier execution, default: 100)
*/
public function __construct(
protected readonly int $priority = 100
) {
}
/**
* @inheritDoc
*/
public function preProcessContext(array $context): array
{
return $context;
}
/**
* @inheritDoc
*/
public function postProcessContext(array $context): array
{
return $context;
}
/**
* @inheritDoc
*/
public function preProcessMessage(string $message): string
{
return $message;
}
/**
* @inheritDoc
*/
public function postProcessMessage(string $message): string
{
return $message;
}
/**
* @inheritDoc
*/
public function getPatterns(): array
{
return [];
}
/**
* @inheritDoc
*/
public function getFieldPaths(): array
{
return [];
}
/**
* @inheritDoc
*/
public function getPriority(): int
{
return $this->priority;
}
}

View File

@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter;
/**
* Rate-limited wrapper for audit logging to prevent log flooding.
*
* This class wraps any audit logger callable and applies rate limiting
* to prevent overwhelming the audit system with too many log entries.
*
* @api
*/
class RateLimitedAuditLogger
{
private readonly RateLimiter $rateLimiter;
/**
* @param callable(string,mixed,mixed):void $auditLogger The underlying audit logger
* @param int $maxRequestsPerMinute Maximum audit log entries per minute (default: 100)
* @param int $windowSeconds Time window for rate limiting in seconds (default: 60)
*/
public function __construct(
private readonly mixed $auditLogger,
int $maxRequestsPerMinute = 100,
int $windowSeconds = 60
) {
$this->rateLimiter = new RateLimiter($maxRequestsPerMinute, $windowSeconds);
}
/**
* Log an audit entry if rate limiting allows it.
*
* @param string $path The path or operation being audited
* @param mixed $original The original value
* @param mixed $masked The masked value
*/
public function __invoke(string $path, mixed $original, mixed $masked): void
{
// Use a combination of path and operation type as the rate limiting key
$key = $this->generateRateLimitKey($path);
if ($this->rateLimiter->isAllowed($key)) {
// Rate limit allows this log entry
/** @psalm-suppress RedundantConditionGivenDocblockType - Runtime validation for defensive programming */
if (is_callable($this->auditLogger)) {
($this->auditLogger)($path, $original, $masked);
}
} else {
// Rate limit exceeded - optionally log a rate limit warning
$this->logRateLimitExceeded($path, $key);
}
}
public function isOperationAllowed(string $path): bool
{
// Use a combination of path and operation type as the rate limiting key
$key = $this->generateRateLimitKey($path);
return $this->rateLimiter->isAllowed($key);
}
/**
* Get rate limiting statistics for all active operation types.
*
* @return int[][]
*
* @psalm-return array{
* 'audit:general_operations'?: array{
* current_requests: int<1, max>,
* remaining_requests: int<0, max>,
* time_until_reset: int<0, max>
* },
* 'audit:error_operations'?: array{
* current_requests: int<1, max>,
* remaining_requests: int<0, max>,
* time_until_reset: int<0, max>
* },
* 'audit:regex_operations'?: array{
* current_requests: int<1, max>,
* remaining_requests: int<0, max>,
* time_until_reset: int<0, max>
* },
* 'audit:conditional_operations'?: array{
* current_requests: int<1, max>,
* remaining_requests: int<0, max>,
* time_until_reset: int<0, max>
* },
* 'audit:json_operations'?: array{
* current_requests: int<1, max>,
* remaining_requests: int<0, max>,
* time_until_reset: int<0, max>
* }
* }
*/
public function getRateLimitStats(): array
{
// Get all possible operation types based on the classification logic
$operationTypes = [
'audit:json_operations',
'audit:conditional_operations',
'audit:regex_operations',
'audit:error_operations',
'audit:general_operations'
];
$stats = [];
foreach ($operationTypes as $type) {
$typeStats = $this->rateLimiter->getStats($type);
// Only include operation types that have been used
if ($typeStats['current_requests'] > 0) {
$stats[$type] = $typeStats;
}
}
return $stats;
}
/**
* Clear all rate limiting data.
*/
public function clearRateLimitData(): void
{
RateLimiter::clearAll();
}
/**
* Generate a rate limiting key based on the audit operation.
*
* This allows different types of operations to have separate rate limits.
*/
private function generateRateLimitKey(string $path): string
{
// Group similar operations together to prevent flooding of specific operation types
$operationType = $this->getOperationType($path);
// Use operation type as the primary key for rate limiting
return 'audit:' . $operationType;
}
/**
* Determine the operation type from the path.
*/
private function getOperationType(string $path): string
{
// Group different operations into categories for rate limiting
return match (true) {
str_contains($path, 'json_') => 'json_operations',
str_contains($path, 'conditional_') => 'conditional_operations',
str_contains($path, 'regex_') => 'regex_operations',
str_contains($path, 'preg_replace_') => 'regex_operations',
str_contains($path, 'error') => 'error_operations',
default => 'general_operations'
};
}
/**
* Log when rate limiting is exceeded (with its own rate limiting to prevent spam).
*/
private function logRateLimitExceeded(string $path, string $key): void
{
// Create a separate rate limiter for warnings to avoid interfering with main rate limiting
static $warningRateLimiter = null;
if ($warningRateLimiter === null) {
$warningRateLimiter = new RateLimiter(1, 60); // 1 warning per minute per operation type
}
$warningKey = 'warning:' . $key;
// Only log rate limit warnings once per minute per operation type to prevent warning spam
/** @psalm-suppress RedundantConditionGivenDocblockType - Runtime validation for defensive programming */
if ($warningRateLimiter->isAllowed($warningKey) === true && is_callable($this->auditLogger)) {
$statsJson = json_encode($this->rateLimiter->getStats($key));
($this->auditLogger)(
'rate_limit_exceeded',
$path,
sprintf(
'Audit logging rate limit exceeded for operation type: %s. Stats: %s',
$key,
$statsJson !== false ? $statsJson : 'N/A'
)
);
}
}
/**
* Create a factory method for common configurations.
*
* @psalm-param callable(string, mixed, mixed):void $auditLogger
*/
public static function create(
callable $auditLogger,
string $profile = 'default'
): self {
return match ($profile) {
'strict' => new self($auditLogger, 50, 60), // 50 per minute
'relaxed' => new self($auditLogger, 200, 60), // 200 per minute
'testing' => new self($auditLogger, 1000, 60), // 1000 per minute for testing
default => new self($auditLogger, 100, 60), // 100 per minute (default)
};
}
}

314
src/RateLimiter.php Normal file
View File

@@ -0,0 +1,314 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter;
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidRateLimitConfigurationException;
/**
* Simple rate limiter to prevent audit log flooding.
*
* Uses a sliding window approach with memory-based storage.
* For production use, consider implementing persistent storage.
*
* @api
*/
class RateLimiter
{
/**
* Storage for request timestamps per key.
* @var array<string, array<int>>
*/
private static array $requests = [];
/**
* Last time global cleanup was performed.
*/
private static int $lastCleanup = 0;
/**
* How often to perform global cleanup (in seconds).
*/
private static int $cleanupInterval = 300; // 5 minutes
/**
* @param int $maxRequests Maximum number of requests allowed
* @param int $windowSeconds Time window in seconds
*
* @throws InvalidRateLimitConfigurationException When parameters are invalid
*/
public function __construct(
private readonly int $maxRequests,
private readonly int $windowSeconds
) {
// Validate maxRequests
if ($this->maxRequests <= 0) {
throw InvalidRateLimitConfigurationException::invalidMaxRequests($this->maxRequests);
}
if ($this->maxRequests > 1000000) {
throw InvalidRateLimitConfigurationException::forParameter(
'max_requests',
$this->maxRequests,
'Cannot exceed 1,000,000 for memory safety'
);
}
// Validate windowSeconds
if ($this->windowSeconds <= 0) {
throw InvalidRateLimitConfigurationException::invalidTimeWindow($this->windowSeconds);
}
if ($this->windowSeconds > 86400) { // 24 hours max
throw InvalidRateLimitConfigurationException::forParameter(
'window_seconds',
$this->windowSeconds,
'Cannot exceed 86,400 (24 hours) for practical reasons'
);
}
}
/**
* Check if a request is allowed for the given key.
*
* @throws InvalidRateLimitConfigurationException When key is invalid
*/
public function isAllowed(string $key): bool
{
$this->validateKey($key);
$now = time();
$windowStart = $now - $this->windowSeconds;
// Initialize key if not exists
if (!isset(self::$requests[$key])) {
self::$requests[$key] = [];
}
// Remove old requests outside the window
self::$requests[$key] = array_filter(
self::$requests[$key],
fn(int $timestamp): bool => $timestamp > $windowStart
);
// Perform global cleanup periodically to prevent memory leaks
$this->performGlobalCleanupIfNeeded($now);
// Check if we're under the limit
if (count(self::$requests[$key] ?? []) < $this->maxRequests) {
// Add current request
self::$requests[$key][] = $now;
return true;
}
return false;
}
/**
* Get time until next request is allowed (in seconds).
*
* @psalm-return int<0, max>
* @throws InvalidRateLimitConfigurationException When key is invalid
*/
public function getTimeUntilReset(string $key): int
{
$this->validateKey($key);
if (!isset(self::$requests[$key]) || empty(self::$requests[$key])) {
return 0;
}
$now = time();
$oldestRequest = min(self::$requests[$key]);
$resetTime = $oldestRequest + $this->windowSeconds;
return max(0, $resetTime - $now);
}
/**
* Get statistics for a specific key.
*
* @return int[]
*
* @psalm-return array{
* current_requests: int<0, max>,
* remaining_requests: int<0, max>,
* time_until_reset: int<0, max>
* }
* @throws InvalidRateLimitConfigurationException When key is invalid
*/
public function getStats(string $key): array
{
$this->validateKey($key);
$now = time();
$windowStart = $now - $this->windowSeconds;
$currentRequests = 0;
if (isset(self::$requests[$key])) {
$currentRequests = count(array_filter(
self::$requests[$key],
fn(int $timestamp): bool => $timestamp > $windowStart
));
}
return [
'current_requests' => $currentRequests,
'remaining_requests' => max(0, $this->maxRequests - $currentRequests),
'time_until_reset' => $this->getTimeUntilReset($key),
];
}
/**
* Get remaining requests for a specific key.
*
* @param string $key The rate limiting key
* @return int The number of remaining requests
*
* @psalm-return int<0, max>
* @throws InvalidRateLimitConfigurationException When key is invalid
*/
public function getRemainingRequests(string $key): int
{
$this->validateKey($key);
return $this->getStats($key)['remaining_requests'] ?? 0;
}
public static function clearAll(): void
{
self::$requests = [];
}
public static function clearKey(string $key): void
{
self::validateKeyStatic($key);
if (isset(self::$requests[$key])) {
unset(self::$requests[$key]);
}
}
/**
* Perform global cleanup if enough time has passed.
* This prevents memory leaks from accumulating unused keys.
*/
private function performGlobalCleanupIfNeeded(int $now): void
{
if ($now - self::$lastCleanup >= self::$cleanupInterval) {
$this->performGlobalCleanup($now);
self::$lastCleanup = $now;
}
}
/**
* Clean up all expired entries across all keys.
* This prevents memory leaks from accumulating old unused keys.
*/
private function performGlobalCleanup(int $now): void
{
$windowStart = $now - $this->windowSeconds;
foreach (self::$requests as $key => $timestamps) {
// Filter out old timestamps
$validTimestamps = array_filter(
$timestamps,
fn(int $timestamp): bool => $timestamp > $windowStart
);
if ($validTimestamps === []) {
// Remove keys with no valid timestamps
unset(self::$requests[$key]);
} else {
// Update with filtered timestamps
self::$requests[$key] = array_values($validTimestamps);
}
}
}
/**
* Get memory usage statistics for debugging.
*
* @return int[]
*
* @psalm-return array{
* total_keys: int<0, max>,
* total_timestamps: int,
* estimated_memory_bytes: int<min, max>,
* last_cleanup: int,
* cleanup_interval: int
* }
*/
public static function getMemoryStats(): array
{
$totalKeys = count(self::$requests);
$totalTimestamps = array_sum(array_map(count(...), self::$requests));
$estimatedMemory = $totalKeys * 50 + $totalTimestamps * 8; // Rough estimate
return [
'total_keys' => $totalKeys,
'total_timestamps' => $totalTimestamps,
'estimated_memory_bytes' => $estimatedMemory,
'last_cleanup' => self::$lastCleanup,
'cleanup_interval' => self::$cleanupInterval,
];
}
/**
* Configure the global cleanup interval.
*
* @param int $seconds Cleanup interval in seconds (minimum 60)
* @throws InvalidRateLimitConfigurationException When seconds is invalid
*/
public static function setCleanupInterval(int $seconds): void
{
if ($seconds <= 0) {
throw InvalidRateLimitConfigurationException::invalidCleanupInterval($seconds);
}
if ($seconds < 60) {
throw InvalidRateLimitConfigurationException::cleanupIntervalTooShort($seconds, 60);
}
if ($seconds > 604800) { // 1 week max
throw InvalidRateLimitConfigurationException::forParameter(
'cleanup_interval',
$seconds,
'Cannot exceed 604,800 seconds (1 week) for practical reasons'
);
}
self::$cleanupInterval = $seconds;
}
/**
* Validate a rate limiting key.
*
* @param string $key The key to validate
* @throws InvalidRateLimitConfigurationException When key is invalid
*/
private function validateKey(string $key): void
{
self::validateKeyStatic($key);
}
/**
* Static version of key validation for use in static methods.
*
* @param string $key The key to validate
* @throws InvalidRateLimitConfigurationException When key is invalid
*/
private static function validateKeyStatic(string $key): void
{
if (trim($key) === '') {
throw InvalidRateLimitConfigurationException::emptyKey();
}
if (strlen($key) > 250) {
throw InvalidRateLimitConfigurationException::keyTooLong($key, 250);
}
// Check for potential problematic characters that could cause issues
if (preg_match('/[\x00-\x1F\x7F]/', $key)) {
throw InvalidRateLimitConfigurationException::invalidKeyFormat(
'Rate limiting key cannot contain control characters'
);
}
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Recovery;
/**
* Defines how the processor should behave when masking operations fail.
*
* @api
*/
enum FailureMode: string
{
/**
* Fail open: On failure, return the original value unmasked.
*
* Use this when availability is more important than privacy,
* but be aware this may expose sensitive data in error scenarios.
*/
case FAIL_OPEN = 'fail_open';
/**
* Fail closed: On failure, return a completely masked/redacted value.
*
* Use this when privacy is critical and you'd rather lose data
* than risk exposing sensitive information.
*/
case FAIL_CLOSED = 'fail_closed';
/**
* Fail safe: On failure, apply a conservative fallback mask.
*
* This is the recommended default. It attempts to provide useful
* information while still protecting potentially sensitive data.
*/
case FAIL_SAFE = 'fail_safe';
/**
* Get a human-readable description of this failure mode.
*/
public function getDescription(): string
{
return match ($this) {
self::FAIL_OPEN => 'Return original value on failure (risky)',
self::FAIL_CLOSED => 'Return fully redacted value on failure (strict)',
self::FAIL_SAFE => 'Apply conservative fallback mask on failure (balanced)',
};
}
/**
* Get the recommended failure mode for production environments.
*/
public static function recommended(): self
{
return self::FAIL_SAFE;
}
}

View File

@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Recovery;
use Ivuorinen\MonologGdprFilter\MaskConstants;
/**
* Provides fallback mask values for different data types and scenarios.
*
* Used by recovery strategies to determine appropriate masked values
* when masking operations fail.
*
* @api
*/
final class FallbackMaskStrategy
{
/**
* @param array<string, string> $customFallbacks Custom fallback values by type
* @param string $defaultFallback Default fallback for unknown types
* @param bool $preserveType Whether to try preserving the original type
*/
public function __construct(
private readonly array $customFallbacks = [],
private readonly string $defaultFallback = MaskConstants::MASK_MASKED,
private readonly bool $preserveType = true,
) {
}
/**
* Create a strategy with default fallback values.
*/
public static function default(): self
{
return new self();
}
/**
* Create a strict strategy that always uses the same mask.
*/
public static function strict(string $mask = MaskConstants::MASK_REDACTED): self
{
return new self(
defaultFallback: $mask,
preserveType: false
);
}
/**
* Create a strategy with custom type mappings.
*
* @param array<string, string> $typeMappings Type name => fallback value
*/
public static function withMappings(array $typeMappings): self
{
return new self(customFallbacks: $typeMappings);
}
/**
* Get the appropriate fallback value for a given original value.
*
* @param mixed $originalValue The original value that couldn't be masked
* @param FailureMode $mode The failure mode to apply
*/
public function getFallback(
mixed $originalValue,
FailureMode $mode = FailureMode::FAIL_SAFE
): mixed {
return match ($mode) {
FailureMode::FAIL_OPEN => $originalValue,
FailureMode::FAIL_CLOSED => $this->getClosedFallback(),
FailureMode::FAIL_SAFE => $this->getSafeFallback($originalValue),
};
}
/**
* Get fallback for FAIL_CLOSED mode.
*/
private function getClosedFallback(): string
{
return $this->customFallbacks['closed'] ?? MaskConstants::MASK_REDACTED;
}
/**
* Get fallback for FAIL_SAFE mode (type-aware).
*/
private function getSafeFallback(mixed $originalValue): mixed
{
$type = gettype($originalValue);
// Check for custom fallback first
if (isset($this->customFallbacks[$type])) {
return $this->customFallbacks[$type];
}
// If not preserving type, return default
if (!$this->preserveType) {
return $this->defaultFallback;
}
// Return type-appropriate fallback
return match ($type) {
'string' => $this->getStringFallback($originalValue),
'integer' => MaskConstants::MASK_INT,
'double' => MaskConstants::MASK_FLOAT,
'boolean' => MaskConstants::MASK_BOOL,
'array' => $this->getArrayFallback($originalValue),
'object' => $this->getObjectFallback($originalValue),
'NULL' => MaskConstants::MASK_NULL,
'resource', 'resource (closed)' => MaskConstants::MASK_RESOURCE,
default => $this->defaultFallback,
};
}
/**
* Get fallback for string values.
*
* @param string $originalValue
*/
private function getStringFallback(string $originalValue): string
{
// Try to preserve length indication
$length = strlen($originalValue);
if ($length <= 10) {
return MaskConstants::MASK_STRING;
}
return sprintf('%s (%d chars)', MaskConstants::MASK_STRING, $length);
}
/**
* Get fallback for array values.
*
* @param array<mixed> $originalValue
*/
private function getArrayFallback(array $originalValue): string
{
$count = count($originalValue);
if ($count === 0) {
return MaskConstants::MASK_ARRAY;
}
return sprintf('%s (%d items)', MaskConstants::MASK_ARRAY, $count);
}
/**
* Get fallback for object values.
*/
private function getObjectFallback(object $originalValue): string
{
$class = $originalValue::class;
// Extract just the class name without namespace
$lastBackslash = strrpos($class, '\\');
$shortClass = $lastBackslash !== false
? substr($class, $lastBackslash + 1)
: $class;
return sprintf('%s (%s)', MaskConstants::MASK_OBJECT, $shortClass);
}
/**
* Get a description of this strategy's configuration.
*
* @return array<string, mixed>
*/
public function getConfiguration(): array
{
return [
'custom_fallbacks' => $this->customFallbacks,
'default_fallback' => $this->defaultFallback,
'preserve_type' => $this->preserveType,
];
}
}

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Recovery;
use Ivuorinen\MonologGdprFilter\Audit\AuditContext;
use Ivuorinen\MonologGdprFilter\Audit\ErrorContext;
/**
* Result of a recovery operation.
*
* Encapsulates the outcome of attempting an operation with recovery,
* including whether it succeeded, failed, or used a fallback.
*
* @api
*/
final readonly class RecoveryResult
{
public const OUTCOME_SUCCESS = 'success';
public const OUTCOME_RECOVERED = 'recovered';
public const OUTCOME_FALLBACK = 'fallback';
public const OUTCOME_FAILED = 'failed';
/**
* @param mixed $value The resulting value (masked or fallback)
* @param string $outcome The outcome type
* @param int $attempts Number of attempts made
* @param float $totalDurationMs Total time spent including retries
* @param ErrorContext|null $lastError The last error if any occurred
*/
public function __construct(
public mixed $value,
public string $outcome,
public int $attempts = 1,
public float $totalDurationMs = 0.0,
public ?ErrorContext $lastError = null,
) {
}
/**
* Create a success result (first attempt succeeded).
*
* @param mixed $value The masked value
* @param float $durationMs Operation duration
*/
public static function success(mixed $value, float $durationMs = 0.0): self
{
return new self(
value: $value,
outcome: self::OUTCOME_SUCCESS,
attempts: 1,
totalDurationMs: $durationMs,
);
}
/**
* Create a recovered result (succeeded after retry).
*
* @param mixed $value The masked value
* @param int $attempts Number of attempts needed
* @param float $totalDurationMs Total duration including retries
*/
public static function recovered(
mixed $value,
int $attempts,
float $totalDurationMs = 0.0
): self {
return new self(
value: $value,
outcome: self::OUTCOME_RECOVERED,
attempts: $attempts,
totalDurationMs: $totalDurationMs,
);
}
/**
* Create a fallback result (used fallback value after failures).
*
* @param mixed $fallbackValue The fallback value used
* @param int $attempts Number of attempts made before fallback
* @param ErrorContext $lastError The error that triggered fallback
* @param float $totalDurationMs Total duration including retries
*/
public static function fallback(
mixed $fallbackValue,
int $attempts,
ErrorContext $lastError,
float $totalDurationMs = 0.0
): self {
return new self(
value: $fallbackValue,
outcome: self::OUTCOME_FALLBACK,
attempts: $attempts,
totalDurationMs: $totalDurationMs,
lastError: $lastError,
);
}
/**
* Create a failed result (all recovery attempts exhausted).
*
* @param mixed $originalValue The original value (returned as-is)
* @param int $attempts Number of attempts made
* @param ErrorContext $error The final error
* @param float $totalDurationMs Total duration including retries
*/
public static function failed(
mixed $originalValue,
int $attempts,
ErrorContext $error,
float $totalDurationMs = 0.0
): self {
return new self(
value: $originalValue,
outcome: self::OUTCOME_FAILED,
attempts: $attempts,
totalDurationMs: $totalDurationMs,
lastError: $error,
);
}
/**
* Check if the operation was successful (including recovery).
*/
public function isSuccess(): bool
{
return $this->outcome === self::OUTCOME_SUCCESS
|| $this->outcome === self::OUTCOME_RECOVERED;
}
/**
* Check if a fallback was used.
*/
public function usedFallback(): bool
{
return $this->outcome === self::OUTCOME_FALLBACK;
}
/**
* Check if the operation completely failed.
*/
public function isFailed(): bool
{
return $this->outcome === self::OUTCOME_FAILED;
}
/**
* Check if retry was needed.
*/
public function neededRetry(): bool
{
return $this->attempts > 1;
}
/**
* Create an AuditContext from this result.
*
* @param string $operationType The type of operation performed
*/
public function toAuditContext(string $operationType): AuditContext
{
return match ($this->outcome) {
self::OUTCOME_SUCCESS => AuditContext::success(
$operationType,
$this->totalDurationMs
),
self::OUTCOME_RECOVERED => AuditContext::recovered(
$operationType,
$this->attempts,
$this->totalDurationMs
),
default => AuditContext::failed(
$operationType,
$this->lastError ?? ErrorContext::create('unknown', 'Unknown error'),
$this->attempts,
$this->totalDurationMs,
['outcome' => $this->outcome]
),
};
}
/**
* Convert to array for logging/debugging.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
$data = [
'outcome' => $this->outcome,
'attempts' => $this->attempts,
'duration_ms' => round($this->totalDurationMs, 3),
];
if ($this->lastError instanceof ErrorContext) {
$data['error'] = $this->lastError->toArray();
}
return $data;
}
}

Some files were not shown because too many files have changed in this diff Show More