mirror of
https://github.com/ivuorinen/monolog-gdpr-filter.git
synced 2026-02-09 21:48:44 +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:
304
src/RateLimiter.php
Normal file
304
src/RateLimiter.php
Normal file
@@ -0,0 +1,304 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidRateLimitConfigurationException;
|
||||
|
||||
/**
|
||||
* Simple rate limiter to prevent audit log flooding.
|
||||
*
|
||||
* Uses a sliding window approach with memory-based storage.
|
||||
* For production use, consider implementing persistent storage.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class RateLimiter
|
||||
{
|
||||
/**
|
||||
* Storage for request timestamps per key.
|
||||
* @var array<string, array<int>>
|
||||
*/
|
||||
private static array $requests = [];
|
||||
|
||||
/**
|
||||
* Last time global cleanup was performed.
|
||||
*/
|
||||
private static int $lastCleanup = 0;
|
||||
|
||||
/**
|
||||
* How often to perform global cleanup (in seconds).
|
||||
*/
|
||||
private static int $cleanupInterval = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
* @param int $maxRequests Maximum number of requests allowed
|
||||
* @param int $windowSeconds Time window in seconds
|
||||
*
|
||||
* @throws InvalidRateLimitConfigurationException When parameters are invalid
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly int $maxRequests,
|
||||
private readonly int $windowSeconds
|
||||
) {
|
||||
// Validate maxRequests
|
||||
if ($this->maxRequests <= 0) {
|
||||
throw InvalidRateLimitConfigurationException::invalidMaxRequests($this->maxRequests);
|
||||
}
|
||||
|
||||
if ($this->maxRequests > 1000000) {
|
||||
throw InvalidRateLimitConfigurationException::forParameter(
|
||||
'max_requests',
|
||||
$this->maxRequests,
|
||||
'Cannot exceed 1,000,000 for memory safety'
|
||||
);
|
||||
}
|
||||
|
||||
// Validate windowSeconds
|
||||
if ($this->windowSeconds <= 0) {
|
||||
throw InvalidRateLimitConfigurationException::invalidTimeWindow($this->windowSeconds);
|
||||
}
|
||||
|
||||
if ($this->windowSeconds > 86400) { // 24 hours max
|
||||
throw InvalidRateLimitConfigurationException::forParameter(
|
||||
'window_seconds',
|
||||
$this->windowSeconds,
|
||||
'Cannot exceed 86,400 (24 hours) for practical reasons'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request is allowed for the given key.
|
||||
*
|
||||
* @throws InvalidRateLimitConfigurationException When key is invalid
|
||||
*/
|
||||
public function isAllowed(string $key): bool
|
||||
{
|
||||
$this->validateKey($key);
|
||||
$now = time();
|
||||
$windowStart = $now - $this->windowSeconds;
|
||||
|
||||
// Initialize key if not exists
|
||||
if (!isset(self::$requests[$key])) {
|
||||
self::$requests[$key] = [];
|
||||
}
|
||||
|
||||
// Remove old requests outside the window
|
||||
self::$requests[$key] = array_filter(
|
||||
self::$requests[$key],
|
||||
fn(int $timestamp): bool => $timestamp > $windowStart
|
||||
);
|
||||
|
||||
// Perform global cleanup periodically to prevent memory leaks
|
||||
$this->performGlobalCleanupIfNeeded($now);
|
||||
|
||||
// Check if we're under the limit
|
||||
if (count(self::$requests[$key] ?? []) < $this->maxRequests) {
|
||||
// Add current request
|
||||
self::$requests[$key][] = $now;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time until next request is allowed (in seconds).
|
||||
*
|
||||
* @psalm-return int<0, max>
|
||||
* @throws InvalidRateLimitConfigurationException When key is invalid
|
||||
*/
|
||||
public function getTimeUntilReset(string $key): int
|
||||
{
|
||||
$this->validateKey($key);
|
||||
if (!isset(self::$requests[$key]) || empty(self::$requests[$key])) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$oldestRequest = min(self::$requests[$key]);
|
||||
$resetTime = $oldestRequest + $this->windowSeconds;
|
||||
|
||||
return max(0, $resetTime - $now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics for a specific key.
|
||||
*
|
||||
* @return int[]
|
||||
*
|
||||
* @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
|
||||
{
|
||||
$this->validateKey($key);
|
||||
$now = time();
|
||||
$windowStart = $now - $this->windowSeconds;
|
||||
|
||||
$currentRequests = 0;
|
||||
if (isset(self::$requests[$key])) {
|
||||
$currentRequests = count(array_filter(
|
||||
self::$requests[$key],
|
||||
fn(int $timestamp): bool => $timestamp > $windowStart
|
||||
));
|
||||
}
|
||||
|
||||
return [
|
||||
'current_requests' => $currentRequests,
|
||||
'remaining_requests' => max(0, $this->maxRequests - $currentRequests),
|
||||
'time_until_reset' => $this->getTimeUntilReset($key),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining requests for a specific key.
|
||||
*
|
||||
* @param string $key The rate limiting key
|
||||
* @return int The number of remaining requests
|
||||
*
|
||||
* @psalm-return int<0, max>
|
||||
* @throws InvalidRateLimitConfigurationException When key is invalid
|
||||
*/
|
||||
public function getRemainingRequests(string $key): int
|
||||
{
|
||||
$this->validateKey($key);
|
||||
return $this->getStats($key)['remaining_requests'] ?? 0;
|
||||
}
|
||||
|
||||
public static function clearAll(): void
|
||||
{
|
||||
self::$requests = [];
|
||||
}
|
||||
|
||||
public static function clearKey(string $key): void
|
||||
{
|
||||
self::validateKeyStatic($key);
|
||||
if (isset(self::$requests[$key])) {
|
||||
unset(self::$requests[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform global cleanup if enough time has passed.
|
||||
* This prevents memory leaks from accumulating unused keys.
|
||||
*/
|
||||
private function performGlobalCleanupIfNeeded(int $now): void
|
||||
{
|
||||
if ($now - self::$lastCleanup >= self::$cleanupInterval) {
|
||||
$this->performGlobalCleanup($now);
|
||||
self::$lastCleanup = $now;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all expired entries across all keys.
|
||||
* This prevents memory leaks from accumulating old unused keys.
|
||||
*/
|
||||
private function performGlobalCleanup(int $now): void
|
||||
{
|
||||
$windowStart = $now - $this->windowSeconds;
|
||||
|
||||
foreach (self::$requests as $key => $timestamps) {
|
||||
// Filter out old timestamps
|
||||
$validTimestamps = array_filter(
|
||||
$timestamps,
|
||||
fn(int $timestamp): bool => $timestamp > $windowStart
|
||||
);
|
||||
|
||||
if ($validTimestamps === []) {
|
||||
// Remove keys with no valid timestamps
|
||||
unset(self::$requests[$key]);
|
||||
} else {
|
||||
// Update with filtered timestamps
|
||||
self::$requests[$key] = array_values($validTimestamps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get memory usage statistics for debugging.
|
||||
*
|
||||
* @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}
|
||||
*/
|
||||
public static function getMemoryStats(): array
|
||||
{
|
||||
$totalKeys = count(self::$requests);
|
||||
$totalTimestamps = array_sum(array_map('count', self::$requests));
|
||||
$estimatedMemory = $totalKeys * 50 + $totalTimestamps * 8; // Rough estimate
|
||||
|
||||
return [
|
||||
'total_keys' => $totalKeys,
|
||||
'total_timestamps' => $totalTimestamps,
|
||||
'estimated_memory_bytes' => $estimatedMemory,
|
||||
'last_cleanup' => self::$lastCleanup,
|
||||
'cleanup_interval' => self::$cleanupInterval,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the global cleanup interval.
|
||||
*
|
||||
* @param int $seconds Cleanup interval in seconds (minimum 60)
|
||||
* @throws InvalidRateLimitConfigurationException When seconds is invalid
|
||||
*/
|
||||
public static function setCleanupInterval(int $seconds): void
|
||||
{
|
||||
if ($seconds <= 0) {
|
||||
throw InvalidRateLimitConfigurationException::invalidCleanupInterval($seconds);
|
||||
}
|
||||
|
||||
if ($seconds < 60) {
|
||||
throw InvalidRateLimitConfigurationException::cleanupIntervalTooShort($seconds, 60);
|
||||
}
|
||||
|
||||
if ($seconds > 604800) { // 1 week max
|
||||
throw InvalidRateLimitConfigurationException::forParameter(
|
||||
'cleanup_interval',
|
||||
$seconds,
|
||||
'Cannot exceed 604,800 seconds (1 week) for practical reasons'
|
||||
);
|
||||
}
|
||||
|
||||
self::$cleanupInterval = $seconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a rate limiting key.
|
||||
*
|
||||
* @param string $key The key to validate
|
||||
* @throws InvalidRateLimitConfigurationException When key is invalid
|
||||
*/
|
||||
private function validateKey(string $key): void
|
||||
{
|
||||
self::validateKeyStatic($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Static version of key validation for use in static methods.
|
||||
*
|
||||
* @param string $key The key to validate
|
||||
* @throws InvalidRateLimitConfigurationException When key is invalid
|
||||
*/
|
||||
private static function validateKeyStatic(string $key): void
|
||||
{
|
||||
if (trim($key) === '') {
|
||||
throw InvalidRateLimitConfigurationException::emptyKey();
|
||||
}
|
||||
|
||||
if (strlen($key) > 250) {
|
||||
throw InvalidRateLimitConfigurationException::keyTooLong($key, 250);
|
||||
}
|
||||
|
||||
// Check for potential problematic characters that could cause issues
|
||||
if (preg_match('/[\x00-\x1F\x7F]/', $key)) {
|
||||
throw InvalidRateLimitConfigurationException::invalidKeyFormat(
|
||||
'Rate limiting key cannot contain control characters'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user