feat: add advanced architecture, documentation, and coverage improvements (#65)

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

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

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

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

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

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

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

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

* docs: add framework integration guides and examples

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

* chore(docker): add Docker development environment

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

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

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

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

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

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

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

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

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

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

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

* chore: tests, update actions, sonarcloud issues

* chore: rector

* fix: more sonarcloud fixes

* chore: more fixes

* refactor: copilot review fix

* chore: rector
This commit is contained in:
2025-12-22 13:38:18 +02:00
committed by GitHub
parent b1eb567b92
commit 8866daaf33
112 changed files with 15391 additions and 607 deletions

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

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Recovery;
use Throwable;
/**
* Interface for implementing recovery strategies when masking operations fail.
*
* Recovery strategies define how the processor should handle errors during
* masking operations, including retry logic and fallback behavior.
*
* @api
*/
interface RecoveryStrategy
{
/**
* Attempt to execute an operation with recovery logic.
*
* @param callable $operation The masking operation to execute
* @param mixed $originalValue The original value being masked
* @param string $path The field path
* @param callable|null $auditLogger Optional audit logger for recovery events
*
* @return RecoveryResult The result of the operation (success or fallback)
*/
public function execute(
callable $operation,
mixed $originalValue,
string $path,
?callable $auditLogger = null
): RecoveryResult;
/**
* Determine if an error is recoverable (worth retrying).
*
* @param Throwable $error The error that occurred
* @return bool True if the operation should be retried
*/
public function isRecoverable(Throwable $error): bool;
/**
* Get the failure mode for this recovery strategy.
*/
public function getFailureMode(): FailureMode;
/**
* Get the maximum number of retry attempts.
*/
public function getMaxAttempts(): int;
/**
* Get configuration information about this strategy.
*
* @return array<string, mixed>
*/
public function getConfiguration(): array;
}

View File

@@ -0,0 +1,307 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Recovery;
use Ivuorinen\MonologGdprFilter\Audit\ErrorContext;
use Ivuorinen\MonologGdprFilter\Exceptions\MaskingOperationFailedException;
use Ivuorinen\MonologGdprFilter\Exceptions\RecursionDepthExceededException;
use Ivuorinen\MonologGdprFilter\MaskConstants;
use Throwable;
/**
* Retry strategy with exponential backoff and fallback behavior.
*
* Attempts to retry failed masking operations with configurable
* delays and maximum attempts, then falls back to a safe value.
*
* @api
*/
final class RetryStrategy implements RecoveryStrategy
{
private const DEFAULT_MAX_ATTEMPTS = 3;
private const DEFAULT_BASE_DELAY_MS = 10;
private const DEFAULT_MAX_DELAY_MS = 100;
/**
* @param int $maxAttempts Maximum number of attempts (1 = no retry)
* @param int $baseDelayMs Base delay in milliseconds for exponential backoff
* @param int $maxDelayMs Maximum delay cap in milliseconds
* @param FailureMode $failureMode How to handle final failure
* @param string|null $fallbackMask Custom fallback mask value
*/
public function __construct(
private readonly int $maxAttempts = self::DEFAULT_MAX_ATTEMPTS,
private readonly int $baseDelayMs = self::DEFAULT_BASE_DELAY_MS,
private readonly int $maxDelayMs = self::DEFAULT_MAX_DELAY_MS,
private readonly FailureMode $failureMode = FailureMode::FAIL_SAFE,
private readonly ?string $fallbackMask = null,
) {
}
/**
* Create a retry strategy with default settings.
*/
public static function default(): self
{
return new self();
}
/**
* Create a strategy that doesn't retry (immediate fallback).
*/
public static function noRetry(FailureMode $failureMode = FailureMode::FAIL_SAFE): self
{
return new self(maxAttempts: 1, failureMode: $failureMode);
}
/**
* Create a strategy optimized for fast recovery.
*/
public static function fast(): self
{
return new self(
maxAttempts: 2,
baseDelayMs: 5,
maxDelayMs: 20,
failureMode: FailureMode::FAIL_SAFE
);
}
/**
* Create a strategy for thorough retry attempts.
*/
public static function thorough(): self
{
return new self(
maxAttempts: 5,
baseDelayMs: 20,
maxDelayMs: 200,
failureMode: FailureMode::FAIL_CLOSED
);
}
public function execute(
callable $operation,
mixed $originalValue,
string $path,
?callable $auditLogger = null
): RecoveryResult {
$startTime = microtime(true);
$lastError = null;
for ($attempt = 1; $attempt <= $this->maxAttempts; $attempt++) {
$attemptResult = $this->handleRetryAttempt($operation, $startTime, $attempt, $path, $auditLogger);
if ($attemptResult['success'] && $attemptResult['result'] instanceof RecoveryResult) {
return $attemptResult['result'];
}
$lastError = $attemptResult['errorContext'];
$exception = $attemptResult['exception'];
if (!$this->shouldContinueRetrying($exception, $attempt)) {
break;
}
}
return $this->applyFallback($originalValue, $path, $startTime, $lastError, $auditLogger);
}
public function isRecoverable(Throwable $error): bool
{
// These errors indicate permanent failures that won't recover with retry
if ($error instanceof RecursionDepthExceededException) {
return false;
}
// Some MaskingOperationFailedException errors are non-recoverable
if ($error instanceof MaskingOperationFailedException) {
$message = $error->getMessage();
$isNonRecoverable = str_contains($message, 'Pattern compilation failed')
|| str_contains($message, 'ReDoS');
return !$isNonRecoverable;
}
// Transient errors like timeouts might recover
return true;
}
public function getFailureMode(): FailureMode
{
return $this->failureMode;
}
public function getMaxAttempts(): int
{
return $this->maxAttempts;
}
public function getConfiguration(): array
{
return [
'max_attempts' => $this->maxAttempts,
'base_delay_ms' => $this->baseDelayMs,
'max_delay_ms' => $this->maxDelayMs,
'failure_mode' => $this->failureMode->value,
'fallback_mask' => $this->fallbackMask ?? '[auto]',
];
}
/**
* Handle a single retry attempt.
*
* @return array{
* success: bool,
* result: RecoveryResult|null,
* errorContext: ErrorContext|null,
* exception: Throwable|null
* }
*/
private function handleRetryAttempt(
callable $operation,
float $startTime,
int $attempt,
string $path,
?callable $auditLogger
): array {
try {
$result = $operation();
$duration = (microtime(true) - $startTime) * 1000.0;
$recoveryResult = $attempt === 1
? RecoveryResult::success($result, $duration)
: RecoveryResult::recovered($result, $attempt, $duration);
return ['success' => true, 'result' => $recoveryResult, 'errorContext' => null, 'exception' => null];
} catch (Throwable $e) {
$errorContext = ErrorContext::fromThrowable($e);
$this->logRetryAttempt($path, $attempt, $errorContext, $auditLogger);
return ['success' => false, 'result' => null, 'errorContext' => $errorContext, 'exception' => $e];
}
}
/**
* Log a retry attempt to the audit logger.
*/
private function logRetryAttempt(
string $path,
int $attempt,
ErrorContext $errorContext,
?callable $auditLogger
): void {
if ($auditLogger !== null && $attempt < $this->maxAttempts) {
$auditLogger(
'recovery_retry',
['path' => $path, 'attempt' => $attempt],
['error' => $errorContext->message, 'will_retry' => true]
);
}
}
/**
* Determine if retry should be continued.
*/
private function shouldContinueRetrying(?Throwable $exception, int $attempt): bool
{
if (!$exception instanceof \Throwable || !$this->isRecoverable($exception)) {
return false;
}
if ($attempt >= $this->maxAttempts) {
return false;
}
$this->delay($attempt);
return true;
}
/**
* Apply fallback value after all retry attempts failed.
*/
private function applyFallback(
mixed $originalValue,
string $path,
float $startTime,
?ErrorContext $lastError,
?callable $auditLogger
): RecoveryResult {
$duration = (microtime(true) - $startTime) * 1000.0;
$fallbackValue = $this->getFallbackValue($originalValue);
$errorContext = $lastError ?? ErrorContext::create('unknown', 'No error captured');
if ($auditLogger !== null) {
$auditLogger(
'recovery_fallback',
['path' => $path, 'mode' => $this->failureMode->value],
['error' => $errorContext->message, 'fallback_applied' => true]
);
}
return RecoveryResult::fallback(
$fallbackValue,
$this->maxAttempts,
$errorContext,
$duration
);
}
/**
* Get the fallback value based on failure mode.
*/
private function getFallbackValue(mixed $originalValue): mixed
{
if ($this->fallbackMask !== null) {
return $this->fallbackMask;
}
return match ($this->failureMode) {
FailureMode::FAIL_OPEN => $originalValue,
FailureMode::FAIL_CLOSED => MaskConstants::MASK_REDACTED,
FailureMode::FAIL_SAFE => $this->getSafeFallback($originalValue),
};
}
/**
* Get a safe fallback value that preserves type information.
*/
private function getSafeFallback(mixed $originalValue): mixed
{
return match (gettype($originalValue)) {
'string' => MaskConstants::MASK_STRING,
'integer' => MaskConstants::MASK_INT,
'double' => MaskConstants::MASK_FLOAT,
'boolean' => MaskConstants::MASK_BOOL,
'array' => MaskConstants::MASK_ARRAY,
'object' => MaskConstants::MASK_OBJECT,
'NULL' => MaskConstants::MASK_NULL,
default => MaskConstants::MASK_MASKED,
};
}
/**
* Apply exponential backoff delay.
*
* @param int $attempt Current attempt number (1-based)
*/
private function delay(int $attempt): void
{
// Exponential backoff: baseDelay * 2^(attempt-1)
$delay = $this->baseDelayMs * (2 ** ($attempt - 1));
// Apply jitter (random 0-25% of delay)
$jitterMax = (int) floor((float) $delay * 0.25);
$jitter = random_int(0, $jitterMax);
$delay += $jitter;
// Cap at max delay
$delay = min($delay, $this->maxDelayMs);
// Convert to microseconds and sleep
usleep($delay * 1000);
}
}