mirror of
https://github.com/ivuorinen/monolog-gdpr-filter.git
synced 2026-02-08 05:47:56 +00:00
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:
48
src/Anonymization/GeneralizationStrategy.php
Normal file
48
src/Anonymization/GeneralizationStrategy.php
Normal 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;
|
||||
}
|
||||
}
|
||||
212
src/Anonymization/KAnonymizer.php
Normal file
212
src/Anonymization/KAnonymizer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
75
src/ArrayAccessor/ArrayAccessorFactory.php
Normal file
75
src/ArrayAccessor/ArrayAccessorFactory.php
Normal 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);
|
||||
}
|
||||
}
|
||||
80
src/ArrayAccessor/DotArrayAccessor.php
Normal file
80
src/ArrayAccessor/DotArrayAccessor.php
Normal 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
216
src/Audit/AuditContext.php
Normal 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
147
src/Audit/ErrorContext.php
Normal 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;
|
||||
}
|
||||
}
|
||||
232
src/Audit/StructuredAuditLogger.php
Normal file
232
src/Audit/StructuredAuditLogger.php
Normal 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;
|
||||
}
|
||||
}
|
||||
118
src/Builder/GdprProcessorBuilder.php
Normal file
118
src/Builder/GdprProcessorBuilder.php
Normal 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);
|
||||
}
|
||||
}
|
||||
121
src/Builder/PluginAwareProcessor.php
Normal file
121
src/Builder/PluginAwareProcessor.php
Normal 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);
|
||||
}
|
||||
}
|
||||
100
src/Builder/Traits/CallbackConfigurationTrait.php
Normal file
100
src/Builder/Traits/CallbackConfigurationTrait.php
Normal 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;
|
||||
}
|
||||
}
|
||||
65
src/Builder/Traits/FieldPathConfigurationTrait.php
Normal file
65
src/Builder/Traits/FieldPathConfigurationTrait.php
Normal 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;
|
||||
}
|
||||
}
|
||||
74
src/Builder/Traits/PatternConfigurationTrait.php
Normal file
74
src/Builder/Traits/PatternConfigurationTrait.php
Normal 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;
|
||||
}
|
||||
}
|
||||
67
src/Builder/Traits/PluginConfigurationTrait.php
Normal file
67
src/Builder/Traits/PluginConfigurationTrait.php
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
54
src/Contracts/ArrayAccessorInterface.php
Normal file
54
src/Contracts/ArrayAccessorInterface.php
Normal 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;
|
||||
}
|
||||
72
src/Contracts/MaskingPluginInterface.php
Normal file
72
src/Contracts/MaskingPluginInterface.php
Normal 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;
|
||||
}
|
||||
50
src/Exceptions/StreamingOperationFailedException.php
Normal file
50
src/Exceptions/StreamingOperationFailedException.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
126
src/Factory/AuditLoggerFactory.php
Normal file
126
src/Factory/AuditLoggerFactory.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
291
src/MaskingOrchestrator.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
82
src/Plugins/AbstractMaskingPlugin.php
Normal file
82
src/Plugins/AbstractMaskingPlugin.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
57
src/Recovery/FailureMode.php
Normal file
57
src/Recovery/FailureMode.php
Normal 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;
|
||||
}
|
||||
}
|
||||
178
src/Recovery/FallbackMaskStrategy.php
Normal file
178
src/Recovery/FallbackMaskStrategy.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
202
src/Recovery/RecoveryResult.php
Normal file
202
src/Recovery/RecoveryResult.php
Normal 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;
|
||||
}
|
||||
}
|
||||
60
src/Recovery/RecoveryStrategy.php
Normal file
60
src/Recovery/RecoveryStrategy.php
Normal 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;
|
||||
}
|
||||
307
src/Recovery/RetryStrategy.php
Normal file
307
src/Recovery/RetryStrategy.php
Normal 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);
|
||||
}
|
||||
}
|
||||
113
src/Retention/RetentionPolicy.php
Normal file
113
src/Retention/RetentionPolicy.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
264
src/SerializedDataProcessor.php
Normal file
264
src/SerializedDataProcessor.php
Normal 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;
|
||||
}
|
||||
}
|
||||
210
src/Strategies/CallbackMaskingStrategy.php
Normal file
210
src/Strategies/CallbackMaskingStrategy.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
207
src/Streaming/StreamingProcessor.php
Normal file
207
src/Streaming/StreamingProcessor.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user