mirror of
https://github.com/ivuorinen/monolog-gdpr-filter.git
synced 2026-02-16 19:51:22 +00:00
feat: performance, integrations, advanced features (#2)
* feat: performance, integrations, advanced features * chore: fix linting problems * chore: suppressions and linting * chore(lint): pre-commit linting, fixes * feat: comprehensive input validation, security hardening, and regression testing - Add extensive input validation throughout codebase with proper error handling - Implement comprehensive security hardening with ReDoS protection and bounds checking - Add 3 new regression test suites covering critical bugs, security, and validation scenarios - Enhance rate limiting with memory management and configurable cleanup intervals - Update configuration security settings and improve Laravel integration - Fix TODO.md timestamps to reflect actual development timeline - Strengthen static analysis configuration and improve code quality standards * feat: configure static analysis tools and enhance development workflow - Complete configuration of Psalm, PHPStan, and Rector for harmonious static analysis. - Fix invalid configurations and tool conflicts that prevented proper code quality analysis. - Add comprehensive safe analysis script with interactive workflow, backup/restore capabilities, and dry-run modes. Update documentation with linting policy requiring issue resolution over suppression. - Clean completed items from TODO to focus on actionable improvements. - All static analysis tools now work together seamlessly to provide code quality insights without breaking existing functionality. * fix(test): update Invalid regex pattern expectation * chore: phpstan, psalm fixes * chore: phpstan, psalm fixes, more tests * chore: tooling tweaks, cleanup * chore: tweaks to get the tests pass * fix(lint): rector config tweaks and successful run * feat: refactoring, more tests, fixes, cleanup * chore: deduplication, use constants * chore: psalm fixes * chore: ignore phpstan deliberate errors in tests * chore: improve codebase, deduplicate code * fix: lint * chore: deduplication, codebase simplification, sonarqube fixes * fix: resolve SonarQube reliability rating issues Fix useless object instantiation warnings in test files by assigning instantiated objects to variables. This resolves the SonarQube reliability rating issue (was C, now targeting A). Changes: - tests/Strategies/MaskingStrategiesTest.php: Fix 3 instances - tests/Strategies/FieldPathMaskingStrategyTest.php: Fix 1 instance The tests use expectException() to verify that constructors throw exceptions for invalid input. SonarQube flagged standalone `new` statements as useless. Fixed by assigning to variables with explicit unset() and fail() calls. All tests pass (623/623) and static analysis tools pass. * fix: resolve more SonarQube detected issues * fix: resolve psalm detected issues * fix: resolve more SonarQube detected issues * fix: resolve psalm detected issues * fix: duplications * fix: resolve SonarQube reliability rating issues * fix: resolve psalm and phpstan detected issues
This commit is contained in:
210
src/Strategies/AbstractMaskingStrategy.php
Normal file
210
src/Strategies/AbstractMaskingStrategy.php
Normal file
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Strategies;
|
||||
|
||||
use Monolog\LogRecord;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\MaskingOperationFailedException;
|
||||
|
||||
/**
|
||||
* Abstract base class for masking strategies.
|
||||
*
|
||||
* Provides common functionality and utilities that most masking strategies
|
||||
* will need, reducing code duplication and ensuring consistent behavior.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
abstract class AbstractMaskingStrategy implements MaskingStrategyInterface
|
||||
{
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param int $priority The priority of the strategy
|
||||
* @param array<string, mixed> $configuration The configuration for the strategy
|
||||
*/
|
||||
public function __construct(
|
||||
protected readonly int $priority = 50,
|
||||
protected readonly array $configuration = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function getPriority(): int
|
||||
{
|
||||
return $this->priority;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function getConfiguration(): array
|
||||
{
|
||||
return $this->configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function validate(): bool
|
||||
{
|
||||
// Base validation - can be overridden by concrete implementations
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely convert a value to string for processing.
|
||||
*
|
||||
* @param mixed $value The value to convert
|
||||
* @return string The string representation
|
||||
*
|
||||
* @throws MaskingOperationFailedException If value cannot be converted to string
|
||||
*/
|
||||
protected function valueToString(mixed $value): string
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_numeric($value) || is_bool($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
if (is_array($value) || is_object($value)) {
|
||||
$json = json_encode($value, JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
throw MaskingOperationFailedException::dataTypeMaskingFailed(
|
||||
gettype($value),
|
||||
$value,
|
||||
'Cannot convert value to string for masking'
|
||||
);
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
throw MaskingOperationFailedException::dataTypeMaskingFailed(
|
||||
gettype($value),
|
||||
$value,
|
||||
'Unsupported value type for string conversion'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a field path matches a given pattern.
|
||||
*
|
||||
* Supports simple wildcard matching with * and exact matches.
|
||||
*
|
||||
* @param string $path The field path to check
|
||||
* @param string $pattern The pattern to match against (supports * wildcards)
|
||||
* @return bool True if the path matches the pattern
|
||||
*/
|
||||
protected function pathMatches(string $path, string $pattern): bool
|
||||
{
|
||||
// Exact match
|
||||
if ($path === $pattern) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wildcard match
|
||||
if (str_contains($pattern, '*')) {
|
||||
// Escape dots and replace * with .*
|
||||
$regexPattern = '/^' . str_replace(['\\', '.', '*'], ['\\\\', '\\.', '.*'], $pattern) . '$/';
|
||||
return preg_match($regexPattern, $path) === 1;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the log record matches specific conditions.
|
||||
*
|
||||
* @param LogRecord $logRecord The log record to check
|
||||
* @param array<string, mixed> $conditions Conditions to check (level, channel, etc.)
|
||||
* @return bool True if all conditions are met
|
||||
*/
|
||||
protected function recordMatches(LogRecord $logRecord, array $conditions): bool
|
||||
{
|
||||
foreach ($conditions as $field => $expectedValue) {
|
||||
$actualValue = match ($field) {
|
||||
'level' => $logRecord->level->name,
|
||||
'channel' => $logRecord->channel,
|
||||
'message' => $logRecord->message,
|
||||
default => $logRecord->context[$field] ?? null,
|
||||
};
|
||||
|
||||
if ($actualValue !== $expectedValue) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a preview of a value for error messages.
|
||||
*
|
||||
* @param mixed $value The value to preview
|
||||
* @param int $maxLength Maximum length of the preview
|
||||
* @return string Safe preview string
|
||||
*/
|
||||
protected function generateValuePreview(mixed $value, int $maxLength = 100): string
|
||||
{
|
||||
try {
|
||||
$stringValue = $this->valueToString($value);
|
||||
return strlen($stringValue) > $maxLength
|
||||
? substr($stringValue, 0, $maxLength) . '...'
|
||||
: $stringValue;
|
||||
} catch (MaskingOperationFailedException) {
|
||||
return '[' . gettype($value) . ']';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a masked value while preserving the original type when possible.
|
||||
*
|
||||
* @param mixed $originalValue The original value
|
||||
* @param string $maskedString The masked string representation
|
||||
* @return mixed The masked value with appropriate type
|
||||
*/
|
||||
protected function preserveValueType(mixed $originalValue, string $maskedString): mixed
|
||||
{
|
||||
// If original was a string, return string
|
||||
if (is_string($originalValue)) {
|
||||
return $maskedString;
|
||||
}
|
||||
|
||||
// For arrays and objects, try to decode back if it was JSON
|
||||
if (is_array($originalValue) || is_object($originalValue)) {
|
||||
$decoded = json_decode($maskedString, true);
|
||||
if ($decoded !== null && json_last_error() === JSON_ERROR_NONE) {
|
||||
return is_object($originalValue) ? (object) $decoded : $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
// For primitives, try to convert back
|
||||
if (is_int($originalValue) && is_numeric($maskedString)) {
|
||||
return (int) $maskedString;
|
||||
}
|
||||
|
||||
if (is_float($originalValue) && is_numeric($maskedString)) {
|
||||
return (float) $maskedString;
|
||||
}
|
||||
|
||||
if (is_bool($originalValue)) {
|
||||
return filter_var($maskedString, FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
// Default to returning the masked string
|
||||
return $maskedString;
|
||||
}
|
||||
}
|
||||
232
src/Strategies/ConditionalMaskingStrategy.php
Normal file
232
src/Strategies/ConditionalMaskingStrategy.php
Normal file
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Strategies;
|
||||
|
||||
use Throwable;
|
||||
use Monolog\LogRecord;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\MaskingOperationFailedException;
|
||||
|
||||
/**
|
||||
* Conditional masking strategy.
|
||||
*
|
||||
* Applies masking only when specific conditions are met, such as log level,
|
||||
* channel, or custom context-based rules. This allows for fine-grained
|
||||
* control over when masking should occur.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class ConditionalMaskingStrategy extends AbstractMaskingStrategy
|
||||
{
|
||||
/**
|
||||
* @param MaskingStrategyInterface $wrappedStrategy The strategy to apply when conditions are met
|
||||
* @param array<string, callable(LogRecord): bool> $conditions Named conditions that must be satisfied
|
||||
* @param bool $requireAllConditions Whether all conditions must be true (AND) or just one (OR)
|
||||
* @param int $priority Strategy priority (default: 70)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly MaskingStrategyInterface $wrappedStrategy,
|
||||
private readonly array $conditions,
|
||||
private readonly bool $requireAllConditions = true,
|
||||
int $priority = 70
|
||||
) {
|
||||
parent::__construct($priority, [
|
||||
'wrapped_strategy' => $wrappedStrategy->getName(),
|
||||
'conditions' => array_keys($conditions),
|
||||
'require_all_conditions' => $requireAllConditions,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function mask(mixed $value, string $path, LogRecord $logRecord): mixed
|
||||
{
|
||||
// This should only be called if shouldApply returned true
|
||||
try {
|
||||
return $this->wrappedStrategy->mask($value, $path, $logRecord);
|
||||
} catch (Throwable $throwable) {
|
||||
throw MaskingOperationFailedException::customCallbackFailed(
|
||||
$path,
|
||||
$value,
|
||||
'Conditional masking failed: ' . $throwable->getMessage(),
|
||||
$throwable
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function shouldApply(mixed $value, string $path, LogRecord $logRecord): bool
|
||||
{
|
||||
// First check if conditions are met
|
||||
if (!$this->conditionsAreMet($logRecord)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Then check if the wrapped strategy should apply
|
||||
return $this->wrappedStrategy->shouldApply($value, $path, $logRecord);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function getName(): string
|
||||
{
|
||||
$conditionCount = count($this->conditions);
|
||||
$logic = $this->requireAllConditions ? 'AND' : 'OR';
|
||||
return sprintf('Conditional Masking (%d conditions, %s logic) -> %s', $conditionCount, $logic, $this->wrappedStrategy->getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function validate(): bool
|
||||
{
|
||||
if ($this->conditions === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate that all conditions are callable
|
||||
foreach ($this->conditions as $condition) {
|
||||
if (!is_callable($condition)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the wrapped strategy
|
||||
return $this->wrappedStrategy->validate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the wrapped strategy.
|
||||
*
|
||||
* @return MaskingStrategyInterface The wrapped strategy
|
||||
*/
|
||||
public function getWrappedStrategy(): MaskingStrategyInterface
|
||||
{
|
||||
return $this->wrappedStrategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the condition names.
|
||||
*
|
||||
* @return string[] The condition names
|
||||
*
|
||||
* @psalm-return list<string>
|
||||
*/
|
||||
public function getConditionNames(): array
|
||||
{
|
||||
return array_keys($this->conditions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all conditions are met for the given log record.
|
||||
*
|
||||
* @param LogRecord $logRecord The log record to evaluate
|
||||
* @return bool True if conditions are satisfied
|
||||
*/
|
||||
private function conditionsAreMet(LogRecord $logRecord): bool
|
||||
{
|
||||
if ($this->conditions === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$satisfiedConditions = 0;
|
||||
|
||||
foreach ($this->conditions as $condition) {
|
||||
try {
|
||||
$result = $condition($logRecord);
|
||||
if ($result === true) {
|
||||
$satisfiedConditions++;
|
||||
|
||||
// For OR logic, one satisfied condition is enough
|
||||
if (!$this->requireAllConditions) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// If condition evaluation fails, treat as not satisfied
|
||||
if ($this->requireAllConditions) {
|
||||
return false; // For AND logic, any failure means failure
|
||||
}
|
||||
|
||||
// For OR logic, continue checking other conditions
|
||||
}
|
||||
}
|
||||
|
||||
// For AND logic, all conditions must be satisfied
|
||||
if ($this->requireAllConditions) {
|
||||
return $satisfiedConditions === count($this->conditions);
|
||||
}
|
||||
|
||||
// For OR logic, at least one condition must be satisfied
|
||||
return $satisfiedConditions > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a level-based conditional strategy.
|
||||
*
|
||||
* @param MaskingStrategyInterface $strategy The strategy to wrap
|
||||
* @param array<string> $levels The log levels that should trigger masking
|
||||
* @param int $priority Strategy priority
|
||||
*/
|
||||
public static function forLevels(
|
||||
MaskingStrategyInterface $strategy,
|
||||
array $levels,
|
||||
int $priority = 70
|
||||
): self {
|
||||
$condition = (static fn(LogRecord $logRecord): bool => in_array($logRecord->level->name, $levels, true));
|
||||
|
||||
return new self($strategy, ['level' => $condition], true, $priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a channel-based conditional strategy.
|
||||
*
|
||||
* @param MaskingStrategyInterface $strategy The strategy to wrap
|
||||
* @param array<string> $channels The channels that should trigger masking
|
||||
* @param int $priority Strategy priority
|
||||
*/
|
||||
public static function forChannels(
|
||||
MaskingStrategyInterface $strategy,
|
||||
array $channels,
|
||||
int $priority = 70
|
||||
): self {
|
||||
$condition = (static fn(LogRecord $logRecord): bool => in_array($logRecord->channel, $channels, true));
|
||||
|
||||
return new self($strategy, ['channel' => $condition], true, $priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a context-based conditional strategy.
|
||||
*
|
||||
* @param MaskingStrategyInterface $strategy The strategy to wrap
|
||||
* @param array<string, mixed> $requiredContext Context key-value pairs that must be present
|
||||
* @param int $priority Strategy priority
|
||||
*/
|
||||
public static function forContext(
|
||||
MaskingStrategyInterface $strategy,
|
||||
array $requiredContext,
|
||||
int $priority = 70
|
||||
): self {
|
||||
$condition = static function (LogRecord $logRecord) use ($requiredContext): bool {
|
||||
foreach ($requiredContext as $key => $expectedValue) {
|
||||
$actualValue = $logRecord->context[$key] ?? null;
|
||||
if ($actualValue !== $expectedValue) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
return new self($strategy, ['context' => $condition], true, $priority);
|
||||
}
|
||||
}
|
||||
289
src/Strategies/DataTypeMaskingStrategy.php
Normal file
289
src/Strategies/DataTypeMaskingStrategy.php
Normal file
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Strategies;
|
||||
|
||||
use Throwable;
|
||||
use Monolog\LogRecord;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\MaskingOperationFailedException;
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants as Mask;
|
||||
|
||||
/**
|
||||
* Data type-based masking strategy.
|
||||
*
|
||||
* Applies different masking based on the PHP data type of values.
|
||||
* Useful for applying consistent masking patterns across all values
|
||||
* of specific types (e.g., all integers, all strings).
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class DataTypeMaskingStrategy extends AbstractMaskingStrategy
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $typeMasks Map of PHP type names to their mask values
|
||||
* @param array<string> $includePaths Optional field paths to include (empty = all paths)
|
||||
* @param array<string> $excludePaths Optional field paths to exclude
|
||||
* @param int $priority Strategy priority (default: 40)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $typeMasks,
|
||||
private readonly array $includePaths = [],
|
||||
private readonly array $excludePaths = [],
|
||||
int $priority = 40
|
||||
) {
|
||||
parent::__construct($priority, [
|
||||
'type_masks' => $typeMasks,
|
||||
'include_paths' => $includePaths,
|
||||
'exclude_paths' => $excludePaths,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function mask(mixed $value, string $path, LogRecord $logRecord): mixed
|
||||
{
|
||||
$type = $this->getValueType($value);
|
||||
$mask = $this->typeMasks[$type] ?? null;
|
||||
|
||||
if ($mask === null) {
|
||||
return $value; // Should not happen if shouldApply was called first
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->applyTypeMask($value, $mask, $type);
|
||||
} catch (Throwable $throwable) {
|
||||
throw MaskingOperationFailedException::dataTypeMaskingFailed(
|
||||
$type,
|
||||
$value,
|
||||
$throwable->getMessage(),
|
||||
$throwable
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function shouldApply(mixed $value, string $path, LogRecord $logRecord): bool
|
||||
{
|
||||
// Check exclude paths first
|
||||
foreach ($this->excludePaths as $excludePath) {
|
||||
if ($this->pathMatches($path, $excludePath)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If include paths are specified, check them
|
||||
if ($this->includePaths !== []) {
|
||||
$included = false;
|
||||
foreach ($this->includePaths as $includePath) {
|
||||
if ($this->pathMatches($path, $includePath)) {
|
||||
$included = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$included) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have a mask for this value's type
|
||||
$type = $this->getValueType($value);
|
||||
return isset($this->typeMasks[$type]);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function getName(): string
|
||||
{
|
||||
$typeCount = count($this->typeMasks);
|
||||
$types = implode(', ', array_keys($this->typeMasks));
|
||||
return sprintf('Data Type Masking (%d types: %s)', $typeCount, $types);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function validate(): bool
|
||||
{
|
||||
if ($this->typeMasks === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$validTypes = ['string', 'integer', 'double', 'boolean', 'array', 'object', 'NULL', 'resource'];
|
||||
|
||||
foreach ($this->typeMasks as $type => $mask) {
|
||||
if (!in_array($type, $validTypes, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @psalm-suppress DocblockTypeContradiction - Runtime validation for defensive programming */
|
||||
if (!is_string($mask)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a strategy for common data types.
|
||||
*
|
||||
* @param array<string, string> $customMasks Additional or override masks
|
||||
* @param int $priority Strategy priority
|
||||
*/
|
||||
public static function createDefault(array $customMasks = [], int $priority = 40): self
|
||||
{
|
||||
$defaultMasks = [
|
||||
'string' => Mask::MASK_STRING,
|
||||
'integer' => '999',
|
||||
'double' => '99.99',
|
||||
'boolean' => 'false',
|
||||
'array' => '[]',
|
||||
'object' => '{}',
|
||||
'NULL' => '',
|
||||
];
|
||||
|
||||
$masks = array_merge($defaultMasks, $customMasks);
|
||||
|
||||
return new self($masks, [], [], $priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a strategy that only masks sensitive data types.
|
||||
*
|
||||
* @param array<string, string> $customMasks Additional or override masks
|
||||
* @param int $priority Strategy priority
|
||||
*/
|
||||
public static function createSensitiveOnly(array $customMasks = [], int $priority = 40): self
|
||||
{
|
||||
$sensitiveMasks = [
|
||||
'string' => Mask::MASK_MASKED, // Strings often contain sensitive data
|
||||
'array' => '[]', // Arrays might contain sensitive structured data
|
||||
'object' => '{}', // Objects might contain sensitive data
|
||||
];
|
||||
|
||||
$masks = array_merge($sensitiveMasks, $customMasks);
|
||||
|
||||
return new self($masks, [], [], $priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a normalized type name for a value.
|
||||
*
|
||||
* @param mixed $value The value to get the type for
|
||||
* @return string The normalized type name
|
||||
*/
|
||||
private function getValueType(mixed $value): string
|
||||
{
|
||||
$type = gettype($value);
|
||||
|
||||
// Normalize some type names to match common usage
|
||||
return match ($type) {
|
||||
'double' => 'double', // Keep as 'double' for consistency with gettype()
|
||||
'boolean' => 'boolean',
|
||||
'integer' => 'integer',
|
||||
default => $type,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply type-specific masking to a value.
|
||||
*
|
||||
* @param mixed $value The original value
|
||||
* @param string $mask The mask to apply
|
||||
* @param string $type The value type for error context
|
||||
* @return mixed The masked value
|
||||
*
|
||||
* @throws MaskingOperationFailedException
|
||||
*/
|
||||
private function applyTypeMask(mixed $value, string $mask, string $type): mixed
|
||||
{
|
||||
// For null values, mask should also be null or empty
|
||||
if ($value === null) {
|
||||
return $mask === '' ? null : $mask;
|
||||
}
|
||||
|
||||
// Try to convert mask to appropriate type
|
||||
try {
|
||||
return match ($type) {
|
||||
'integer' => is_numeric($mask) ? (int) $mask : $mask,
|
||||
'double' => is_numeric($mask) ? (float) $mask : $mask,
|
||||
'boolean' => filter_var($mask, FILTER_VALIDATE_BOOLEAN),
|
||||
'array' => $this->parseArrayMask($mask),
|
||||
'object' => $this->parseObjectMask($mask),
|
||||
'string' => $mask,
|
||||
default => $mask,
|
||||
};
|
||||
} catch (Throwable $throwable) {
|
||||
throw MaskingOperationFailedException::dataTypeMaskingFailed(
|
||||
$type,
|
||||
$value,
|
||||
'Failed to apply type mask: ' . $throwable->getMessage(),
|
||||
$throwable
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an array mask from string representation.
|
||||
*
|
||||
* @param string $mask The mask string
|
||||
* @return array<mixed> The parsed array
|
||||
*
|
||||
* @throws MaskingOperationFailedException
|
||||
*/
|
||||
private function parseArrayMask(string $mask): array
|
||||
{
|
||||
// Handle JSON array representation
|
||||
if (str_starts_with($mask, '[') && str_ends_with($mask, ']')) {
|
||||
$decoded = json_decode($mask, true);
|
||||
if ($decoded !== null && is_array($decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle simple cases
|
||||
if ($mask === '[]' || $mask === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Try to split on commas for simple arrays
|
||||
return explode(',', trim($mask, '[]'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an object mask from string representation.
|
||||
*
|
||||
* @param string $mask The mask string
|
||||
* @return object The parsed object
|
||||
*
|
||||
* @throws MaskingOperationFailedException
|
||||
*/
|
||||
private function parseObjectMask(string $mask): object
|
||||
{
|
||||
// Handle JSON object representation
|
||||
if (str_starts_with($mask, '{') && str_ends_with($mask, '}')) {
|
||||
$decoded = json_decode($mask, false);
|
||||
if ($decoded !== null && is_object($decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle simple cases
|
||||
if ($mask === '{}' || $mask === '') {
|
||||
return (object) [];
|
||||
}
|
||||
|
||||
// Create a simple object with the mask as a property
|
||||
return (object) ['masked' => $mask];
|
||||
}
|
||||
}
|
||||
294
src/Strategies/FieldPathMaskingStrategy.php
Normal file
294
src/Strategies/FieldPathMaskingStrategy.php
Normal file
@@ -0,0 +1,294 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Strategies;
|
||||
|
||||
use Throwable;
|
||||
use Monolog\LogRecord;
|
||||
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\MaskingOperationFailedException;
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants as Mask;
|
||||
|
||||
/**
|
||||
* Field path-based masking strategy.
|
||||
*
|
||||
* Applies masking based on specific field paths using dot notation.
|
||||
* Supports static replacements, regex patterns, and removal of fields.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class FieldPathMaskingStrategy extends AbstractMaskingStrategy
|
||||
{
|
||||
/**
|
||||
* @param array<string, FieldMaskConfig|string> $fieldConfigs Field path => config mappings
|
||||
* @param int $priority Strategy priority (default: 80)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $fieldConfigs,
|
||||
int $priority = 80
|
||||
) {
|
||||
parent::__construct($priority, [
|
||||
'field_configs' => array_map(
|
||||
/**
|
||||
* @return (null|string)[]|string
|
||||
*
|
||||
* @psalm-return array{type: string, replacement: null|string}|string
|
||||
*/
|
||||
fn(FieldMaskConfig|string $config): array|string => $config instanceof FieldMaskConfig
|
||||
? $config->toArray() : $config,
|
||||
$fieldConfigs
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function mask(mixed $value, string $path, LogRecord $logRecord): mixed
|
||||
{
|
||||
$config = $this->getConfigForPath($path);
|
||||
|
||||
if ($config === null) {
|
||||
return $value; // Should not happen if shouldApply was called first
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->applyFieldConfig($value, $config, $path);
|
||||
} catch (Throwable $throwable) {
|
||||
throw MaskingOperationFailedException::fieldPathMaskingFailed(
|
||||
$path,
|
||||
$value,
|
||||
$throwable->getMessage(),
|
||||
$throwable
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function shouldApply(mixed $value, string $path, LogRecord $logRecord): bool
|
||||
{
|
||||
return $this->getConfigForPath($path) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function getName(): string
|
||||
{
|
||||
$configCount = count($this->fieldConfigs);
|
||||
return sprintf('Field Path Masking (%d fields)', $configCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function validate(): bool
|
||||
{
|
||||
if ($this->fieldConfigs === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate each configuration
|
||||
foreach ($this->fieldConfigs as $path => $config) {
|
||||
if (!$this->validateFieldPath($path) || !$this->validateFieldConfig($config)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a field path.
|
||||
*
|
||||
* Intentionally accepts mixed type to validate any input during configuration validation.
|
||||
*
|
||||
* @param mixed $path
|
||||
*/
|
||||
private function validateFieldPath(mixed $path): bool
|
||||
{
|
||||
return is_string($path) && $path !== '' && $path !== '0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a field configuration.
|
||||
*
|
||||
* @param mixed $config
|
||||
*/
|
||||
private function validateFieldConfig(mixed $config): bool
|
||||
{
|
||||
if (!($config instanceof FieldMaskConfig) && !is_string($config)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate regex patterns in FieldMaskConfig
|
||||
if ($config instanceof FieldMaskConfig && $config->hasRegexPattern()) {
|
||||
return $this->validateRegexPattern($config->getRegexPattern());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a regex pattern.
|
||||
*/
|
||||
private function validateRegexPattern(?string $pattern): bool
|
||||
{
|
||||
if ($pattern === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
/** @psalm-suppress ArgumentTypeCoercion - Pattern checked for null above */
|
||||
$testResult = @preg_match($pattern, '');
|
||||
return $testResult !== false;
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configuration that applies to a given field path.
|
||||
*
|
||||
* @param string $path The field path to check
|
||||
* @return FieldMaskConfig|string|null The configuration or null if no match
|
||||
*/
|
||||
private function getConfigForPath(string $path): FieldMaskConfig|string|null
|
||||
{
|
||||
// First try exact matches
|
||||
if (isset($this->fieldConfigs[$path])) {
|
||||
return $this->fieldConfigs[$path];
|
||||
}
|
||||
|
||||
// Then try pattern matches
|
||||
foreach ($this->fieldConfigs as $configPath => $config) {
|
||||
if ($this->pathMatches($path, $configPath)) {
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply field configuration to a value.
|
||||
*
|
||||
* @param mixed $value The value to mask
|
||||
* @param FieldMaskConfig|string $config The masking configuration
|
||||
* @param string $path The field path for error context
|
||||
* @return mixed The masked value
|
||||
*
|
||||
* @throws MaskingOperationFailedException
|
||||
*/
|
||||
private function applyFieldConfig(mixed $value, FieldMaskConfig|string $config, string $path): mixed
|
||||
{
|
||||
// Simple string replacement
|
||||
if (is_string($config)) {
|
||||
return $config;
|
||||
}
|
||||
|
||||
// FieldMaskConfig handling
|
||||
return $this->applyFieldMaskConfig($value, $config, $path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a FieldMaskConfig to a value.
|
||||
*
|
||||
* @param mixed $value The value to mask
|
||||
* @param FieldMaskConfig $config The mask configuration
|
||||
* @param string $path The field path for error context
|
||||
* @return mixed The masked value
|
||||
*
|
||||
* @throws MaskingOperationFailedException
|
||||
*/
|
||||
private function applyFieldMaskConfig(mixed $value, FieldMaskConfig $config, string $path): mixed
|
||||
{
|
||||
// Handle removal
|
||||
if ($config->shouldRemove()) {
|
||||
return null; // This will be handled by the processor to remove the field
|
||||
}
|
||||
|
||||
// Handle regex masking
|
||||
if ($config->hasRegexPattern()) {
|
||||
return $this->applyRegexMasking($value, $config, $path);
|
||||
}
|
||||
|
||||
// Handle static replacement
|
||||
return $this->applyStaticReplacement($value, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply regex masking to a value.
|
||||
*
|
||||
* @throws MaskingOperationFailedException
|
||||
*/
|
||||
private function applyRegexMasking(mixed $value, FieldMaskConfig $config, string $path): mixed
|
||||
{
|
||||
try {
|
||||
$stringValue = $this->valueToString($value);
|
||||
$pattern = $config->getRegexPattern();
|
||||
|
||||
if ($pattern === null) {
|
||||
throw MaskingOperationFailedException::fieldPathMaskingFailed(
|
||||
$path,
|
||||
$value,
|
||||
'Regex pattern is null'
|
||||
);
|
||||
}
|
||||
|
||||
$replacement = $config->getReplacement() ?? Mask::MASK_MASKED;
|
||||
|
||||
/** @psalm-suppress ArgumentTypeCoercion - Pattern validated during construction */
|
||||
$result = preg_replace($pattern, $replacement, $stringValue);
|
||||
if ($result === null) {
|
||||
throw MaskingOperationFailedException::fieldPathMaskingFailed(
|
||||
$path,
|
||||
$value,
|
||||
'Regex replacement failed'
|
||||
);
|
||||
}
|
||||
|
||||
return $this->preserveValueType($value, $result);
|
||||
} catch (MaskingOperationFailedException $e) {
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
throw MaskingOperationFailedException::fieldPathMaskingFailed(
|
||||
$path,
|
||||
$value,
|
||||
'Regex processing failed: ' . $e->getMessage(),
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply static replacement to a value, preserving type when possible.
|
||||
*/
|
||||
private function applyStaticReplacement(mixed $value, FieldMaskConfig $config): mixed
|
||||
{
|
||||
$replacement = $config->getReplacement();
|
||||
if ($replacement === null) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Try to preserve type if the replacement can be converted
|
||||
$result = $replacement;
|
||||
|
||||
if (is_int($value) && is_numeric($replacement)) {
|
||||
$result = (int) $replacement;
|
||||
} elseif (is_float($value) && is_numeric($replacement)) {
|
||||
$result = (float) $replacement;
|
||||
} elseif (is_bool($value)) {
|
||||
$result = filter_var($replacement, FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
82
src/Strategies/MaskingStrategyInterface.php
Normal file
82
src/Strategies/MaskingStrategyInterface.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Strategies;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\MaskingOperationFailedException;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\GdprProcessorException;
|
||||
use Monolog\LogRecord;
|
||||
|
||||
/**
|
||||
* Interface for implementing custom masking strategies.
|
||||
*
|
||||
* This interface allows for pluggable masking approaches, enabling users to
|
||||
* create custom masking logic while maintaining consistency with the library's
|
||||
* architecture.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
interface MaskingStrategyInterface
|
||||
{
|
||||
/**
|
||||
* Apply masking to a given value.
|
||||
*
|
||||
* @param mixed $value The value to be masked
|
||||
* @param string $path The field path (dot notation) where the value was found
|
||||
* @param LogRecord $logRecord The complete log record for context
|
||||
* @return mixed The masked value
|
||||
*
|
||||
* @throws MaskingOperationFailedException
|
||||
*/
|
||||
public function mask(mixed $value, string $path, LogRecord $logRecord): mixed;
|
||||
|
||||
/**
|
||||
* Determine if this strategy should be applied to the given value/context.
|
||||
*
|
||||
* @param mixed $value The value to potentially mask
|
||||
* @param string $path The field path (dot notation) where the value was found
|
||||
* @param LogRecord $logRecord The complete log record for context
|
||||
* @return bool True if this strategy should be applied, false otherwise
|
||||
*/
|
||||
public function shouldApply(mixed $value, string $path, LogRecord $logRecord): bool;
|
||||
|
||||
/**
|
||||
* Get a human-readable name for this masking strategy.
|
||||
*
|
||||
* @return string The strategy name (e.g., "Regex Pattern", "Credit Card", "Email")
|
||||
*/
|
||||
public function getName(): string;
|
||||
|
||||
/**
|
||||
* Get the priority of this strategy (higher number = higher priority).
|
||||
*
|
||||
* When multiple strategies match, the one with highest priority is used.
|
||||
* Built-in strategies use priorities in the range 0-100.
|
||||
*
|
||||
* @return int The priority level (0-1000, where 1000 is highest priority)
|
||||
*/
|
||||
public function getPriority(): int;
|
||||
|
||||
/**
|
||||
* Get configuration options for this strategy.
|
||||
*
|
||||
* This can be used by management interfaces to display strategy settings
|
||||
* or for serialization/deserialization of strategy configurations.
|
||||
*
|
||||
* @return array<string, mixed> Configuration options as key-value pairs
|
||||
*/
|
||||
public function getConfiguration(): array;
|
||||
|
||||
/**
|
||||
* Validate the strategy configuration and dependencies.
|
||||
*
|
||||
* This method should check that the strategy is properly configured
|
||||
* and can function correctly with the current environment.
|
||||
*
|
||||
* @return bool True if the strategy is valid and ready to use
|
||||
*
|
||||
* @throws GdprProcessorException
|
||||
*/
|
||||
public function validate(): bool;
|
||||
}
|
||||
257
src/Strategies/RegexMaskingStrategy.php
Normal file
257
src/Strategies/RegexMaskingStrategy.php
Normal file
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Strategies;
|
||||
|
||||
use Throwable;
|
||||
use Error;
|
||||
use Monolog\LogRecord;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\MaskingOperationFailedException;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidRegexPatternException;
|
||||
|
||||
/**
|
||||
* Regex-based masking strategy.
|
||||
*
|
||||
* Applies regex pattern matching to mask sensitive data based on patterns.
|
||||
* Supports multiple patterns with corresponding replacements.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class RegexMaskingStrategy extends AbstractMaskingStrategy
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $patterns Array of regex pattern => replacement pairs
|
||||
* @param array<string> $includePaths Optional field paths to include (empty = all paths)
|
||||
* @param array<string> $excludePaths Optional field paths to exclude
|
||||
* @param int $priority Strategy priority (default: 60)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $patterns,
|
||||
private readonly array $includePaths = [],
|
||||
private readonly array $excludePaths = [],
|
||||
int $priority = 60
|
||||
) {
|
||||
parent::__construct($priority, [
|
||||
'patterns' => $patterns,
|
||||
'include_paths' => $includePaths,
|
||||
'exclude_paths' => $excludePaths,
|
||||
]);
|
||||
|
||||
$this->validatePatterns();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function mask(mixed $value, string $path, LogRecord $logRecord): mixed
|
||||
{
|
||||
try {
|
||||
$stringValue = $this->valueToString($value);
|
||||
$maskedString = $this->applyPatterns($stringValue);
|
||||
return $this->preserveValueType($value, $maskedString);
|
||||
} catch (Throwable $throwable) {
|
||||
throw MaskingOperationFailedException::regexMaskingFailed(
|
||||
implode(', ', array_keys($this->patterns)),
|
||||
$this->generateValuePreview($value),
|
||||
$throwable->getMessage(),
|
||||
$throwable
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function shouldApply(mixed $value, string $path, LogRecord $logRecord): bool
|
||||
{
|
||||
// Check exclude paths first
|
||||
foreach ($this->excludePaths as $excludePath) {
|
||||
if ($this->pathMatches($path, $excludePath)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If include paths are specified, check them
|
||||
if ($this->includePaths !== []) {
|
||||
$included = false;
|
||||
foreach ($this->includePaths as $includePath) {
|
||||
if ($this->pathMatches($path, $includePath)) {
|
||||
$included = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$included) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if value contains any pattern matches
|
||||
try {
|
||||
$stringValue = $this->valueToString($value);
|
||||
return $this->hasPatternMatches($stringValue);
|
||||
} catch (MaskingOperationFailedException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function getName(): string
|
||||
{
|
||||
$patternCount = count($this->patterns);
|
||||
return sprintf('Regex Pattern Masking (%d patterns)', $patternCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function validate(): bool
|
||||
{
|
||||
if ($this->patterns === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->validatePatterns();
|
||||
return true;
|
||||
} catch (InvalidRegexPatternException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all regex patterns to a string value.
|
||||
*
|
||||
* @param string $value The string to process
|
||||
* @return string The processed string with patterns applied
|
||||
* @throws MaskingOperationFailedException
|
||||
*/
|
||||
private function applyPatterns(string $value): string
|
||||
{
|
||||
$result = $value;
|
||||
|
||||
foreach ($this->patterns as $pattern => $replacement) {
|
||||
try {
|
||||
/** @psalm-suppress ArgumentTypeCoercion - Pattern validated during construction */
|
||||
$processedResult = preg_replace($pattern, $replacement, $result);
|
||||
|
||||
if ($processedResult === null) {
|
||||
throw MaskingOperationFailedException::regexMaskingFailed(
|
||||
$pattern,
|
||||
$value,
|
||||
'preg_replace returned null - possible PCRE error'
|
||||
);
|
||||
}
|
||||
|
||||
$result = $processedResult;
|
||||
} catch (Error $e) {
|
||||
throw MaskingOperationFailedException::regexMaskingFailed(
|
||||
$pattern,
|
||||
$value,
|
||||
'Pattern execution failed: ' . $e->getMessage(),
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string contains any pattern matches.
|
||||
*
|
||||
* @param string $value The string to check
|
||||
* @return bool True if any patterns match
|
||||
*/
|
||||
private function hasPatternMatches(string $value): bool
|
||||
{
|
||||
foreach (array_keys($this->patterns) as $pattern) {
|
||||
try {
|
||||
/** @psalm-suppress ArgumentTypeCoercion - Pattern validated during construction */
|
||||
if (preg_match($pattern, $value) === 1) {
|
||||
return true;
|
||||
}
|
||||
} catch (Error) {
|
||||
// Skip invalid patterns during matching
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all regex patterns.
|
||||
*
|
||||
* @throws InvalidRegexPatternException
|
||||
*/
|
||||
private function validatePatterns(): void
|
||||
{
|
||||
foreach (array_keys($this->patterns) as $pattern) {
|
||||
$this->validateSinglePattern($pattern);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single regex pattern.
|
||||
*
|
||||
* @param string $pattern The pattern to validate
|
||||
*
|
||||
* @throws InvalidRegexPatternException
|
||||
*/
|
||||
private function validateSinglePattern(string $pattern): void
|
||||
{
|
||||
// Test pattern compilation by attempting a match
|
||||
/** @psalm-suppress ArgumentTypeCoercion - Pattern validated during construction */
|
||||
$testResult = @preg_match($pattern, '');
|
||||
|
||||
if ($testResult === false) {
|
||||
$error = preg_last_error();
|
||||
throw InvalidRegexPatternException::compilationFailed($pattern, $error);
|
||||
}
|
||||
|
||||
// Basic ReDoS detection - look for potentially dangerous patterns
|
||||
if ($this->detectReDoSRisk($pattern)) {
|
||||
throw InvalidRegexPatternException::redosVulnerable(
|
||||
$pattern,
|
||||
'Pattern contains potentially catastrophic backtracking sequences'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic ReDoS (Regular Expression Denial of Service) risk detection.
|
||||
*
|
||||
* @param string $pattern The pattern to analyze
|
||||
* @return bool True if pattern appears to have ReDoS risk
|
||||
*/
|
||||
private function detectReDoSRisk(string $pattern): bool
|
||||
{
|
||||
// Look for common ReDoS patterns
|
||||
$riskyPatterns = [
|
||||
'/\([^)]*\+[^)]*\)[*+]/', // (x+)+ or (x+)*
|
||||
'/\([^)]*\*[^)]*\)[*+]/', // (x*)+ or (x*)*
|
||||
'/\([^)]*\+[^)]*\)\{[0-9,]+\}/', // (x+){n,m}
|
||||
'/\([^)]*\*[^)]*\)\{[0-9,]+\}/', // (x*){n,m}
|
||||
'/\(\.\*\s*\|\s*\.\*\)/', // (.*|.*) - identical alternations
|
||||
'/\(\.\+\s*\|\s*\.\+\)/', // (.+|.+) - identical alternations
|
||||
'/\([a-zA-Z0-9]+(\s*\|\s*[a-zA-Z0-9]+){2,}\)\*/', // Multiple overlapping alternations with *
|
||||
'/\([a-zA-Z0-9]+(\s*\|\s*[a-zA-Z0-9]+){2,}\)\+/', // Multiple overlapping alternations with +
|
||||
];
|
||||
|
||||
foreach ($riskyPatterns as $riskyPattern) {
|
||||
if (preg_match($riskyPattern, $pattern) === 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
347
src/Strategies/StrategyManager.php
Normal file
347
src/Strategies/StrategyManager.php
Normal file
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Strategies;
|
||||
|
||||
use Throwable;
|
||||
use Monolog\LogRecord;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\MaskingOperationFailedException;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\GdprProcessorException;
|
||||
|
||||
/**
|
||||
* Strategy manager for coordinating multiple masking strategies.
|
||||
*
|
||||
* Manages a collection of masking strategies, applies them in priority order,
|
||||
* and provides utilities for strategy validation and introspection.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class StrategyManager
|
||||
{
|
||||
/** @var array<MaskingStrategyInterface> */
|
||||
private array $strategies = [];
|
||||
|
||||
/** @var array<MaskingStrategyInterface> */
|
||||
private array $sortedStrategies = [];
|
||||
|
||||
private bool $needsSorting = false;
|
||||
|
||||
/**
|
||||
* @param array<MaskingStrategyInterface> $strategies Initial strategies to register
|
||||
*/
|
||||
public function __construct(array $strategies = [])
|
||||
{
|
||||
foreach ($strategies as $strategy) {
|
||||
$this->addStrategy($strategy);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a masking strategy.
|
||||
*
|
||||
* @param MaskingStrategyInterface $strategy The strategy to add
|
||||
*
|
||||
* @throws GdprProcessorException If strategy validation fails
|
||||
*/
|
||||
public function addStrategy(MaskingStrategyInterface $strategy): static
|
||||
{
|
||||
if (!$strategy->validate()) {
|
||||
throw GdprProcessorException::withContext(
|
||||
'Invalid masking strategy',
|
||||
[
|
||||
'strategy_name' => $strategy->getName(),
|
||||
'strategy_class' => $strategy::class,
|
||||
'configuration' => $strategy->getConfiguration(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$this->strategies[] = $strategy;
|
||||
$this->needsSorting = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a strategy by instance.
|
||||
*
|
||||
* @param MaskingStrategyInterface $strategy The strategy to remove
|
||||
* @return bool True if the strategy was found and removed
|
||||
*/
|
||||
public function removeStrategy(MaskingStrategyInterface $strategy): bool
|
||||
{
|
||||
$key = array_search($strategy, $this->strategies, true);
|
||||
if ($key !== false) {
|
||||
unset($this->strategies[$key]);
|
||||
$this->strategies = array_values($this->strategies);
|
||||
$this->needsSorting = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all strategies of a specific class.
|
||||
*
|
||||
* @param string $className The class name to remove
|
||||
*
|
||||
* @return int The number of strategies removed
|
||||
*
|
||||
* @psalm-return int<0, max>
|
||||
*/
|
||||
public function removeStrategiesByClass(string $className): int
|
||||
{
|
||||
/** @var int<0, max> $removed */
|
||||
$removed = 0;
|
||||
$this->strategies = array_filter(
|
||||
$this->strategies,
|
||||
function (MaskingStrategyInterface $strategy) use ($className, &$removed): bool {
|
||||
if ($strategy instanceof $className) {
|
||||
$removed++;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
if ($removed > 0) {
|
||||
$this->strategies = array_values($this->strategies);
|
||||
$this->needsSorting = true;
|
||||
}
|
||||
|
||||
return $removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all strategies.
|
||||
*/
|
||||
public function clearStrategies(): static
|
||||
{
|
||||
$this->strategies = [];
|
||||
$this->sortedStrategies = [];
|
||||
$this->needsSorting = false;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply masking strategies to a value.
|
||||
*
|
||||
* @param mixed $value The value to mask
|
||||
* @param string $path The field path where the value was found
|
||||
* @param LogRecord $logRecord The complete log record for context
|
||||
* @return mixed The masked value
|
||||
*
|
||||
* @throws MaskingOperationFailedException
|
||||
*/
|
||||
public function maskValue(mixed $value, string $path, LogRecord $logRecord): mixed
|
||||
{
|
||||
$strategies = $this->getSortedStrategies();
|
||||
|
||||
if ($strategies === []) {
|
||||
return $value; // No strategies configured
|
||||
}
|
||||
|
||||
// Find the first applicable strategy (highest priority)
|
||||
foreach ($strategies as $strategy) {
|
||||
if ($strategy->shouldApply($value, $path, $logRecord)) {
|
||||
try {
|
||||
return $strategy->mask($value, $path, $logRecord);
|
||||
} catch (Throwable $e) {
|
||||
throw MaskingOperationFailedException::customCallbackFailed(
|
||||
$path,
|
||||
$value,
|
||||
sprintf(
|
||||
"Strategy '%s' failed: %s",
|
||||
$strategy->getName(),
|
||||
$e->getMessage()
|
||||
),
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No applicable strategy found
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any strategy would apply to a given value/context.
|
||||
*
|
||||
* @param mixed $value The value to check
|
||||
* @param string $path The field path where the value was found
|
||||
* @param LogRecord $logRecord The complete log record for context
|
||||
* @return bool True if at least one strategy would apply
|
||||
*/
|
||||
public function hasApplicableStrategy(mixed $value, string $path, LogRecord $logRecord): bool
|
||||
{
|
||||
foreach ($this->getSortedStrategies() as $strategy) {
|
||||
if ($strategy->shouldApply($value, $path, $logRecord)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all applicable strategies for a given value/context.
|
||||
*
|
||||
* @param mixed $value The value to check
|
||||
* @param string $path The field path where the value was found
|
||||
* @param LogRecord $logRecord The complete log record for context
|
||||
*
|
||||
* @return MaskingStrategyInterface[] Applicable strategies in priority order
|
||||
*
|
||||
* @psalm-return list<MaskingStrategyInterface>
|
||||
*/
|
||||
public function getApplicableStrategies(mixed $value, string $path, LogRecord $logRecord): array
|
||||
{
|
||||
$applicable = [];
|
||||
foreach ($this->getSortedStrategies() as $strategy) {
|
||||
if ($strategy->shouldApply($value, $path, $logRecord)) {
|
||||
$applicable[] = $strategy;
|
||||
}
|
||||
}
|
||||
|
||||
return $applicable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered strategies.
|
||||
*
|
||||
* @return array<MaskingStrategyInterface> All strategies
|
||||
*/
|
||||
public function getAllStrategies(): array
|
||||
{
|
||||
return $this->strategies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get strategies sorted by priority (highest first).
|
||||
*
|
||||
* @return MaskingStrategyInterface[] Sorted strategies
|
||||
*
|
||||
* @psalm-return list<MaskingStrategyInterface>
|
||||
*/
|
||||
public function getSortedStrategies(): array
|
||||
{
|
||||
if ($this->needsSorting || $this->sortedStrategies === []) {
|
||||
$this->sortedStrategies = $this->strategies;
|
||||
usort($this->sortedStrategies, fn($a, $b): int => $b->getPriority() <=> $a->getPriority());
|
||||
$this->needsSorting = false;
|
||||
}
|
||||
|
||||
return $this->sortedStrategies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get strategy statistics.
|
||||
*
|
||||
* @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>},...}}
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
$strategies = $this->getAllStrategies();
|
||||
$stats = [
|
||||
'total_strategies' => count($strategies),
|
||||
'strategy_types' => [],
|
||||
'priority_distribution' => [],
|
||||
'strategies' => [],
|
||||
];
|
||||
|
||||
foreach ($strategies as $strategy) {
|
||||
$className = $strategy::class;
|
||||
$lastBackslashPos = strrpos($className, '\\');
|
||||
$shortName = $lastBackslashPos !== false ? substr($className, $lastBackslashPos + 1) : $className;
|
||||
|
||||
// Count by type
|
||||
$stats['strategy_types'][$shortName] = ($stats['strategy_types'][$shortName] ?? 0) + 1;
|
||||
|
||||
// Priority distribution
|
||||
$priority = $strategy->getPriority();
|
||||
$priorityRange = match (true) {
|
||||
$priority >= 90 => '90-100 (Critical)',
|
||||
$priority >= 80 => '80-89 (High)',
|
||||
$priority >= 60 => '60-79 (Medium-High)',
|
||||
$priority >= 40 => '40-59 (Medium)',
|
||||
$priority >= 20 => '20-39 (Low-Medium)',
|
||||
default => '0-19 (Low)',
|
||||
};
|
||||
$stats['priority_distribution'][$priorityRange] = (
|
||||
$stats['priority_distribution'][$priorityRange] ?? 0
|
||||
) + 1;
|
||||
|
||||
// Individual strategy info
|
||||
$stats['strategies'][] = [
|
||||
'name' => $strategy->getName(),
|
||||
'class' => $shortName,
|
||||
'priority' => $priority,
|
||||
'configuration' => $strategy->getConfiguration(),
|
||||
];
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all registered strategies.
|
||||
*
|
||||
* @return string[]
|
||||
*
|
||||
* @psalm-return array<string, string>
|
||||
*/
|
||||
public function validateAllStrategies(): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
foreach ($this->strategies as $strategy) {
|
||||
try {
|
||||
if (!$strategy->validate()) {
|
||||
$errors[$strategy->getName()] = 'Strategy validation failed';
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors[$strategy->getName()] = 'Validation error: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a default strategy manager with common strategies.
|
||||
*
|
||||
* @param array<string, string> $regexPatterns Regex patterns for RegexMaskingStrategy
|
||||
* @param array<string, mixed> $fieldConfigs Field configurations for FieldPathMaskingStrategy
|
||||
* @param array<string, string> $typeMasks Type masks for DataTypeMaskingStrategy
|
||||
*/
|
||||
public static function createDefault(
|
||||
array $regexPatterns = [],
|
||||
array $fieldConfigs = [],
|
||||
array $typeMasks = []
|
||||
): self {
|
||||
$manager = new self();
|
||||
|
||||
// Add regex strategy if patterns provided
|
||||
if ($regexPatterns !== []) {
|
||||
$manager->addStrategy(new RegexMaskingStrategy($regexPatterns));
|
||||
}
|
||||
|
||||
// Add field path strategy if configs provided
|
||||
if ($fieldConfigs !== []) {
|
||||
$manager->addStrategy(new FieldPathMaskingStrategy($fieldConfigs));
|
||||
}
|
||||
|
||||
// Add data type strategy if masks provided
|
||||
if ($typeMasks !== []) {
|
||||
$manager->addStrategy(new DataTypeMaskingStrategy($typeMasks));
|
||||
}
|
||||
|
||||
return $manager;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user