Files
monolog-gdpr-filter/demo/PatternTester.php
Ismo Vuorinen 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

294 lines
8.8 KiB
PHP

<?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;
}
}