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,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

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

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter;
use Adbar\Dot;
use Ivuorinen\MonologGdprFilter\Contracts\ArrayAccessorInterface;
use Throwable;
/**
@@ -34,11 +34,11 @@ class ContextProcessor
/**
* Mask field paths in the context using the configured field masks.
*
* @param Dot<array-key, mixed> $accessor
* @param ArrayAccessorInterface $accessor
* @return string[] Array of processed field paths
* @psalm-return list<string>
*/
public function maskFieldPaths(Dot $accessor): array
public function maskFieldPaths(ArrayAccessorInterface $accessor): array
{
$processedFields = [];
foreach ($this->fieldPaths as $path => $config) {
@@ -70,11 +70,11 @@ class ContextProcessor
/**
* Process custom callbacks on context fields.
*
* @param Dot<array-key, mixed> $accessor
* @param ArrayAccessorInterface $accessor
* @return string[] Array of processed field paths
* @psalm-return list<string>
*/
public function processCustomCallbacks(Dot $accessor): array
public function processCustomCallbacks(ArrayAccessorInterface $accessor): array
{
$processedFields = [];
foreach ($this->customCallbacks as $path => $callback) {

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

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

@@ -2,12 +2,12 @@
namespace Ivuorinen\MonologGdprFilter;
use Ivuorinen\MonologGdprFilter\ArrayAccessor\ArrayAccessorFactory;
use Ivuorinen\MonologGdprFilter\Exceptions\PatternValidationException;
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidRegexPatternException;
use Ivuorinen\MonologGdprFilter\Factory\AuditLoggerFactory;
use Closure;
use Throwable;
use Error;
use Adbar\Dot;
use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;
@@ -15,14 +15,18 @@ use Monolog\Processor\ProcessorInterface;
* GdprProcessor is a Monolog processor that masks sensitive information in log messages
* according to specified regex patterns and field paths.
*
* This class serves as a Monolog adapter, delegating actual masking work to MaskingOrchestrator.
*
* @psalm-api
*/
class GdprProcessor implements ProcessorInterface
{
private readonly DataTypeMasker $dataTypeMasker;
private readonly JsonMasker $jsonMasker;
private readonly ContextProcessor $contextProcessor;
private readonly RecursiveProcessor $recursiveProcessor;
private readonly MaskingOrchestrator $orchestrator;
/**
* @var callable(string,mixed,mixed):void|null
*/
private $auditLogger;
/**
* @param array<string,string> $patterns Regex pattern => replacement
@@ -34,18 +38,22 @@ class GdprProcessor implements ProcessorInterface
* @param array<string,string> $dataTypeMasks Type-based masking: type => mask pattern
* @param array<string,callable(LogRecord):bool> $conditionalRules Conditional masking rules:
* rule_name => condition_callback
* @param ArrayAccessorFactory|null $arrayAccessorFactory Factory for creating array accessors
*
* @throws \InvalidArgumentException When any parameter is invalid
*/
public function __construct(
private readonly array $patterns,
private readonly array $fieldPaths = [],
private readonly array $customCallbacks = [],
private $auditLogger = null,
array $fieldPaths = [],
array $customCallbacks = [],
$auditLogger = null,
int $maxDepth = 100,
array $dataTypeMasks = [],
private readonly array $conditionalRules = []
private readonly array $conditionalRules = [],
?ArrayAccessorFactory $arrayAccessorFactory = null
) {
$this->auditLogger = $auditLogger;
// Validate all constructor parameters using InputValidator
InputValidator::validateAll(
$patterns,
@@ -58,48 +66,34 @@ class GdprProcessor implements ProcessorInterface
);
// Pre-validate and cache patterns for better performance
/** @psalm-suppress DeprecatedMethod - Internal use of caching mechanism */
PatternValidator::cachePatterns($patterns);
// Initialize data type masker
$this->dataTypeMasker = new DataTypeMasker($dataTypeMasks, $auditLogger);
// Initialize recursive processor for data structure processing
$this->recursiveProcessor = new RecursiveProcessor(
$this->regExpMessage(...),
$this->dataTypeMasker,
$auditLogger,
$maxDepth
);
// Initialize JSON masker with recursive mask callback
/** @psalm-suppress InvalidArgument - recursiveMask is intentionally impure due to audit logging */
$this->jsonMasker = new JsonMasker(
$this->recursiveProcessor->recursiveMask(...),
$auditLogger
);
// Initialize context processor for field-level operations
$this->contextProcessor = new ContextProcessor(
// Create orchestrator to handle actual masking work
$this->orchestrator = new MaskingOrchestrator(
$patterns,
$fieldPaths,
$customCallbacks,
$auditLogger,
$this->regExpMessage(...)
$maxDepth,
$dataTypeMasks,
$arrayAccessorFactory
);
}
/**
* Create a rate-limited audit logger wrapper.
*
* @param callable(string,mixed,mixed):void $auditLogger The underlying audit logger
* @param string $profile Rate limiting profile: 'strict', 'default', 'relaxed', or 'testing'
*
* @deprecated Use AuditLoggerFactory::create()->createRateLimited() instead
*/
public static function createRateLimitedAuditLogger(
callable $auditLogger,
string $profile = 'default'
): RateLimitedAuditLogger {
return RateLimitedAuditLogger::create($auditLogger, $profile);
return AuditLoggerFactory::create()->createRateLimited($auditLogger, $profile);
}
/**
@@ -111,30 +105,17 @@ class GdprProcessor implements ProcessorInterface
* @phpstan-param-out array<array-key, mixed> $logStorage
* @param bool $rateLimited Whether to apply rate limiting (default: false for testing)
*
*
* @psalm-return RateLimitedAuditLogger|Closure(string, mixed, mixed):void
* @psalm-suppress ReferenceConstraintViolation - The closure always sets timestamp, but Psalm can't infer this through RateLimitedAuditLogger wrapper
*
* @deprecated Use AuditLoggerFactory::create()->createArrayLogger() instead
*/
public static function createArrayAuditLogger(
array &$logStorage,
bool $rateLimited = false
): Closure|RateLimitedAuditLogger {
$baseLogger = function (string $path, mixed $original, mixed $masked) use (&$logStorage): void {
$logStorage[] = [
'path' => $path,
'original' => $original,
'masked' => $masked,
'timestamp' => time()
];
};
return $rateLimited
? self::createRateLimitedAuditLogger($baseLogger, 'testing')
: $baseLogger;
return AuditLoggerFactory::create()->createArrayLogger($logStorage, $rateLimited);
}
/**
* Process a log record to mask sensitive information.
*
@@ -149,36 +130,10 @@ class GdprProcessor implements ProcessorInterface
return $record;
}
$message = $this->regExpMessage($record->message);
$context = $record->context;
$accessor = new Dot($context);
$processedFields = [];
// Delegate to orchestrator
$result = $this->orchestrator->process($record->message, $record->context);
if ($this->fieldPaths !== []) {
$processedFields = array_merge($processedFields, $this->contextProcessor->maskFieldPaths($accessor));
}
if ($this->customCallbacks !== []) {
$processedFields = array_merge(
$processedFields,
$this->contextProcessor->processCustomCallbacks($accessor)
);
}
if ($this->fieldPaths !== [] || $this->customCallbacks !== []) {
$context = $accessor->all();
// Apply data type masking to the entire context after field/callback processing
$context = $this->dataTypeMasker->applyToContext(
$context,
$processedFields,
'',
$this->recursiveProcessor->recursiveMask(...)
);
} else {
$context = $this->recursiveProcessor->recursiveMask($context, 0);
}
return $record->with(message: $message, context: $context);
return $record->with(message: $result['message'], context: $result['context']);
}
/**
@@ -227,57 +182,7 @@ class GdprProcessor implements ProcessorInterface
*/
public function regExpMessage(string $message = ''): string
{
// Early return for empty messages
if ($message === '') {
return $message;
}
// Track original message for empty result protection
$originalMessage = $message;
// Handle JSON strings and regular patterns in a coordinated way
$message = $this->maskMessageWithJsonSupport($message);
return $message === '' || $message === '0' ? $originalMessage : $message;
}
/**
* Mask message content, handling both JSON structures and regular patterns.
*/
private function maskMessageWithJsonSupport(string $message): string
{
// Use JsonMasker to process JSON structures
$result = $this->jsonMasker->processMessage($message);
// Now apply regular patterns to the entire result
foreach ($this->patterns as $regex => $replacement) {
try {
/** @psalm-suppress ArgumentTypeCoercion */
$newResult = preg_replace($regex, $replacement, $result, -1, $count);
if ($newResult === null) {
$error = preg_last_error_msg();
if ($this->auditLogger !== null) {
($this->auditLogger)('preg_replace_error', $result, 'Error: ' . $error);
}
continue;
}
if ($count > 0) {
$result = $newResult;
}
} catch (Error $e) {
if ($this->auditLogger !== null) {
($this->auditLogger)('regex_error', $regex, $e->getMessage());
}
continue;
}
}
return $result;
return $this->orchestrator->regExpMessage($message);
}
/**
@@ -290,7 +195,7 @@ class GdprProcessor implements ProcessorInterface
*/
public function recursiveMask(array|string $data, int $currentDepth = 0): array|string
{
return $this->recursiveProcessor->recursiveMask($data, $currentDepth);
return $this->orchestrator->recursiveMask($data, $currentDepth);
}
/**
@@ -314,7 +219,7 @@ class GdprProcessor implements ProcessorInterface
}
return $result;
} catch (Error $error) {
} catch (\Error $error) {
if ($this->auditLogger !== null) {
($this->auditLogger)('regex_batch_error', implode(', ', $keys), $error->getMessage());
}
@@ -331,10 +236,15 @@ class GdprProcessor implements ProcessorInterface
public function setAuditLogger(?callable $auditLogger): void
{
$this->auditLogger = $auditLogger;
$this->orchestrator->setAuditLogger($auditLogger);
}
// Propagate to child processors
$this->contextProcessor->setAuditLogger($auditLogger);
$this->recursiveProcessor->setAuditLogger($auditLogger);
/**
* Get the underlying orchestrator for direct access.
*/
public function getOrchestrator(): MaskingOrchestrator
{
return $this->orchestrator;
}
/**
@@ -347,6 +257,7 @@ class GdprProcessor implements ProcessorInterface
public static function validatePatternsArray(array $patterns): void
{
try {
/** @psalm-suppress DeprecatedMethod - Wrapper for deprecated validation */
PatternValidator::validateAll($patterns);
} catch (InvalidRegexPatternException $e) {
throw PatternValidationException::forMultiplePatterns(

View File

@@ -81,6 +81,7 @@ final class InputValidator
}
// Validate regex pattern syntax
/** @psalm-suppress DeprecatedMethod - Internal validation use */
if (!PatternValidator::isValid($pattern)) {
throw InvalidRegexPatternException::forPattern(
$pattern,

291
src/MaskingOrchestrator.php Normal file
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);
}
}

View File

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

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

@@ -66,7 +66,33 @@ class RateLimitedAuditLogger
*
* @return int[][]
*
* @psalm-return array{'audit:general_operations'?: array{current_requests: int<1, max>, remaining_requests: int<0, max>, time_until_reset: int<0, max>}, 'audit:error_operations'?: array{current_requests: int<1, max>, remaining_requests: int<0, max>, time_until_reset: int<0, max>}, 'audit:regex_operations'?: array{current_requests: int<1, max>, remaining_requests: int<0, max>, time_until_reset: int<0, max>}, 'audit:conditional_operations'?: array{current_requests: int<1, max>, remaining_requests: int<0, max>, time_until_reset: int<0, max>}, 'audit:json_operations'?: array{current_requests: int<1, max>, remaining_requests: int<0, max>, time_until_reset: int<0, max>}}
* @psalm-return array{
* 'audit:general_operations'?: array{
* current_requests: int<1, max>,
* remaining_requests: int<0, max>,
* time_until_reset: int<0, max>
* },
* 'audit:error_operations'?: array{
* current_requests: int<1, max>,
* remaining_requests: int<0, max>,
* time_until_reset: int<0, max>
* },
* 'audit:regex_operations'?: array{
* current_requests: int<1, max>,
* remaining_requests: int<0, max>,
* time_until_reset: int<0, max>
* },
* 'audit:conditional_operations'?: array{
* current_requests: int<1, max>,
* remaining_requests: int<0, max>,
* time_until_reset: int<0, max>
* },
* 'audit:json_operations'?: array{
* current_requests: int<1, max>,
* remaining_requests: int<0, max>,
* time_until_reset: int<0, max>
* }
* }
*/
public function getRateLimitStats(): array
{

View File

@@ -129,7 +129,11 @@ class RateLimiter
*
* @return int[]
*
* @psalm-return array{current_requests: int<0, max>, remaining_requests: int<0, max>, time_until_reset: int<0, max>}
* @psalm-return array{
* current_requests: int<0, max>,
* remaining_requests: int<0, max>,
* time_until_reset: int<0, max>
* }
* @throws InvalidRateLimitConfigurationException When key is invalid
*/
public function getStats(string $key): array
@@ -223,7 +227,13 @@ class RateLimiter
*
* @return int[]
*
* @psalm-return array{total_keys: int<0, max>, total_timestamps: int, estimated_memory_bytes: int<min, max>, last_cleanup: int, cleanup_interval: int}
* @psalm-return array{
* total_keys: int<0, max>,
* total_timestamps: int,
* estimated_memory_bytes: int<min, max>,
* last_cleanup: int,
* cleanup_interval: int
* }
*/
public static function getMemoryStats(): array
{

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

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Retention;
/**
* Data retention policy configuration for GDPR compliance.
*
* Defines how long different types of data should be retained
* and what actions to take when the retention period expires.
*
* @api
*/
final class RetentionPolicy
{
public const ACTION_DELETE = 'delete';
public const ACTION_ANONYMIZE = 'anonymize';
public const ACTION_ARCHIVE = 'archive';
/**
* @param string $name Policy name
* @param int $retentionDays Number of days to retain data
* @param string $action Action to take when retention expires
* @param list<string> $fields Fields this policy applies to (empty = all fields)
*/
public function __construct(
private readonly string $name,
private readonly int $retentionDays,
private readonly string $action = self::ACTION_DELETE,
private readonly array $fields = []
) {
}
/**
* Get the policy name.
*/
public function getName(): string
{
return $this->name;
}
/**
* Get the retention period in days.
*/
public function getRetentionDays(): int
{
return $this->retentionDays;
}
/**
* Get the expiration action.
*/
public function getAction(): string
{
return $this->action;
}
/**
* Get fields this policy applies to.
*
* @return list<string>
*/
public function getFields(): array
{
return $this->fields;
}
/**
* Check if a date is within the retention period.
*/
public function isWithinRetention(\DateTimeInterface $date): bool
{
$now = new \DateTimeImmutable();
$cutoff = $now->modify("-{$this->retentionDays} days");
return $date >= $cutoff;
}
/**
* Get the retention cutoff date.
*/
public function getCutoffDate(): \DateTimeImmutable
{
return (new \DateTimeImmutable())->modify("-{$this->retentionDays} days");
}
/**
* Create a GDPR standard 30-day retention policy.
*/
public static function gdpr30Days(string $name = 'gdpr_standard'): self
{
return new self($name, 30, self::ACTION_DELETE);
}
/**
* Create a long-term archival policy (7 years).
*/
public static function archival(string $name = 'archival'): self
{
return new self($name, 2555, self::ACTION_ARCHIVE); // ~7 years
}
/**
* Create an anonymization policy.
*
* @param list<string> $fields Fields to anonymize
*/
public static function anonymize(string $name, int $days, array $fields = []): self
{
return new self($name, $days, self::ACTION_ANONYMIZE, $fields);
}
}

View File

@@ -64,8 +64,10 @@ final class SecuritySanitizer
'/\b[a-z_]*secret[a-z_]*[=:\s]+[\w\d_-]{10,}/i' => 'secret=***',
'/\b[a-z_]*key[a-z_]*[=:\s]+[\w\d_-]{10,}/i' => 'key=***',
// IP addresses in internal ranges
'/\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b/' => '***.***.***',
// IP addresses in internal ranges (10.x.x.x, 172.16-31.x.x, 192.168.x.x)
'/\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|'
. '172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|'
. '192\.168\.\d{1,3}\.\d{1,3})\b/' => '***.***.***',
];
$sanitized = $message;

View File

@@ -0,0 +1,264 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter;
/**
* Processes serialized data formats like print_r, var_export, and serialize output.
*
* Detects and masks sensitive data within:
* - print_r() output: Array (key => value) format
* - var_export() output: array('key' => 'value') format
* - serialize() output: a:1:{s:3:"key";s:5:"value";}
* - json_encode() output: {"key":"value"}
*
* @api
*/
final class SerializedDataProcessor
{
/**
* @var callable(string):string
*/
private $stringMasker;
/**
* @var callable(string,mixed,mixed):void|null
*/
private $auditLogger;
/**
* @param callable(string):string $stringMasker Function to mask strings
* @param callable(string,mixed,mixed):void|null $auditLogger Optional audit logger
*/
public function __construct(
callable $stringMasker,
?callable $auditLogger = null
) {
$this->stringMasker = $stringMasker;
$this->auditLogger = $auditLogger;
}
/**
* Process a message that may contain serialized data.
*
* Automatically detects the format and applies masking.
*
* @param string $message The message to process
* @return string The processed message with masked data
*/
public function process(string $message): string
{
if ($message === '') {
return $message;
}
// Try to detect and process JSON embedded in the message
$message = $this->processEmbeddedJson($message);
// Try to detect and process print_r output
$message = $this->processPrintROutput($message);
// Try to detect and process var_export output
$message = $this->processVarExportOutput($message);
// Try to detect and process serialize output
$message = $this->processSerializeOutput($message);
return $message;
}
/**
* Process embedded JSON strings in the message.
*/
private function processEmbeddedJson(string $message): string
{
// Match JSON objects and arrays
$pattern = '/(\{(?:[^{}]|(?1))*\}|\[(?:[^\[\]]|(?1))*\])/';
return (string) preg_replace_callback($pattern, function (array $matches): string {
$json = $matches[0];
// Try to decode
$decoded = json_decode($json, true);
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
// Not valid JSON, return as-is
return $json;
}
// Process the decoded data
$masked = $this->maskRecursive($decoded, 'json');
// Re-encode
$result = json_encode($masked, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return $result === false ? $json : $result;
}, $message);
}
/**
* Process print_r output format.
*
* Example format:
* Array
* (
* [key] => value
* [nested] => Array
* (
* [inner] => data
* )
* )
*/
private function processPrintROutput(string $message): string
{
// Check if message contains print_r style output
if (!preg_match('/Array\s*\(\s*\[/s', $message)) {
return $message;
}
// Process key => value pairs in print_r format
// Match: [key] => string_value (including multi-line values)
$pattern = '/(\[\S+\])\s*=>\s*([^\n\[]+)/';
return (string) preg_replace_callback($pattern, function (array $matches): string {
$key = trim($matches[1], '[]');
$value = trim($matches[2]);
// Skip if value looks like "Array" (nested structure)
if ($value === 'Array') {
return $matches[0];
}
$masked = ($this->stringMasker)($value);
if ($masked !== $value) {
$this->logAudit("print_r.{$key}", $value, $masked);
return "[{$key}] => {$masked}";
}
return $matches[0];
}, $message);
}
/**
* Process var_export output format.
*
* Example format:
* array (
* 'key' => 'value',
* 'nested' => array (
* 'inner' => 'data',
* ),
* )
*/
private function processVarExportOutput(string $message): string
{
// Check if message contains var_export style output
if (!preg_match('/array\s*\(\s*[\'"]?\w+[\'"]?\s*=>/s', $message)) {
return $message;
}
// Process 'key' => 'value' pairs
$pattern = "/(['\"])(\w+)\\1\s*=>\s*(['\"])([^'\"]+)\\3/";
return (string) preg_replace_callback($pattern, function (array $matches): string {
$keyQuote = $matches[1];
$key = $matches[2];
$valueQuote = $matches[3];
$value = $matches[4];
$masked = ($this->stringMasker)($value);
if ($masked !== $value) {
$this->logAudit("var_export.{$key}", $value, $masked);
return "{$keyQuote}{$key}{$keyQuote} => {$valueQuote}{$masked}{$valueQuote}";
}
return $matches[0];
}, $message);
}
/**
* Process PHP serialize() output format.
*
* Example format: a:1:{s:3:"key";s:5:"value";}
*/
private function processSerializeOutput(string $message): string
{
// Check if message contains serialize style output
if (!preg_match('/[aOCs]:\d+:/s', $message)) {
return $message;
}
// Match serialized strings: s:length:"value";
$pattern = '/s:(\d+):"([^"]*)";/';
return (string) preg_replace_callback($pattern, function (array $matches): string {
$originalLength = (int) $matches[1];
$value = $matches[2];
// Verify the length matches
if (strlen($value) !== $originalLength) {
return $matches[0];
}
$masked = ($this->stringMasker)($value);
if ($masked !== $value) {
$newLength = strlen($masked);
$this->logAudit('serialize.string', $value, $masked);
return "s:{$newLength}:\"{$masked}\";";
}
return $matches[0];
}, $message);
}
/**
* Recursively mask values in an array.
*
* @param mixed $data The data to mask
* @param string $path Current path for audit logging
* @return mixed The masked data
*/
private function maskRecursive(mixed $data, string $path): mixed
{
if (is_string($data)) {
$masked = ($this->stringMasker)($data);
if ($masked !== $data) {
$this->logAudit($path, $data, $masked);
}
return $masked;
}
if (is_array($data)) {
$result = [];
foreach ($data as $key => $value) {
$newPath = $path . '.' . $key;
$result[$key] = $this->maskRecursive($value, $newPath);
}
return $result;
}
return $data;
}
/**
* Log audit event if logger is configured.
*/
private function logAudit(string $path, string $original, string $masked): void
{
if ($this->auditLogger !== null) {
($this->auditLogger)($path, $original, $masked);
}
}
/**
* Set the audit logger.
*
* @param callable(string,mixed,mixed):void|null $auditLogger
*/
public function setAuditLogger(?callable $auditLogger): void
{
$this->auditLogger = $auditLogger;
}
}

View File

@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Strategies;
use Ivuorinen\MonologGdprFilter\Exceptions\MaskingOperationFailedException;
use Monolog\LogRecord;
use Throwable;
/**
* Masking strategy that uses custom callbacks for field-specific masking.
*
* This strategy allows wrapping legacy custom callback functions as proper
* strategy implementations, enabling gradual migration to the strategy pattern.
*
* @api
*/
final class CallbackMaskingStrategy extends AbstractMaskingStrategy
{
/** @var callable(mixed): mixed */
private $callback;
/**
* @param string $fieldPath The field path this callback applies to
* @param callable(mixed): mixed $callback The masking callback function
* @param int $priority Strategy priority (default: 50)
* @param bool $exactMatch Whether to require exact path match (default: true)
*/
public function __construct(
private readonly string $fieldPath,
callable $callback,
int $priority = 50,
private readonly bool $exactMatch = true
) {
parent::__construct($priority, [
'field_path' => $fieldPath,
'exact_match' => $exactMatch,
]);
$this->callback = $callback;
}
/**
* Create a strategy for multiple field paths with the same callback.
*
* @param array<string> $fieldPaths Array of field paths
* @param callable(mixed): mixed $callback The masking callback
* @param int $priority Strategy priority
* @return array<self> Array of CallbackMaskingStrategy instances
*/
public static function forPaths(
array $fieldPaths,
callable $callback,
int $priority = 50
): array {
return array_map(
fn(string $path): self => new self($path, $callback, $priority),
$fieldPaths
);
}
/**
* Create a strategy that always returns a constant value.
*
* @param string $fieldPath The field path
* @param string $replacementValue The constant replacement value
* @param int $priority Strategy priority
*/
public static function constant(
string $fieldPath,
string $replacementValue,
int $priority = 50
): self {
return new self(
$fieldPath,
fn(mixed $value): string => $replacementValue,
$priority
);
}
/**
* Create a strategy that hashes the original value.
*
* @param string $fieldPath The field path
* @param string $algorithm Hash algorithm (default: 'sha256')
* @param int $truncateLength Truncate hash to this length (0 = no truncation)
* @param int $priority Strategy priority
*/
public static function hash(
string $fieldPath,
string $algorithm = 'sha256',
int $truncateLength = 8,
int $priority = 50
): self {
return new self(
$fieldPath,
function (mixed $value) use ($algorithm, $truncateLength): string {
$stringValue = is_scalar($value) ? (string) $value : json_encode($value);
$hash = hash($algorithm, $stringValue === false ? '' : $stringValue);
return $truncateLength > 0
? substr($hash, 0, $truncateLength) . '...'
: $hash;
},
$priority
);
}
/**
* Create a strategy that partially masks a value (e.g., email@****.com).
*
* @param string $fieldPath The field path
* @param int $visibleStart Characters to show at start
* @param int $visibleEnd Characters to show at end
* @param string $maskChar Character to use for masking
* @param int $priority Strategy priority
*/
public static function partial(
string $fieldPath,
int $visibleStart = 2,
int $visibleEnd = 2,
string $maskChar = '*',
int $priority = 50
): self {
return new self(
$fieldPath,
function (mixed $value) use ($visibleStart, $visibleEnd, $maskChar): string {
$str = is_scalar($value) ? (string) $value : '[OBJECT]';
$len = strlen($str);
if ($len <= $visibleStart + $visibleEnd) {
return str_repeat($maskChar, $len);
}
$start = substr($str, 0, $visibleStart);
$end = substr($str, -$visibleEnd);
$masked = str_repeat($maskChar, $len - $visibleStart - $visibleEnd);
return $start . $masked . $end;
},
$priority
);
}
#[\Override]
public function mask(mixed $value, string $path, LogRecord $logRecord): mixed
{
try {
return ($this->callback)($value);
} catch (Throwable $e) {
throw MaskingOperationFailedException::customCallbackFailed(
$path,
$value,
'Callback threw exception: ' . $e->getMessage()
);
}
}
#[\Override]
public function shouldApply(mixed $value, string $path, LogRecord $logRecord): bool
{
if ($this->exactMatch) {
return $path === $this->fieldPath;
}
return $this->pathMatches($path, $this->fieldPath);
}
#[\Override]
public function getName(): string
{
return sprintf('Callback Masking (%s)', $this->fieldPath);
}
#[\Override]
public function validate(): bool
{
if ($this->fieldPath === '' || $this->fieldPath === '0') {
return false;
}
return is_callable($this->callback);
}
/**
* Get the field path this strategy applies to.
*/
public function getFieldPath(): string
{
return $this->fieldPath;
}
/**
* Check if this strategy uses exact matching.
*/
public function isExactMatch(): bool
{
return $this->exactMatch;
}
#[\Override]
public function getConfiguration(): array
{
return [
'field_path' => $this->fieldPath,
'exact_match' => $this->exactMatch,
'priority' => $this->priority,
];
}
}

View File

@@ -80,7 +80,14 @@ class ConditionalMaskingStrategy extends AbstractMaskingStrategy
{
$conditionCount = count($this->conditions);
$logic = $this->requireAllConditions ? 'AND' : 'OR';
return sprintf('Conditional Masking (%d conditions, %s logic) -> %s', $conditionCount, $logic, $this->wrappedStrategy->getName());
$wrappedName = $this->wrappedStrategy->getName();
return sprintf(
'Conditional Masking (%d conditions, %s logic) -> %s',
$conditionCount,
$logic,
$wrappedName
);
}
/**

View File

@@ -243,7 +243,27 @@ class StrategyManager
*
* @return (((array|int|string)[]|int)[]|int)[]
*
* @psalm-return array{total_strategies: int<0, max>, strategy_types: array<string, 1|2>, priority_distribution: array{'90-100 (Critical)'?: 1|2, '80-89 (High)'?: 1|2, '60-79 (Medium-High)'?: 1|2, '40-59 (Medium)'?: 1|2, '20-39 (Low-Medium)'?: 1|2, '0-19 (Low)'?: 1|2}, strategies: list{0?: array{name: string, class: string, priority: int<min, max>, configuration: array<string, mixed>},...}}
* @psalm-return array{
* total_strategies: int<0, max>,
* strategy_types: array<string, 1|2>,
* priority_distribution: array{
* '90-100 (Critical)'?: 1|2,
* '80-89 (High)'?: 1|2,
* '60-79 (Medium-High)'?: 1|2,
* '40-59 (Medium)'?: 1|2,
* '20-39 (Low-Medium)'?: 1|2,
* '0-19 (Low)'?: 1|2
* },
* strategies: list{
* 0?: array{
* name: string,
* class: string,
* priority: int<min, max>,
* configuration: array<string, mixed>
* },
* ...
* }
* }
*/
public function getStatistics(): array
{

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter\Streaming;
use Ivuorinen\MonologGdprFilter\Exceptions\StreamingOperationFailedException;
use Ivuorinen\MonologGdprFilter\MaskingOrchestrator;
/**
* Streaming processor for handling large log files.
*
* Processes logs in chunks to minimize memory usage when
* dealing with large log files or data streams.
*
* @api
*/
final class StreamingProcessor
{
private const DEFAULT_CHUNK_SIZE = 1000;
/**
* @var callable(string,mixed,mixed):void|null
*/
private $auditLogger;
/**
* @param MaskingOrchestrator $orchestrator The masking orchestrator
* @param int $chunkSize Number of records to process at once
*/
public function __construct(
private readonly MaskingOrchestrator $orchestrator,
private readonly int $chunkSize = self::DEFAULT_CHUNK_SIZE,
?callable $auditLogger = null
) {
$this->auditLogger = $auditLogger;
}
/**
* Process a generator of records.
*
* @param iterable<array{message: string, context: array<string,mixed>}> $records
* @return \Generator<array{message: string, context: array<string,mixed>}>
*/
public function processStream(iterable $records): \Generator
{
$buffer = [];
$count = 0;
foreach ($records as $record) {
$buffer[] = $record;
$count++;
if ($count >= $this->chunkSize) {
foreach ($this->processChunk($buffer) as $item) {
yield $item;
}
$buffer = [];
$count = 0;
}
}
// Process remaining records
if ($buffer !== []) {
foreach ($this->processChunk($buffer) as $item) {
yield $item;
}
}
}
/**
* Process a file line by line.
*
* @param string $filePath Path to the log file
* @param callable(string):array{message: string, context: array<string,mixed>} $lineParser
* @return \Generator<array{message: string, context: array<string,mixed>}>
*/
public function processFile(string $filePath, callable $lineParser): \Generator
{
$handle = @fopen($filePath, 'r');
if ($handle === false) {
throw StreamingOperationFailedException::cannotOpenInputFile($filePath);
}
try {
$buffer = [];
$count = 0;
while (($line = fgets($handle)) !== false) {
$line = trim($line);
if ($line === '') {
continue;
}
$record = $lineParser($line);
$buffer[] = $record;
$count++;
if ($count >= $this->chunkSize) {
foreach ($this->processChunk($buffer) as $item) {
yield $item;
}
$buffer = [];
$count = 0;
}
}
// Process remaining records
if ($buffer !== []) {
foreach ($this->processChunk($buffer) as $item) {
yield $item;
}
}
} finally {
fclose($handle);
}
}
/**
* Process and write to an output file.
*
* @param iterable<array{message: string, context: array<string,mixed>}> $records
* @param string $outputPath Path to output file
* @param callable(array{message: string, context: array<string,mixed>}):string $formatter
* @return int Number of records processed
*/
public function processToFile(
iterable $records,
string $outputPath,
callable $formatter
): int {
$handle = @fopen($outputPath, 'w');
if ($handle === false) {
throw StreamingOperationFailedException::cannotOpenOutputFile($outputPath);
}
try {
$count = 0;
foreach ($this->processStream($records) as $record) {
fwrite($handle, $formatter($record) . "\n");
$count++;
}
return $count;
} finally {
fclose($handle);
}
}
/**
* Process a chunk of records.
*
* @param list<array{message: string, context: array<string,mixed>}> $chunk
* @return \Generator<array{message: string, context: array<string,mixed>}>
*/
private function processChunk(array $chunk): \Generator
{
foreach ($chunk as $record) {
$processed = $this->orchestrator->process($record['message'], $record['context']);
if ($this->auditLogger !== null) {
($this->auditLogger)('streaming.processed', count($chunk), 1);
}
yield $processed;
}
}
/**
* Get statistics about a streaming operation.
*
* @param iterable<array{message: string, context: array<string,mixed>}> $records
* @return array{processed: int, masked: int, errors: int}
*/
public function getStatistics(iterable $records): array
{
$stats = ['processed' => 0, 'masked' => 0, 'errors' => 0];
foreach ($this->processStream($records) as $record) {
$stats['processed']++;
// Count if any masking occurred (simple heuristic)
if (str_contains($record['message'], '***') || str_contains($record['message'], '[')) {
$stats['masked']++;
}
}
return $stats;
}
/**
* Set the audit logger.
*
* @param callable(string,mixed,mixed):void|null $auditLogger
*/
public function setAuditLogger(?callable $auditLogger): void
{
$this->auditLogger = $auditLogger;
}
/**
* Get the chunk size.
*/
public function getChunkSize(): int
{
return $this->chunkSize;
}
}