mirror of
https://github.com/ivuorinen/monolog-gdpr-filter.git
synced 2026-03-13 13:01: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:
227
src/JsonMasker.php
Normal file
227
src/JsonMasker.php
Normal file
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter;
|
||||
|
||||
use JsonException;
|
||||
|
||||
/**
|
||||
* Handles JSON structure detection and masking within log messages.
|
||||
*
|
||||
* This class provides methods to find JSON structures in strings,
|
||||
* parse them, apply masking, and re-encode them.
|
||||
*/
|
||||
final class JsonMasker
|
||||
{
|
||||
/**
|
||||
* @param callable(array<mixed>|string, int=):array<mixed>|string $recursiveMaskCallback
|
||||
* @param callable(string, mixed, mixed):void|null $auditLogger
|
||||
*/
|
||||
public function __construct(
|
||||
private $recursiveMaskCallback,
|
||||
private $auditLogger = null
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Find and process JSON structures in the message.
|
||||
*/
|
||||
public function processMessage(string $message): string
|
||||
{
|
||||
$result = '';
|
||||
$length = strlen($message);
|
||||
$i = 0;
|
||||
|
||||
while ($i < $length) {
|
||||
$char = $message[$i];
|
||||
|
||||
if ($char === '{' || $char === '[') {
|
||||
// Found potential JSON start, try to extract balanced structure
|
||||
$jsonCandidate = $this->extractBalancedStructure($message, $i);
|
||||
|
||||
if ($jsonCandidate !== null) {
|
||||
// Process the candidate
|
||||
$processed = $this->processCandidate($jsonCandidate);
|
||||
$result .= $processed;
|
||||
$i += strlen($jsonCandidate);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$result .= $char;
|
||||
$i++;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a balanced JSON structure starting from the given position.
|
||||
*/
|
||||
public function extractBalancedStructure(string $message, int $startPos): ?string
|
||||
{
|
||||
$length = strlen($message);
|
||||
$startChar = $message[$startPos];
|
||||
$endChar = $startChar === '{' ? '}' : ']';
|
||||
$level = 0;
|
||||
$inString = false;
|
||||
$escaped = false;
|
||||
|
||||
for ($i = $startPos; $i < $length; $i++) {
|
||||
$char = $message[$i];
|
||||
|
||||
if ($this->isEscapedCharacter($escaped)) {
|
||||
$escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->isEscapeStart($char, $inString)) {
|
||||
$escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === '"') {
|
||||
$inString = !$inString;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($inString) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$balancedEnd = $this->processStructureChar($char, $startChar, $endChar, $level, $message, $startPos, $i);
|
||||
if ($balancedEnd !== null) {
|
||||
return $balancedEnd;
|
||||
}
|
||||
}
|
||||
|
||||
// No balanced structure found
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current character is escaped.
|
||||
*/
|
||||
private function isEscapedCharacter(bool $escaped): bool
|
||||
{
|
||||
return $escaped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current character starts an escape sequence.
|
||||
*/
|
||||
private function isEscapeStart(string $char, bool $inString): bool
|
||||
{
|
||||
return $char === '\\' && $inString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a structure character (bracket or brace) and update nesting level.
|
||||
*
|
||||
* @return string|null Returns the extracted structure if complete, null otherwise
|
||||
*/
|
||||
private function processStructureChar(
|
||||
string $char,
|
||||
string $startChar,
|
||||
string $endChar,
|
||||
int &$level,
|
||||
string $message,
|
||||
int $startPos,
|
||||
int $currentPos
|
||||
): ?string {
|
||||
if ($char === $startChar) {
|
||||
$level++;
|
||||
} elseif ($char === $endChar) {
|
||||
$level--;
|
||||
|
||||
if ($level === 0) {
|
||||
// Found complete balanced structure
|
||||
return substr($message, $startPos, $currentPos - $startPos + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a potential JSON candidate string.
|
||||
*/
|
||||
public function processCandidate(string $potentialJson): string
|
||||
{
|
||||
try {
|
||||
// Try to parse as JSON
|
||||
$decoded = json_decode($potentialJson, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
// If successfully decoded, apply masking and re-encode
|
||||
if ($decoded !== null) {
|
||||
$masked = ($this->recursiveMaskCallback)($decoded, 0);
|
||||
$reEncoded = $this->encodePreservingEmptyObjects($masked, $potentialJson);
|
||||
|
||||
if ($reEncoded !== false) {
|
||||
// Log the operation if audit logger is available
|
||||
if ($this->auditLogger !== null && $reEncoded !== $potentialJson) {
|
||||
($this->auditLogger)('json_masked', $potentialJson, $reEncoded);
|
||||
}
|
||||
|
||||
return $reEncoded;
|
||||
}
|
||||
}
|
||||
} catch (JsonException) {
|
||||
// Not valid JSON, leave as-is to be processed by regular patterns
|
||||
}
|
||||
|
||||
return $potentialJson;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode JSON while preserving empty object structures from the original.
|
||||
*
|
||||
* @param array<mixed>|string $data The data to encode.
|
||||
* @param string $originalJson The original JSON string.
|
||||
*
|
||||
* @return false|string The encoded JSON string or false on failure.
|
||||
*/
|
||||
public function encodePreservingEmptyObjects(array|string $data, string $originalJson): string|false
|
||||
{
|
||||
// Handle simple empty cases first
|
||||
if (in_array($data, ['', '0', []], true)) {
|
||||
if ($originalJson === '{}') {
|
||||
return '{}';
|
||||
}
|
||||
|
||||
if ($originalJson === '[]') {
|
||||
return '[]';
|
||||
}
|
||||
}
|
||||
|
||||
// Encode the processed data
|
||||
$encoded = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
if ($encoded === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fix empty arrays that should be empty objects by comparing with original
|
||||
return $this->fixEmptyObjects($encoded, $originalJson);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix empty arrays that should be empty objects in the encoded JSON.
|
||||
*/
|
||||
public function fixEmptyObjects(string $encoded, string $original): string
|
||||
{
|
||||
// Count empty objects in original and empty arrays in encoded
|
||||
$originalEmptyObjects = substr_count($original, '{}');
|
||||
$encodedEmptyArrays = substr_count($encoded, '[]');
|
||||
|
||||
// If we lost empty objects (they became arrays), fix them
|
||||
if ($originalEmptyObjects > 0 && $encodedEmptyArrays >= $originalEmptyObjects) {
|
||||
// Replace empty arrays with empty objects, up to the number we had originally
|
||||
for ($i = 0; $i < $originalEmptyObjects; $i++) {
|
||||
$encoded = preg_replace('/\[\]/', '{}', $encoded, 1) ?? $encoded;
|
||||
}
|
||||
}
|
||||
|
||||
return $encoded;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user