Files
monolog-gdpr-filter/src/FieldMaskConfig.php
Ismo Vuorinen 00c6f76c97 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
2025-10-31 13:59:01 +02:00

227 lines
6.8 KiB
PHP

<?php
declare(strict_types=1);
namespace Ivuorinen\MonologGdprFilter;
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidConfigurationException;
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidRegexPatternException;
use Ivuorinen\MonologGdprFilter\MaskConstants as Mask;
/**
* FieldMaskConfig: configuration for masking/removal per field path.
*
* @api
*/
final readonly class FieldMaskConfig
{
public const MASK_REGEX = 'mask_regex';
public const REMOVE = 'remove';
public const REPLACE = 'replace';
public function __construct(
public string $type,
public ?string $replacement = null
) {}
/**
* Create a configuration for field removal.
*/
public static function remove(): self
{
return new self(self::REMOVE);
}
/**
* Create a configuration for static replacement.
*
* @param string $replacement The replacement value
*/
public static function replace(string $replacement): self
{
return new self(self::REPLACE, $replacement);
}
/**
* Create a configuration that uses the processor's global regex patterns.
* This is a shorthand for indicating "apply regex masking from the processor".
*/
public static function useProcessorPatterns(): self
{
return new self(self::MASK_REGEX);
}
/**
* Create a configuration for regex-based masking.
*
* @param string $pattern The regex pattern
* @param string $replacement The replacement string (default: '***MASKED***')
*
* @throws InvalidConfigurationException|InvalidRegexPatternException When pattern
* is empty or invalid, or replacement is empty
*/
public static function regexMask(string $pattern, string $replacement = Mask::MASK_MASKED): self
{
// Validate pattern is not empty
if (trim($pattern) === '') {
throw InvalidConfigurationException::emptyValue('regex pattern');
}
// Validate replacement is not empty
if (trim($replacement) === '') {
throw InvalidConfigurationException::emptyValue('replacement string');
}
// Validate regex pattern syntax
if (!self::isValidRegexPattern($pattern)) {
throw InvalidRegexPatternException::forPattern($pattern, 'Invalid regex pattern syntax');
}
return new self(self::MASK_REGEX, $pattern . '::' . $replacement);
}
/**
* Check if this configuration should remove the field.
*/
public function shouldRemove(): bool
{
return $this->type === self::REMOVE;
}
/**
* Check if this configuration has a regex pattern.
*/
public function hasRegexPattern(): bool
{
return $this->type === self::MASK_REGEX;
}
/**
* Get the regex pattern from a regex mask configuration.
*
* @return string|null The regex pattern or null if not a regex mask
*/
public function getRegexPattern(): ?string
{
if ($this->type !== self::MASK_REGEX || $this->replacement === null) {
return null;
}
$parts = explode('::', $this->replacement, 2);
return $parts[0] ?? null;
}
/**
* Get the replacement value.
*
* @return string|null The replacement value
*/
public function getReplacement(): ?string
{
if ($this->type === self::MASK_REGEX && $this->replacement !== null) {
$parts = explode('::', $this->replacement, 2);
return $parts[1] ?? Mask::MASK_MASKED;
}
return $this->replacement;
}
/**
* Convert to array representation.
*
* @return (null|string)[]
*
* @psalm-return array{type: string, replacement: null|string}
*/
public function toArray(): array
{
return [
'type' => $this->type,
'replacement' => $this->replacement,
];
}
/**
* Create from array representation.
*
* @param array<string, mixed> $data
*
* @throws InvalidConfigurationException|InvalidRegexPatternException When data contains invalid values
*/
public static function fromArray(array $data): self
{
$type = $data['type'] ?? self::REPLACE;
$replacement = $data['replacement'] ?? null;
// Validate type
$validTypes = [self::MASK_REGEX, self::REMOVE, self::REPLACE];
if (!in_array($type, $validTypes, true)) {
$validList = implode(', ', $validTypes);
throw InvalidConfigurationException::forParameter(
'type',
$type,
sprintf("Must be one of: %s", $validList)
);
}
// Validate replacement for REPLACE type - only when explicitly provided
if (
$type === self::REPLACE &&
array_key_exists('replacement', $data) &&
($replacement === null || trim($replacement) === '')
) {
throw InvalidConfigurationException::forParameter(
'replacement',
null,
'Cannot be null or empty for REPLACE type'
);
}
return new self($type, $replacement);
}
/**
* Validate if a regex pattern is syntactically correct.
*
* @param string $pattern The regex pattern to validate
* @return bool True if valid, false otherwise
*/
private static function isValidRegexPattern(string $pattern): bool
{
// Suppress warnings for invalid patterns
$previousErrorReporting = error_reporting(E_ERROR);
try {
// Test the pattern by attempting to use it
/** @psalm-suppress ArgumentTypeCoercion - Pattern validated by caller */
$result = @preg_match($pattern, '');
// Check if preg_match succeeded (returns 0 or 1) or failed (returns false)
$isValid = $result !== false;
// Additional check for PREG errors
if ($isValid && preg_last_error() !== PREG_NO_ERROR) {
$isValid = false;
}
// Additional validation for effectively empty patterns
// Check for patterns that are effectively empty (like '//' or '/\s*/')
// Extract the pattern content between delimiters
if ($isValid && preg_match('/^(.)(.*?)\1[gimuxXs]*$/', $pattern, $matches)) {
$patternContent = $matches[2];
// Reject patterns that are empty or only whitespace-based
if ($patternContent === '' || trim($patternContent) === '' || $patternContent === '\s*') {
$isValid = false;
}
}
return $isValid;
} finally {
// Restore previous error reporting level
error_reporting($previousErrorReporting);
}
}
}