Files
monolog-gdpr-filter/tests/SecuritySanitizerTest.php
Ismo Vuorinen 8866daaf33 feat: add advanced architecture, documentation, and coverage improvements (#65)
* fix(style): resolve PHPCS line-length warnings in source files

* fix(style): resolve PHPCS line-length warnings in test files

* feat(audit): add structured audit logging with ErrorContext and AuditContext

- ErrorContext: standardized error information with sensitive data sanitization
- AuditContext: structured context for audit entries with operation types
- StructuredAuditLogger: enhanced audit logger wrapper with timing support

* feat(recovery): add recovery mechanism for failed masking operations

- FailureMode enum: FAIL_OPEN, FAIL_CLOSED, FAIL_SAFE modes
- RecoveryStrategy interface and RecoveryResult value object
- RetryStrategy: exponential backoff with configurable attempts
- FallbackMaskStrategy: type-aware fallback values

* feat(strategies): add CallbackMaskingStrategy for custom masking logic

- Wraps custom callbacks as MaskingStrategy implementations
- Factory methods: constant(), hash(), partial() for common use cases
- Supports exact match and prefix match for field paths

* docs: add framework integration guides and examples

- symfony-integration.md: Symfony service configuration and Monolog setup
- psr3-decorator.md: PSR-3 logger decorator pattern implementation
- framework-examples.md: CakePHP, CodeIgniter 4, Laminas, Yii2, PSR-15
- docker-development.md: Docker development environment guide

* chore(docker): add Docker development environment

- Dockerfile: PHP 8.2-cli-alpine with Xdebug for coverage
- docker-compose.yml: development services with volume mounts

* feat(demo): add interactive GDPR pattern tester playground

- PatternTester.php: pattern testing utility with strategy support
- index.php: web API endpoint with JSON response handling
- playground.html: interactive web interface for testing patterns

* docs(todo): update with completed medium priority items

- Mark all PHPCS warnings as fixed (81 → 0)
- Document new Audit and Recovery features
- Update test count to 1,068 tests with 2,953 assertions
- Move remaining items to low priority

* feat: add advanced architecture, documentation, and coverage improvements

- Add architecture improvements:
  - ArrayAccessorInterface and DotArrayAccessor for decoupled array access
  - MaskingOrchestrator for single-responsibility masking coordination
  - GdprProcessorBuilder for fluent configuration
  - MaskingPluginInterface and AbstractMaskingPlugin for plugin architecture
  - PluginAwareProcessor for plugin hook execution
  - AuditLoggerFactory for instance-based audit logger creation

- Add advanced features:
  - SerializedDataProcessor for handling print_r/var_export/serialize output
  - KAnonymizer with GeneralizationStrategy for GDPR k-anonymity
  - RetentionPolicy for configurable data retention periods
  - StreamingProcessor for memory-efficient large log processing

- Add comprehensive documentation:
  - docs/performance-tuning.md - benchmarking, optimization, caching
  - docs/troubleshooting.md - common issues and solutions
  - docs/logging-integrations.md - ELK, Graylog, Datadog, etc.
  - docs/plugin-development.md - complete plugin development guide

- Improve test coverage (84.41% → 85.07%):
  - ConditionalRuleFactoryInstanceTest (100% coverage)
  - GdprProcessorBuilderEdgeCasesTest (100% coverage)
  - StrategyEdgeCasesTest for ReDoS detection and type parsing
  - 78 new tests, 119 new assertions

- Update TODO.md with current statistics:
  - 141 PHP files, 1,346 tests, 85.07% line coverage

* chore: tests, update actions, sonarcloud issues

* chore: rector

* fix: more sonarcloud fixes

* chore: more fixes

* refactor: copilot review fix

* chore: rector
2025-12-22 13:38:18 +02:00

388 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests;
use Ivuorinen\MonologGdprFilter\MaskConstants;
use Ivuorinen\MonologGdprFilter\SecuritySanitizer;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Test SecuritySanitizer functionality.
*
* @api
*/
#[CoversClass(SecuritySanitizer::class)]
class SecuritySanitizerTest extends TestCase
{
#[Test]
public function sanitizesPasswordInErrorMessage(): void
{
$message = 'Database connection failed with password=mysecretpass123';
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
$this->assertStringNotContainsString('mysecretpass123', $sanitized);
$this->assertStringContainsString('password=***', $sanitized);
}
#[Test]
public function sanitizesApiKeyInErrorMessage(): void
{
$message = 'API request failed: api_key=' . TestConstants::API_KEY;
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
$this->assertStringNotContainsString(TestConstants::API_KEY, $sanitized);
$this->assertStringContainsString('api_key=***', $sanitized);
}
#[Test]
public function sanitizesMultipleSensitiveValuesInSameMessage(): void
{
$message = 'Failed with password=secret123 and api-key: abc123def456';
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
$this->assertStringNotContainsString('secret123', $sanitized);
$this->assertStringNotContainsString('abc123def456', $sanitized);
$this->assertStringContainsString('password=***', $sanitized);
$this->assertStringContainsString('api_key=***', $sanitized);
}
#[Test]
public function truncatesLongErrorMessages(): void
{
$longMessage = str_repeat('Error occurred with data: ', 50);
$sanitized = SecuritySanitizer::sanitizeErrorMessage($longMessage);
$this->assertLessThanOrEqual(550, strlen($sanitized)); // 500 + " (truncated for security)"
$this->assertStringContainsString(TestConstants::ERROR_TRUNCATED_SECURITY, $sanitized);
}
#[Test]
public function doesNotTruncateShortMessages(): void
{
$shortMessage = 'Simple error message';
$sanitized = SecuritySanitizer::sanitizeErrorMessage($shortMessage);
$this->assertSame($shortMessage, $sanitized);
$this->assertStringNotContainsString('truncated', $sanitized);
}
#[Test]
public function handlesEmptyString(): void
{
$sanitized = SecuritySanitizer::sanitizeErrorMessage('');
$this->assertSame('', $sanitized);
}
#[Test]
public function preservesNonSensitiveContent(): void
{
$message = 'Connection timeout to server database.example.com on port 3306';
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
$this->assertSame($message, $sanitized);
}
#[Test]
#[DataProvider('sensitivePatternProvider')]
public function sanitizesVariousSensitivePatterns(string $input, string $shouldNotContain): void
{
$sanitized = SecuritySanitizer::sanitizeErrorMessage($input);
$this->assertStringNotContainsString($shouldNotContain, $sanitized);
$this->assertStringContainsString(MaskConstants::MASK_GENERIC, $sanitized);
}
/**
* @return string[][]
*
* @psalm-return array{
* 'password with equals': array{
* input: 'Error: password=secretpass',
* shouldNotContain: 'secretpass'
* },
* 'api key with underscore': array{
* input: 'Failed: api_key=key123456',
* shouldNotContain: 'key123456'
* },
* 'api key with dash': array{
* input: 'Failed: api-key: key123456',
* shouldNotContain: 'key123456'
* },
* 'token in header': array{
* input: 'Request failed: Authorization: Bearer token123abc',
* shouldNotContain: 'token123abc'
* },
* 'mysql connection string': array{
* input: 'DB error: mysql://user:pass@localhost:3306',
* shouldNotContain: 'user:pass'
* },
* 'secret key': array{
* input: 'Config: secret_key=my-secret-123',
* shouldNotContain: 'my-secret-123'
* },
* 'private key': array{
* input: 'Error: private_key=pk_test_12345',
* shouldNotContain: 'pk_test_12345'
* }
* }
*/
public static function sensitivePatternProvider(): array
{
return [
'password with equals' => [
'input' => 'Error: password=secretpass',
'shouldNotContain' => 'secretpass',
],
'api key with underscore' => [
'input' => 'Failed: api_key=key123456',
'shouldNotContain' => 'key123456',
],
'api key with dash' => [
'input' => 'Failed: api-key: key123456',
'shouldNotContain' => 'key123456',
],
'token in header' => [
'input' => 'Request failed: Authorization: Bearer token123abc',
'shouldNotContain' => 'token123abc',
],
'mysql connection string' => [
'input' => 'DB error: mysql://user:pass@localhost:3306',
'shouldNotContain' => 'user:pass',
],
'secret key' => [
'input' => 'Config: secret_key=my-secret-123',
'shouldNotContain' => 'my-secret-123',
],
'private key' => [
'input' => 'Error: private_key=pk_test_12345',
'shouldNotContain' => 'pk_test_12345',
],
];
}
#[Test]
public function combinesTruncationAndSanitization(): void
{
$longMessageWithPassword = 'Error occurred: ' . str_repeat('data ', 100) . ' password=secret123';
$sanitized = SecuritySanitizer::sanitizeErrorMessage($longMessageWithPassword);
$this->assertStringNotContainsString('secret123', $sanitized);
$this->assertStringContainsString(TestConstants::ERROR_TRUNCATED_SECURITY, $sanitized);
$this->assertLessThanOrEqual(550, strlen($sanitized));
}
#[Test]
public function handlesMessageExactlyAt500Characters(): void
{
$message = str_repeat('a', 500);
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
$this->assertSame($message, $sanitized);
$this->assertStringNotContainsString('truncated', $sanitized);
}
#[Test]
public function handlesMessageJustOver500Characters(): void
{
$message = str_repeat('a', 501);
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
$this->assertStringContainsString(TestConstants::ERROR_TRUNCATED_SECURITY, $sanitized);
$this->assertLessThanOrEqual(550, strlen($sanitized)); // 500 + truncation message
}
#[Test]
public function sanitizesPwdAndPassVariations(): void
{
$message = 'Connection failed: pwd=mypassword pass=anotherpass';
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
$this->assertStringContainsString('pwd=***', $sanitized);
$this->assertStringContainsString('pass=***', $sanitized);
$this->assertStringNotContainsString('mypassword', $sanitized);
$this->assertStringNotContainsString('anotherpass', $sanitized);
}
#[Test]
public function sanitizesHostServerHostnamePatterns(): void
{
$message = 'Error: host=db.example.com server=192.168.1.1 hostname=internal.local';
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
$this->assertStringContainsString('host=***', $sanitized);
$this->assertStringContainsString('server=***', $sanitized);
$this->assertStringContainsString('hostname=***', $sanitized);
$this->assertStringNotContainsString('db.example.com', $sanitized);
$this->assertStringNotContainsString('internal.local', $sanitized);
}
#[Test]
public function sanitizesUsernameAndUidPatterns(): void
{
$message = 'Auth error: username=admin uid=12345';
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
$this->assertStringContainsString('username=***', $sanitized);
$this->assertStringContainsString('uid=***', $sanitized);
$this->assertStringNotContainsString('=admin', $sanitized);
}
#[Test]
public function sanitizesStripeStyleKeys(): void
{
$message = 'Stripe error: sk_live_abc123def456 pk_test_xyz789ghi012';
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
$this->assertStringContainsString('sk_***', $sanitized);
$this->assertStringContainsString('pk_***', $sanitized);
$this->assertStringNotContainsString('sk_live_abc123def456', $sanitized);
$this->assertStringNotContainsString('pk_test_xyz789ghi012', $sanitized);
}
#[Test]
public function sanitizesRedisConnectionString(): void
{
$message = 'Redis error: redis://user:pass@localhost:6379';
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
$this->assertStringContainsString('redis://***:***@***:***', $sanitized);
$this->assertStringNotContainsString('user:pass@', $sanitized);
}
#[Test]
public function sanitizesPostgresqlConnectionString(): void
{
$message = 'Connection error: postgresql://admin:password@pg.server:5432';
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
$this->assertStringContainsString('postgresql://***:***@***:***', $sanitized);
$this->assertStringNotContainsString('admin:password@', $sanitized);
}
#[Test]
public function sanitizesJwtSecretPatterns(): void
{
$message = 'JWT error: jwt_secret=mysupersecret123';
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
$this->assertStringContainsString('jwt_secret=***', $sanitized);
$this->assertStringNotContainsString('mysupersecret123', $sanitized);
}
#[Test]
public function sanitizesSuperSecretPattern(): void
{
$message = 'Config contains super_secret_value';
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
$this->assertStringContainsString(MaskConstants::MASK_SECRET, $sanitized);
$this->assertStringNotContainsString('super_secret_value', $sanitized);
}
#[Test]
#[DataProvider('internalIpRangesProvider')]
public function sanitizesInternalIpAddresses(string $ip, string $range): void
{
$message = 'Cannot reach server at ' . $ip;
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
$this->assertStringNotContainsString($ip, $sanitized, "Internal IP in $range range should be masked");
$this->assertStringContainsString('***.***.***', $sanitized);
}
/**
* @return array<string, array{ip: string, range: string}>
*/
public static function internalIpRangesProvider(): array
{
return [
'10.0.0.1' => ['ip' => '10.0.0.1', 'range' => '10.x.x.x'],
'10.255.255.255' => ['ip' => '10.255.255.255', 'range' => '10.x.x.x'],
'172.16.0.1' => ['ip' => '172.16.0.1', 'range' => '172.16-31.x.x'],
'172.31.255.255' => ['ip' => '172.31.255.255', 'range' => '172.16-31.x.x'],
'192.168.0.1' => ['ip' => '192.168.0.1', 'range' => '192.168.x.x'],
'192.168.255.255' => ['ip' => '192.168.255.255', 'range' => '192.168.x.x'],
];
}
#[Test]
public function preservesPublicIpAddresses(): void
{
$message = 'External server at 8.8.8.8 responded';
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
$this->assertStringContainsString('8.8.8.8', $sanitized);
}
#[Test]
public function sanitizesSensitiveFilePaths(): void
{
$message = 'Error reading /var/www/config/secrets.json';
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
$this->assertStringNotContainsString('/var/www/config', $sanitized);
$this->assertStringContainsString('/***/', $sanitized);
}
#[Test]
public function sanitizesWindowsSensitiveFilePaths(): void
{
$message = 'Error reading C:\\Users\\admin\\config\\secrets.txt';
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
// Windows paths with sensitive keywords are masked
$this->assertStringNotContainsString('admin', $sanitized);
$this->assertStringNotContainsString('secrets.txt', $sanitized);
$this->assertStringContainsString('C:\\***', $sanitized);
}
#[Test]
public function sanitizesGenericSecretPatterns(): void
{
$message = 'Config: app_secret=abcd1234567890 encryption_key: xyz9876543210abc';
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
$this->assertStringNotContainsString('abcd1234567890', $sanitized);
$this->assertStringNotContainsString('xyz9876543210abc', $sanitized);
}
#[Test]
public function classCannotBeInstantiated(): void
{
$reflection = new \ReflectionClass(SecuritySanitizer::class);
$constructor = $reflection->getConstructor();
$this->assertNotNull($constructor);
$this->assertTrue($constructor->isPrivate());
}
#[Test]
public function sanitizesUserPattern(): void
{
$message = 'Connection failed: user=dbadmin';
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
$this->assertStringContainsString('user=***', $sanitized);
$this->assertStringNotContainsString('dbadmin', $sanitized);
}
#[Test]
public function caseInsensitivePatternMatching(): void
{
$message = 'Error: PASSWORD=secret HOST=server.local TOKEN=abc123token';
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
// Case-insensitive matching means uppercase is matched but replaced with lowercase pattern
$this->assertStringNotContainsString('secret', $sanitized);
$this->assertStringNotContainsString('server.local', $sanitized);
$this->assertStringNotContainsString('abc123token', $sanitized);
$this->assertStringContainsString('=***', $sanitized);
}
}