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:
2025-10-31 13:59:01 +02:00
committed by GitHub
parent 63637900c8
commit 00c6f76c97
126 changed files with 30815 additions and 921 deletions

View File

@@ -0,0 +1,216 @@
<?php
namespace Ivuorinen\MonologGdprFilter\Laravel\Commands;
use Monolog\LogRecord;
use DateTimeImmutable;
use Monolog\Level;
use JsonException;
use Illuminate\Console\Command;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\Exceptions\CommandExecutionException;
/**
* Artisan command for debugging GDPR configuration and testing.
*
* This command provides information about the current GDPR configuration
* and allows testing with sample log data.
*
* @api
* @psalm-suppress PropertyNotSetInConstructor
*/
class GdprDebugCommand extends Command
{
private const COMMAND_NAME = 'gdpr:debug';
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'gdpr:debug
{--test-data= : JSON string of sample data to test}
{--show-patterns : Show all configured patterns}
{--show-config : Show current configuration}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Debug GDPR configuration and test with sample data';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->info('GDPR Filter Debug Information');
$this->line('=============================');
// Show configuration if requested
if ((bool)$this->option('show-config')) {
$this->showConfiguration();
}
// Show patterns if requested
if ((bool)$this->option('show-patterns')) {
$this->showPatterns();
}
// Test with sample data if provided
$testData = (string)$this->option('test-data');
if ($testData !== '' && $testData !== '0') {
$this->testWithSampleData($testData);
}
if (!$this->option('show-config') && !$this->option('show-patterns') && !$testData) {
$this->showSummary();
}
return 0;
}
/**
* Show current GDPR configuration.
*/
protected function showConfiguration(): void
{
$this->line('');
$this->info('Current Configuration:');
$this->line('----------------------');
$config = \config('gdpr', []);
$this->line('Auto Register: ' . ($config['auto_register'] ?? true ? 'Yes' : 'No'));
$this->line('Max Depth: ' . ($config['max_depth'] ?? 100));
$this->line('Audit Logging: ' . (($config['audit_logging']['enabled'] ?? false) ? 'Enabled' : 'Disabled'));
$channels = $config['channels'] ?? [];
$this->line('Channels: ' . (empty($channels) ? 'None' : implode(', ', $channels)));
$fieldPaths = $config['field_paths'] ?? [];
$this->line('Field Paths: ' . count($fieldPaths) . ' configured');
$customCallbacks = $config['custom_callbacks'] ?? [];
$this->line('Custom Callbacks: ' . count($customCallbacks) . ' configured');
}
/**
* Show all configured patterns.
*/
protected function showPatterns(): void
{
$this->line('');
$this->info('Configured Patterns:');
$this->line('--------------------');
$config = \config('gdpr', []);
/**
* @var array<string, mixed>|null $patterns
*/
$patterns = $config['patterns'] ?? null;
if (count($patterns) === 0 && empty($patterns)) {
$this->line('No patterns configured - using defaults');
$patterns = GdprProcessor::getDefaultPatterns();
}
foreach ($patterns as $pattern => $replacement) {
$this->line(sprintf('%s => %s', $pattern, $replacement));
}
$this->line('');
$this->line('Total patterns: ' . count($patterns));
}
/**
* Test GDPR processing with sample data.
*/
protected function testWithSampleData(string $testData): void
{
$this->line('');
$this->info('Testing with sample data:');
$this->line('-------------------------');
try {
$data = json_decode($testData, true, 512, JSON_THROW_ON_ERROR);
$processor = \app('gdpr.processor');
// Test with a sample log record
$logRecord = new LogRecord(
datetime: new DateTimeImmutable(),
channel: 'test',
level: Level::Info,
message: $data['message'] ?? 'Test message',
context: $data['context'] ?? []
);
$result = $processor($logRecord);
$this->line('Original Message: ' . $logRecord->message);
$this->line('Processed Message: ' . $result->message);
if ($logRecord->context !== []) {
$this->line('');
$this->line('Original Context:');
$this->line((string)json_encode($logRecord->context, JSON_PRETTY_PRINT));
$this->line('Processed Context:');
$this->line((string)json_encode($result->context, JSON_PRETTY_PRINT));
}
} catch (JsonException $e) {
throw CommandExecutionException::forJsonProcessing(
self::COMMAND_NAME,
$testData,
$e->getMessage(),
$e
);
} catch (\Throwable $e) {
throw CommandExecutionException::forOperation(
self::COMMAND_NAME,
'data processing',
$e->getMessage(),
$e
);
}
}
/**
* Show summary information.
*/
protected function showSummary(): void
{
$this->line('');
$this->info('Quick Summary:');
$this->line('--------------');
try {
\app('gdpr.processor');
$this->line('<info>✓</info> GDPR processor is registered and ready');
$config = \config('gdpr', []);
$patterns = $config['patterns'] ?? GdprProcessor::getDefaultPatterns();
$this->line('Patterns configured: ' . count($patterns));
} catch (\Throwable $exception) {
throw CommandExecutionException::forOperation(
self::COMMAND_NAME,
'configuration check',
'GDPR processor is not properly configured: ' . $exception->getMessage(),
$exception
);
}
$this->line('');
$this->info('Available options:');
$this->line(' --show-config Show current configuration');
$this->line(' --show-patterns Show all regex patterns');
$this->line(' --test-data Test with JSON sample data');
$this->line('');
$this->info('Example usage:');
$this->line(' php artisan gdpr:debug --show-config');
$this->line(' php artisan gdpr:debug --test-data=\'{"message":"Email: test@example.com"}\'');
}
}

View File

@@ -0,0 +1,191 @@
<?php
namespace Ivuorinen\MonologGdprFilter\Laravel\Commands;
use Illuminate\Console\Command;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\Exceptions\PatternValidationException;
use Ivuorinen\MonologGdprFilter\Exceptions\CommandExecutionException;
/**
* Artisan command for testing GDPR regex patterns.
*
* This command allows developers to test regex patterns against sample data
* to ensure they work correctly before deploying to production.
*
* @api
* @psalm-suppress PropertyNotSetInConstructor
*/
class GdprTestPatternCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'gdpr:test-pattern
{pattern : The regex pattern to test}
{replacement : The replacement text}
{test-string : The string to test against}
{--validate : Validate the pattern for security}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Test GDPR regex patterns against sample data';
/**
* Execute the console command.
*
* @psalm-return 0|1
*/
public function handle(): int
{
$args = $this->extractAndNormalizeArguments();
$pattern = $args[0];
$replacement = $args[1];
$testString = $args[2];
$validate = $args[3];
$this->displayTestHeader($pattern, $replacement, $testString);
if ($validate && !$this->validatePattern($pattern, $replacement)) {
return 1;
}
return $this->executePatternTest($pattern, $replacement, $testString);
}
/**
* Extract and normalize command arguments.
*
* @return array{string, string, string, bool}
*/
private function extractAndNormalizeArguments(): array
{
$pattern = $this->argument('pattern');
$replacement = $this->argument('replacement');
$testString = $this->argument('test-string');
$validate = $this->option('validate');
$pattern = is_array($pattern) ? $pattern[0] : $pattern;
$replacement = is_array($replacement) ? $replacement[0] : $replacement;
$testString = is_array($testString) ? $testString[0] : $testString;
$validate = is_bool($validate) ? $validate : (bool) $validate;
return [
(string) ($pattern ?? ''),
(string) ($replacement ?? ''),
(string) ($testString ?? ''),
$validate,
];
}
/**
* Display the test header with pattern information.
*/
private function displayTestHeader(string $pattern, string $replacement, string $testString): void
{
$this->info('Testing GDPR Pattern');
$this->line('====================');
$this->line('Pattern: ' . $pattern);
$this->line('Replacement: ' . $replacement);
$this->line('Test String: ' . $testString);
$this->line('');
}
/**
* Validate the pattern if requested.
*/
private function validatePattern(string $pattern, string $replacement): bool
{
$this->info('Validating pattern...');
try {
GdprProcessor::validatePatternsArray([$pattern => $replacement]);
$this->line('<info>✓</info> Pattern is valid and secure');
} catch (PatternValidationException $e) {
$this->error('✗ Pattern validation failed: ' . $e->getMessage());
return false;
}
$this->line('');
return true;
}
/**
* Execute the pattern test.
*/
private function executePatternTest(string $pattern, string $replacement, string $testString): int
{
$this->info('Testing pattern match...');
try {
$this->validateInputs($pattern, $testString);
$processor = new GdprProcessor([$pattern => $replacement]);
$result = $processor->regExpMessage($testString);
$this->displayTestResult($result, $testString);
$this->showMatchDetails($pattern, $testString);
} catch (CommandExecutionException $exception) {
$this->error('✗ Pattern test failed: ' . $exception->getMessage());
return 1;
}
return 0;
}
/**
* Validate inputs are not empty.
*/
private function validateInputs(string $pattern, string $testString): void
{
if ($pattern === '' || $pattern === '0') {
throw CommandExecutionException::forInvalidInput(
'gdpr:test-pattern',
'pattern',
$pattern,
'Pattern cannot be empty'
);
}
if ($testString === '' || $testString === '0') {
throw CommandExecutionException::forInvalidInput(
'gdpr:test-pattern',
'test-string',
$testString,
'Test string cannot be empty'
);
}
}
/**
* Display the test result.
*/
private function displayTestResult(string $result, string $testString): void
{
if ($result === $testString) {
$this->line('<comment>-</comment> No match found - string unchanged');
} else {
$this->line('<info>✓</info> Pattern matched!');
$this->line('Result: ' . $result);
}
}
/**
* Show detailed matching information.
*/
private function showMatchDetails(string $pattern, string $testString): void
{
$matches = [];
if (preg_match($pattern, $testString, $matches)) {
$this->line('');
$this->info('Match details:');
foreach ($matches as $index => $match) {
$this->line(sprintf(' [%s]: %s', $index, $match));
}
}
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Ivuorinen\MonologGdprFilter\Laravel\Facades;
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
use Monolog\LogRecord;
use Illuminate\Support\Facades\Facade;
/**
* Laravel Facade for GDPR Processor.
*
* @method static string regExpMessage(string $message = '')
* @method static array<string, string> getDefaultPatterns()
* @method static FieldMaskConfig maskWithRegex()
* @method static FieldMaskConfig removeField()
* @method static FieldMaskConfig replaceWith(string $replacement)
* @method static void validatePatterns(array<string, string> $patterns)
* @method static void clearPatternCache()
* @method static LogRecord __invoke(LogRecord $record)
*
* @see \Ivuorinen\MonologGdprFilter\GdprProcessor
* @api
*/
class Gdpr extends Facade
{
/**
* Get the registered name of the component.
*
*
* @psalm-return 'gdpr.processor'
*/
protected static function getFacadeAccessor(): string
{
return 'gdpr.processor';
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace Ivuorinen\MonologGdprFilter\Laravel;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Carbon;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\Laravel\Commands\GdprTestPatternCommand;
use Ivuorinen\MonologGdprFilter\Laravel\Commands\GdprDebugCommand;
use Ivuorinen\MonologGdprFilter\Exceptions\ServiceRegistrationException;
/**
* Laravel Service Provider for Monolog GDPR Filter.
*
* This service provider automatically registers the GDPR processor with Laravel's logging system
* and provides configuration management and artisan commands.
*
* @api
*/
class GdprServiceProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../../config/gdpr.php', 'gdpr');
$this->app->singleton('gdpr.processor', function (Application $app): GdprProcessor {
$config = $app->make('config')->get('gdpr', []);
$patterns = $config['patterns'] ?? GdprProcessor::getDefaultPatterns();
$fieldPaths = $config['field_paths'] ?? [];
$customCallbacks = $config['custom_callbacks'] ?? [];
$maxDepth = $config['max_depth'] ?? 100;
$auditLogger = null;
if ($config['audit_logging']['enabled'] ?? false) {
$auditLogger = function (string $path, mixed $original, mixed $masked): void {
Log::channel('gdpr-audit')->info('GDPR Processing', [
'path' => $path,
'original_type' => gettype($original),
'was_masked' => $original !== $masked,
'timestamp' => Carbon::now()->toISOString(),
]);
};
}
return new GdprProcessor(
$patterns,
$fieldPaths,
$customCallbacks,
$auditLogger,
$maxDepth
);
});
$this->app->alias('gdpr.processor', GdprProcessor::class);
}
/**
* Bootstrap services.
*/
public function boot(): void
{
// Publish configuration file
$this->publishes([
__DIR__ . '/../../config/gdpr.php' => $this->app->configPath('gdpr.php'),
], 'gdpr-config');
// Register artisan commands
if ($this->app->runningInConsole()) {
$this->commands([
GdprTestPatternCommand::class,
GdprDebugCommand::class,
]);
}
// Auto-register with Laravel's logging system if enabled
if (\config('gdpr.auto_register', true)) {
$this->registerWithLogging();
}
}
/**
* Automatically register GDPR processor with Laravel's logging channels.
*/
protected function registerWithLogging(): void
{
$logger = $this->app->make('log');
$processor = $this->app->make('gdpr.processor');
// Get channels to apply GDPR processing to
$channels = \config('gdpr.channels', ['single', 'daily', 'stack']);
foreach ($channels as $channelName) {
try {
$channelLogger = $logger->channel($channelName);
if (method_exists($channelLogger, 'pushProcessor')) {
$channelLogger->pushProcessor($processor);
}
} catch (\Throwable $e) {
// Log proper service registration failure but continue with other channels
$exception = ServiceRegistrationException::forChannel(
$channelName,
$e->getMessage(),
$e
);
Log::debug('GDPR service registration warning: ' . $exception->getMessage());
}
}
}
}

View File

@@ -0,0 +1,207 @@
<?php
/**
* Laravel Middleware for GDPR-compliant logging using MonologGdprFilter.
* This middleware logs HTTP requests and responses while filtering out sensitive data
* according to GDPR guidelines.
*/
namespace Ivuorinen\MonologGdprFilter\Laravel\Middleware;
use JsonException;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\MaskConstants;
/**
* Middleware for GDPR-compliant request/response logging.
*
* This middleware automatically logs HTTP requests and responses
* with GDPR filtering applied to sensitive data.
*
* @api
*/
class GdprLogMiddleware
{
private const LOG_MESSAGE_HTTP_RESPONSE = 'HTTP Response';
protected GdprProcessor $processor;
public function __construct(GdprProcessor $processor)
{
$this->processor = $processor;
}
/**
* Handle an incoming request.
*
* @param Request $request
* @param Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
$startTime = microtime(true);
// Log the incoming request
$this->logRequest($request);
// Process the request
$response = $next($request);
// Log the response
$this->logResponse($request, $response, $startTime);
return $response;
}
/**
* Log the incoming request with GDPR filtering.
*/
protected function logRequest(Request $request): void
{
$requestData = [
'method' => $request->method(),
'url' => $request->fullUrl(),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'headers' => $this->filterHeaders($request->headers->all()),
'query' => $request->query(),
'body' => $this->getRequestBody($request),
];
// Apply GDPR filtering to the entire request data
$filteredData = $this->processor->recursiveMask($requestData);
Log::info('HTTP Request', $filteredData);
}
/**
* Log the response with GDPR filtering.
*
* @param \Illuminate\Http\Response|\Symfony\Component\HttpFoundation\Response $response
*/
protected function logResponse(Request $request, mixed $response, float $startTime): void
{
$duration = round((microtime(true) - $startTime) * 1000, 2);
$responseData = [
'status' => $response->getStatusCode(),
'duration_ms' => $duration,
'memory_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2),
'content_length' => $response->headers->get('Content-Length'),
'response_headers' => $this->filterHeaders($response->headers->all()),
];
// Only log response body for errors or if specifically configured
if ($response->getStatusCode() >= 400 && config('gdpr.log_error_responses', false)) {
$responseData['body'] = $this->getResponseBody($response);
}
// Apply GDPR filtering
$filteredData = $this->processor->recursiveMask($responseData);
$level = $response->getStatusCode() >= 500 ? 'error' : ($response->getStatusCode() >= 400 ? 'warning' : 'info');
match ($level) {
'error' => Log::error(self::LOG_MESSAGE_HTTP_RESPONSE, array_merge(
['method' => $request->method(), 'url' => $request->fullUrl()],
$filteredData
)),
'warning' => Log::warning(self::LOG_MESSAGE_HTTP_RESPONSE, array_merge(
['method' => $request->method(), 'url' => $request->fullUrl()],
$filteredData
)),
default => Log::info(self::LOG_MESSAGE_HTTP_RESPONSE, array_merge(
['method' => $request->method(), 'url' => $request->fullUrl()],
$filteredData
))
};
}
/**
* Get request body safely.
*/
protected function getRequestBody(Request $request): mixed
{
// Only log body for specific content types and methods
if (!in_array($request->method(), ['POST', 'PUT', 'PATCH', 'DELETE'])) {
return null;
}
$contentType = $request->header('Content-Type', '');
if (str_contains($contentType, 'application/json')) {
return $request->json()->all();
}
if (str_contains($contentType, 'application/x-www-form-urlencoded')) {
return $request->all();
}
if (str_contains($contentType, 'multipart/form-data')) {
// Don't log file uploads, just the form fields
return $request->except(['_token']) + ['files' => array_keys($request->allFiles())];
}
return null;
}
/**
* Get response body safely.
*
* @param \Illuminate\Http\Response|\Symfony\Component\HttpFoundation\Response $response
*/
protected function getResponseBody(mixed $response): mixed
{
if (!method_exists($response, 'getContent')) {
return null;
}
$content = $response->getContent();
// Try to decode JSON responses
if (
is_object($response) && property_exists($response, 'headers') &&
$response->headers->get('Content-Type') &&
str_contains((string) $response->headers->get('Content-Type'), 'application/json')
) {
try {
return json_decode((string) $content, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return ['error' => 'Invalid JSON response'];
}
}
// For other content types, limit length to prevent massive logs
return strlen((string) $content) > 1000 ? substr((string) $content, 0, 1000) . '...' : $content;
}
/**
* Filter sensitive headers.
*
* @param array<string, mixed> $headers
* @return array<string, mixed>
*/
protected function filterHeaders(array $headers): array
{
$sensitiveHeaders = [
'authorization',
'x-api-key',
'x-auth-token',
'cookie',
'set-cookie',
'php-auth-user',
'php-auth-pw',
];
$filtered = [];
foreach ($headers as $name => $value) {
$filtered[$name] = in_array(strtolower($name), $sensitiveHeaders) ? [MaskConstants::MASK_FILTERED] : $value;
}
return $filtered;
}
}