diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 2768d74..68ab7c9 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -17,8 +17,9 @@ jobs: strategy: matrix: include: - - php-version: '7.4' - php-version: '8.1' + - php-version: '8.2' + - php-version: '8.4' fail-fast: false steps: diff --git a/README.md b/README.md index 746b862..78eaf9b 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,15 @@ ![CI](https://github.com/aubes/shadow-logger-bundle/actions/workflows/php.yml/badge.svg) -This Symfony bundle provides a monolog processor to transform log data, in order to respect GDPR or to anonymize sensitive data. +This Symfony bundle provides a Monolog processor to transform log data in order to respect GDPR or anonymize sensitive data. -It allows Ip anonymization, encoding or removing data in the log. +It allows IP anonymization, hashing, encryption, or removal of sensitive fields in logs. + +## Requirements + +- PHP >= 8.1 +- Symfony 6, 7 or 8 +- Monolog 2 or 3 ## Installation @@ -14,91 +20,112 @@ composer require aubes/shadow-logger-bundle ## Configuration -The configuration looks as follows : - ```yaml # config/packages/shadow-logger.yaml shadow_logger: - # If enabled, add "shadow-debug" on "extra" with debug information when exception occurred - debug: '%kernel.debug%' + # Add "shadow-debug" in "extra" when a transformer throws an exception. + # Recommended to use '%kernel.debug%' so it is only active in development. + debug: '%kernel.debug%' - # If enabled, remove value when exception occurred + # When a transformer throws an exception: + # true → the field value is set to null + # false → the original (untransformed) value is kept strict: true - # Register ShadowProcessor on channels or handlers, not both - # To configure channels or handlers is recommended for performance reason - # Logging channels the ShadowProcessor should be pushed to + # Register ShadowProcessor on handlers OR channels (not both). + # Scoping to specific handlers/channels is recommended for performance. handlers: ['app'] - - # Logging handlers the ShadowProcessor should be pushed to #channels: ['app'] + # Salt used by the "hash" transformer (see Hash transformer section). encoder: salt: '%env(SHADOW_LOGGER_ENCODER_SALT)%' - + mapping: - # Context fields + # Fields to transform in the log "context" context: - custom_field: [] # Array of Transformer aliases - - # Examples: - user_ip: ['ip'] - user_name: ['hash'] + user_ip: ['ip'] + user_name: ['hash'] user_birthdate: ['remove'] - # Extra fields + # Fields to transform in the log "extra" extra: - custom_field: [] # Array of Transformer aliases + custom_field: ['remove'] ``` -### Mapping +## Choosing between hashing and encryption -Field name could contain dot to dive into array. +Both the `hash` and `encrypt` transformers protect sensitive data in logs, but they serve different purposes. Choosing the right one depends on what you need to do with the data after it is logged. -For example, if 'extra' contains the array : +### Hashing (`hash`) -```php -'user' => [ - 'id' => /* ... */, - 'name' => [ - 'first' => /* ... */, - 'last' => /* ... */, - ], -] -``` +Hashing is a **one-way, irreversible** operation. The original value cannot be recovered from the hash. + +**Use it when:** +- You do not need to read back the original value +- You want to correlate log entries belonging to the same user (e.g. trace a user across multiple requests) without storing their identity — the same input always produces the same hash +- You want to pseudonymize data in compliance with GDPR + +**Limitations:** +- If the space of possible values is small (e.g. an IP address), an attacker with access to the logs could reconstruct the original values through brute force +- The salt must be kept secret; rotating it means previously hashed values can no longer be correlated with new ones +- You cannot fulfill a GDPR "right of access" request using only the hashed value -It is possible to modify `ip` and `name` fields : +**Configuration:** ```yaml -# config/packages/shadow-logger.yaml shadow_logger: - mapping: - extra: - user.ip: ['ip'] - user.name.first: ['hash'] - user.name.last: ['remove'] + encoder: + algo: 'sha256' # any algorithm from hash_algos() + salt: '%env(SHADOW_LOGGER_ENCODER_SALT)%' + binary: false ``` -Warning, it is better to use field name without dot for performance. -Internally, when a field name contains a dot the PropertyAccessor is used instead of a simple array key access. +> Always configure a `salt` to prevent rainbow table attacks. Store it as a secret environment variable. + +### Encryption (`encrypt`) -## Transformer +Encryption is a **reversible** operation. The original value can be recovered using the key and the IV stored alongside it. -Currently, this bundle provides these transformers : - * **ip**: Anonymize IP v4 or v6 (cf: `Symfony\Component\HttpFoundation\IpUtils::anonymize`) - * **hash**: Encode the value using [hash](https://www.php.net/manual/fr/function.hash.php) function - * **string**: Cast a `scalar` into `string` or call `__toString` on object - * **remove**: Remove value (replaced by `--obfuscated--`) - * **encrypt**: Encrypt the value (available only if encryptor is configured, cf: [Encrypt transformer](#encrypt-transformer)) +**Use it when:** +- You may need to read back the original value later (e.g. to respond to a GDPR right of access or right of erasure request, or to debug a production issue with proper authorization) +- You want to store sensitive data in logs in a protected form without losing it permanently -### Chain transformers +**Limitations:** +- Requires secure key management: if the key is compromised, all encrypted log entries are exposed +- Key rotation is complex: old entries encrypted with a previous key can no longer be decrypted with the new one +- The presence of the IV in the log reveals that the original field was non-empty, which may itself be sensitive +- More computationally expensive than hashing -You can chain transformers, for example to encode a "Stringable" object : +### Quick comparison + +| | `hash` | `encrypt` | `remove` | +|---|---|---|---| +| Reversible | No | Yes (with key) | No | +| Correlate entries | Yes (same input → same output) | No (IV is random) | No | +| Protect against brute force | With a strong salt | Yes | Yes | +| GDPR right of access | No | Yes | No | +| Key management required | Salt only | Key + IV | None | + +## Transformers + +The following transformers are available out of the box: + +| Alias | Description | +|-------|-------------| +| `ip` | Anonymizes an IPv4 or IPv6 address | +| `hash` | Hashes the value using the configured algorithm | +| `string` | Casts a scalar or `Stringable` object to string | +| `remove` | Replaces the value with `--obfuscated--` | +| `encrypt` | Encrypts the value (requires encryptor configuration) | +| `truncate` | Masks the middle of a value, keeping start and/or end visible (requires truncator configuration) | + +### Chaining transformers + +Transformers can be chained. They are applied in order, the output of one becoming the input of the next. For example, to hash a `Stringable` object: ```yaml -# config/packages/shadow-logger.yaml shadow_logger: - # [...] mapping: context: custom_field: ['string', 'hash'] @@ -106,24 +133,26 @@ shadow_logger: ### Hash transformer -Encoder configuration : +The `hash` transformer uses the configured encoder. See the [Hashing section](#hashing-hash) above for configuration options. + +### Encrypt transformer + +The `encrypt` transformer supports two modes: a built-in encryptor based on OpenSSL, or a custom implementation. + +#### Built-in encryptor ```yaml -# config/packages/shadow-logger.yaml shadow_logger: - # [...] - encoder: - algo: 'sha256' # cf: https://www.php.net/manual/fr/function.hash-algos.php - salt: '%env(SHADOW_LOGGER_ENCODER_SALT)%' - binary: false + encryptor: + key: '%env(SHADOW_LOGGER_ENCRYPTOR_KEY)%' + cipher: 'aes-256-cbc' # optional, default: aes-256-cbc ``` -### Encrypt transformer +The key must be kept secret and should be provided via an environment variable. The cipher can be any algorithm supported by OpenSSL (`openssl_get_cipher_methods()`). -The bundle does not provide an encryption class. -To use the "encrypt" transformer, you need to manually configure the encryptor. +#### Custom encryptor -First, you need to create an Adapter class and extends [EncryptorInterface](src/Encryptor/EncryptorInterface.php) : +If you need a different encryption strategy, implement [`EncryptorInterface`](src/Encryptor/EncryptorInterface.php): ```php // src/Encryptor/EncryptorAdapter.php @@ -133,25 +162,21 @@ use Aubes\ShadowLoggerBundle\Encryptor\EncryptorInterface; class EncryptorAdapter implements EncryptorInterface { - // [...] - public function encrypt(string $data, string $iv): string { - // [...] - + // your encryption logic return $encryptedValue; } public function generateIv(): string { - // [...] - + // generate a random IV return $iv; } } ``` -Next, register your class as a service (if service discovery is not used): +Register it as a service (if not using autoconfiguration): ```yaml # config/services.yaml @@ -159,44 +184,114 @@ services: App\Encryptor\EncryptorAdapter: ~ ``` -Finally, configure your service Id in the ShadowLoggerBundle : +Then reference it by its service ID: ```yaml -# config/packages/shadow-logger.yaml shadow_logger: - # [...] encryptor: 'App\Encryptor\EncryptorAdapter' ``` -This transformer replaces the value with an array : +#### Output format + +In both cases, the transformer replaces the field value with: ```php [ - 'iv' => , // Random IV used to encrypt the value - 'value' => , // Encrypted value + 'iv' => 'abc123', // base64-encoded IV used during encryption + 'value' => '...', // encrypted value ] ``` +### Truncate transformer + +The `truncate` transformer masks the middle of a value while keeping a configurable number of characters visible at the start and/or end. It is useful for partially revealing values like card numbers, email addresses, or tokens. + +Named variants are declared under `truncators`. Each variant becomes available as a transformer alias: `default` → `truncate`, others → `truncate_{name}`. + +```yaml +shadow_logger: + truncators: + default: # alias: "truncate" + keep_start: 2 + keep_end: 2 + mask: '***' + card: # alias: "truncate_card" + keep_start: 4 + keep_end: 4 + mask: '****' + email: # alias: "truncate_email" + keep_start: 1 + keep_end: 0 + mask: '***' + + mapping: + context: + card_number: ['truncate_card'] # 4242424242424242 → 4242****4242 + email: ['truncate_email'] # john@example.com → j*** + token: ['truncate'] # abcdef1234 → ab***34 +``` + +| Option | Description | Default | +|--------|-------------|---------| +| `keep_start` | Number of characters to keep at the beginning | `2` | +| `keep_end` | Number of characters to keep at the end | `2` | +| `mask` | String used to replace the hidden part | `***` | + +> If the value is shorter than or equal to `keep_start + keep_end`, it is replaced entirely by the mask. + +## Mapping + +### Nested fields + +Field names can use dot notation to target nested array keys. + +Given the following `extra` structure: + +```php +'user' => [ + 'id' => 42, + 'name' => [ + 'first' => 'John', + 'last' => 'Doe', + ], + 'ip' => '1.2.3.4', +] +``` + +You can map nested fields like this: + +```yaml +shadow_logger: + mapping: + extra: + user.ip: ['ip'] + user.name.first: ['hash'] + user.name.last: ['remove'] +``` + +> **Note:** Dot notation uses the Symfony PropertyAccessor internally, which is slower than direct key access. Prefer flat field names when possible. + ## Custom transformer -First you need to create a Transformer class and extends [TransformerInterface](src/Transformer/TransformerInterface.php) : +Implement [`TransformerInterface`](src/Transformer/TransformerInterface.php): ```php // src/Transformer/CustomTransformer.php namespace App\Transformer; +use Aubes\ShadowLoggerBundle\Transformer\TransformerInterface; + class CustomTransformer implements TransformerInterface { - public function transform($data) + public function transform(mixed $data): mixed { - // [...] - - return $value; + // transform and return the value + return $data; } } ``` -Next, register your class as a service with 'shadow_logger.transformer' tag : +Register it as a service with the `shadow_logger.transformer` tag and an `alias`: ```yaml # config/services.yaml @@ -205,3 +300,12 @@ services: tags: - { name: 'shadow_logger.transformer', alias: 'custom' } ``` + +The `alias` is the name used in the `mapping` configuration: + +```yaml +shadow_logger: + mapping: + context: + some_field: ['custom'] +``` diff --git a/composer.json b/composer.json index 40f926a..222ccb3 100644 --- a/composer.json +++ b/composer.json @@ -10,19 +10,20 @@ } ], "require": { - "php": ">=7.4", + "php": ">=8.1", + "ext-openssl": "*", "monolog/monolog": "^2.0 | ^3.0", "symfony/polyfill-php80": "^1.0", - "symfony/http-foundation": "^5.4 |^6.0", - "symfony/http-kernel": "^5.4 |^6.0", - "symfony/property-access": "^5.4 | ^6.0" + "symfony/http-foundation": "^6.0 | ^7.0 | ^8.0", + "symfony/http-kernel": "^6.0 | ^7.0 | ^8.0", + "symfony/property-access": "^6.0 | ^7.0 | ^8.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.1", "phpmd/phpmd": "^2.10", "phpunit/phpunit": ">=9.6", "phpspec/prophecy-phpunit": ">=v2.0.1", - "vimeo/psalm": "^5.9" + "vimeo/psalm": "^5.9 | ^6.0" }, "autoload": { "psr-4": { "Aubes\\ShadowLoggerBundle\\": "src" }, diff --git a/config/services.yaml b/config/services.yaml index e8bf26b..ec0e5e0 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -24,6 +24,11 @@ services: tags: - { name: 'shadow_logger.transformer', alias: 'string' } + Aubes\ShadowLoggerBundle\Encryptor\DefaultEncryptor: + arguments: + $key: '' + $cipher: 'aes-256-cbc' + Aubes\ShadowLoggerBundle\Visitor\ArrayKeyVisitor: ~ Aubes\ShadowLoggerBundle\Visitor\PropertyAccessorVisitor: arguments: diff --git a/psalm.xml b/psalm.xml index 6a2b6bb..7da896b 100644 --- a/psalm.xml +++ b/psalm.xml @@ -13,4 +13,11 @@ + + + + + + + diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 568e4d6..6bad0d6 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -7,22 +7,20 @@ use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; -class Configuration implements ConfigurationInterface +final class Configuration implements ConfigurationInterface { /** - * {@inheritdoc} - * * @psalm-suppress UndefinedMethod */ - public function getConfigTreeBuilder() + public function getConfigTreeBuilder(): TreeBuilder { $treeBuilder = new TreeBuilder('shadow_logger'); $rootNode = $treeBuilder->getRootNode(); $rootNode ->children() - ->scalarNode('debug')->defaultFalse()->info('Debug mode: add debug information when an exception occurred')->end() - ->scalarNode('strict')->defaultTrue()->info('Strict mode: remove value when an exception occurred')->end() + ->booleanNode('debug')->defaultFalse()->info('Debug mode: add debug information when an exception occurred')->end() + ->booleanNode('strict')->defaultTrue()->info('Strict mode: remove value when an exception occurred')->end() ->arrayNode('encoder') ->children() ->scalarNode('algo')->defaultValue('sha256')->end() @@ -30,7 +28,34 @@ public function getConfigTreeBuilder() ->booleanNode('binary')->defaultFalse()->end() ->end() ->end() - ->scalarNode('encryptor')->end() + ->arrayNode('encryptor') + ->info('Use a service ID (string) or configure the built-in encryptor (key/cipher)') + ->beforeNormalization() + ->ifString() + ->then(static fn (string $v) => ['service' => $v]) + ->end() + ->validate() + ->ifTrue(static fn ($v) => $v['service'] !== null && $v['key'] !== null) + ->thenInvalid('You cannot specify both "service" and "key" for the encryptor.') + ->end() + ->children() + ->scalarNode('service')->defaultNull()->end() + ->scalarNode('key')->defaultNull()->end() + ->scalarNode('cipher')->defaultValue('aes-256-cbc')->end() + ->end() + ->end() + ->arrayNode('truncators') + ->info('Named truncator variants, each available as a "truncate_{name}" transformer alias ("default" → "truncate")') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->addDefaultsIfNotSet() + ->children() + ->integerNode('keep_start')->defaultValue(2)->min(0)->end() + ->integerNode('keep_end')->defaultValue(2)->min(0)->end() + ->scalarNode('mask')->defaultValue('***')->end() + ->end() + ->end() + ->end() ->arrayNode('channels') ->info('Logging channel list the ShadowProcessor should be pushed to') ->scalarPrototype()->end() diff --git a/src/DependencyInjection/ShadowLoggerExtension.php b/src/DependencyInjection/ShadowLoggerExtension.php index 9b9cc21..cb3683c 100644 --- a/src/DependencyInjection/ShadowLoggerExtension.php +++ b/src/DependencyInjection/ShadowLoggerExtension.php @@ -5,10 +5,13 @@ namespace Aubes\ShadowLoggerBundle\DependencyInjection; use Aubes\ShadowLoggerBundle\Encoder\Encoder; +use Aubes\ShadowLoggerBundle\Encryptor\DefaultEncryptor; use Aubes\ShadowLoggerBundle\Logger\DataTransformer; use Aubes\ShadowLoggerBundle\Logger\LogRecordShadowProcessor; use Aubes\ShadowLoggerBundle\Logger\ShadowProcessor; use Aubes\ShadowLoggerBundle\Transformer\EncryptTransformer; +use Aubes\ShadowLoggerBundle\Transformer\TruncateTransformer; +use Aubes\ShadowLoggerBundle\Truncator\Truncator; use Aubes\ShadowLoggerBundle\Visitor\ArrayKeyVisitor; use Aubes\ShadowLoggerBundle\Visitor\PropertyAccessorVisitor; use Monolog\LogRecord; @@ -22,11 +25,8 @@ /** * @SuppressWarnings(PMD.CouplingBetweenObjects) */ -class ShadowLoggerExtension extends Extension +final class ShadowLoggerExtension extends Extension { - /** - * {@inheritdoc} - */ public function load(array $configs, ContainerBuilder $container): void { $configuration = new Configuration(); @@ -37,6 +37,7 @@ public function load(array $configs, ContainerBuilder $container): void $this->loadEncoder($config, $container); $this->loadEncryptor($config, $container); + $this->loadTruncators($config, $container); $this->loadProcessor($config, $container); } @@ -66,16 +67,31 @@ protected function loadProcessor(array $config, ContainerBuilder $container): vo $this->loadTransformers($config, $container, $processor); } + /** + * @SuppressWarnings(PMD.ElseExpression) + */ public function loadEncryptor(array $config, ContainerBuilder $container): void { - if (!isset($config['encryptor'])) { + if (!isset($config['encryptor']) || ($config['encryptor']['service'] === null && $config['encryptor']['key'] === null)) { $container->removeDefinition(EncryptTransformer::class); + $container->removeDefinition(DefaultEncryptor::class); return; } $encryptTransformer = $container->getDefinition(EncryptTransformer::class); - $encryptTransformer->setArgument('$encryptor', new Reference($config['encryptor'])); + + if ($config['encryptor']['service'] !== null) { + $encryptorRef = new Reference($config['encryptor']['service']); + } else { + $container->getDefinition(DefaultEncryptor::class) + ->setArgument('$key', $config['encryptor']['key']) + ->setArgument('$cipher', $config['encryptor']['cipher']); + + $encryptorRef = new Reference(DefaultEncryptor::class); + } + + $encryptTransformer->setArgument('$encryptor', $encryptorRef); } /** @@ -128,6 +144,27 @@ protected function loadTransformers(array $config, ContainerBuilder $container, } } + protected function loadTruncators(array $config, ContainerBuilder $container): void + { + foreach ($config['truncators'] as $name => $truncatorConfig) { + $alias = $name === 'default' ? 'truncate' : 'truncate_' . $name; + + $truncatorId = 'shadow_logger.truncator.' . $name; + $transformerId = 'shadow_logger.truncate_transformer.' . $name; + + $container->register($truncatorId, Truncator::class) + ->setArguments([ + $truncatorConfig['keep_start'], + $truncatorConfig['keep_end'], + $truncatorConfig['mask'], + ]); + + $container->register($transformerId, TruncateTransformer::class) + ->setArgument('$truncator', new Reference($truncatorId)) + ->addTag('shadow_logger.transformer', ['alias' => $alias]); + } + } + protected function loadEncoder(array $config, ContainerBuilder $container): void { if (empty($config['encoder'])) { @@ -135,9 +172,9 @@ protected function loadEncoder(array $config, ContainerBuilder $container): void } $encoder = $container->getDefinition(Encoder::class); - $encoder->setArgument('$algo', $config['algo']); - $encoder->setArgument('$salt', $config['salt']); - $encoder->setArgument('$binary', $config['binary']); + $encoder->setArgument('$algo', $config['encoder']['algo']); + $encoder->setArgument('$salt', $config['encoder']['salt']); + $encoder->setArgument('$binary', $config['encoder']['binary']); } protected function propertyAccessorArray(string $path): string diff --git a/src/Encoder/Encoder.php b/src/Encoder/Encoder.php index 9c66bca..bdf7c0a 100644 --- a/src/Encoder/Encoder.php +++ b/src/Encoder/Encoder.php @@ -4,21 +4,16 @@ namespace Aubes\ShadowLoggerBundle\Encoder; -class Encoder implements EncoderInterface +final class Encoder implements EncoderInterface { - protected string $algo; - protected string $salt; - protected bool $binary; - - public function __construct(string $algo = 'sha256', string $salt = '', bool $binary = false) - { + public function __construct( + private readonly string $algo = 'sha256', + private readonly string $salt = '', + private readonly bool $binary = false, + ) { if (!\in_array($algo, \hash_algos())) { throw new \InvalidArgumentException('Invalid algo'); } - - $this->algo = $algo; - $this->salt = $salt; - $this->binary = $binary; } public function hash(string $data): string diff --git a/src/Encryptor/DefaultEncryptor.php b/src/Encryptor/DefaultEncryptor.php new file mode 100644 index 0000000..a597bd7 --- /dev/null +++ b/src/Encryptor/DefaultEncryptor.php @@ -0,0 +1,39 @@ +cipher); + + if ($length === false) { + throw new \RuntimeException(\sprintf('Could not determine IV length for cipher "%s".', $this->cipher)); + } + + return \base64_encode(\random_bytes($length)); + } + + public function encrypt(string $data, string $iv): string + { + $encrypted = \openssl_encrypt($data, $this->cipher, $this->key, 0, \base64_decode($iv)); + + if ($encrypted === false) { + throw new \RuntimeException('Encryption failed.'); + } + + return $encrypted; + } +} diff --git a/src/Logger/DataTransformer.php b/src/Logger/DataTransformer.php index 79fc783..9327c2e 100644 --- a/src/Logger/DataTransformer.php +++ b/src/Logger/DataTransformer.php @@ -8,17 +8,12 @@ class DataTransformer { - protected string $field; - protected LoggerVisitorInterface $visitor; - protected array $transformers = []; - protected bool $strict; - - public function __construct(string $field, LoggerVisitorInterface $visitor, array $transformers, bool $strict) - { - $this->field = $field; - $this->visitor = $visitor; - $this->transformers = $transformers; - $this->strict = $strict; + public function __construct( + private readonly string $field, + private readonly LoggerVisitorInterface $visitor, + private readonly array $transformers, + private readonly bool $strict, + ) { } public function transform(array &$record): void diff --git a/src/Logger/LogRecordShadowProcessor.php b/src/Logger/LogRecordShadowProcessor.php index ebe96db..e557757 100644 --- a/src/Logger/LogRecordShadowProcessor.php +++ b/src/Logger/LogRecordShadowProcessor.php @@ -9,7 +9,7 @@ /** * Logger processor for Monolog 3. */ -class LogRecordShadowProcessor +final class LogRecordShadowProcessor { use ShadowProcessorTrait; @@ -26,6 +26,10 @@ public function __invoke(LogRecord $record): LogRecord $this->applyTransformers($dataTransformers, $data, $property); } + if (isset($data['extra']) && !isset($this->mapping['extra'])) { + $data['extra'] = \array_merge((array) $record['extra'], $data['extra']); + } + return $record->with(...$data); } } diff --git a/src/Logger/ShadowProcessor.php b/src/Logger/ShadowProcessor.php index b120bf3..6ccf8d2 100644 --- a/src/Logger/ShadowProcessor.php +++ b/src/Logger/ShadowProcessor.php @@ -7,7 +7,7 @@ /** * Logger processor for Monolog 2. */ -class ShadowProcessor +final class ShadowProcessor { use ShadowProcessorTrait; diff --git a/src/Logger/ShadowProcessorTrait.php b/src/Logger/ShadowProcessorTrait.php index 6f7bb53..528f83f 100644 --- a/src/Logger/ShadowProcessorTrait.php +++ b/src/Logger/ShadowProcessorTrait.php @@ -6,14 +6,11 @@ trait ShadowProcessorTrait { - protected bool $debug; - /** @var array> */ protected array $mapping = []; - public function __construct(bool $debug) + public function __construct(protected readonly bool $debug) { - $this->debug = $debug; } public function addDataTransformer(string $property, DataTransformer $dataTransformer): void diff --git a/src/Logger/TransformerException.php b/src/Logger/TransformerException.php index 44f8cf3..a648efe 100644 --- a/src/Logger/TransformerException.php +++ b/src/Logger/TransformerException.php @@ -4,18 +4,14 @@ namespace Aubes\ShadowLoggerBundle\Logger; -class TransformerException extends \RuntimeException +final class TransformerException extends \RuntimeException { - protected string $field; - /** * @param string $message * @param int|mixed $code */ - public function __construct(string $field, $message = '', $code = 0, \Throwable $previous = null) + public function __construct(private readonly string $field, $message = '', $code = 0, ?\Throwable $previous = null) { - $this->field = $field; - parent::__construct($message, $code, $previous); } diff --git a/src/ShadowLoggerBundle.php b/src/ShadowLoggerBundle.php index 67cfef9..e34596e 100644 --- a/src/ShadowLoggerBundle.php +++ b/src/ShadowLoggerBundle.php @@ -6,6 +6,6 @@ use Symfony\Component\HttpKernel\Bundle\Bundle; -class ShadowLoggerBundle extends Bundle +final class ShadowLoggerBundle extends Bundle { } diff --git a/src/Transformer/EncryptTransformer.php b/src/Transformer/EncryptTransformer.php index 2e3433d..48bfd0f 100644 --- a/src/Transformer/EncryptTransformer.php +++ b/src/Transformer/EncryptTransformer.php @@ -6,18 +6,15 @@ use Aubes\ShadowLoggerBundle\Encryptor\EncryptorInterface; -class EncryptTransformer implements TransformerInterface +final class EncryptTransformer implements TransformerInterface { - protected EncryptorInterface $encryptor; - - public function __construct(EncryptorInterface $encryptor) + public function __construct(private readonly EncryptorInterface $encryptor) { - $this->encryptor = $encryptor; } - public function transform($data): array + public function transform(mixed $data): array { - if (empty($data)) { + if ($data === null) { return []; } diff --git a/src/Transformer/HashTransformer.php b/src/Transformer/HashTransformer.php index ff76c39..5abdaf3 100644 --- a/src/Transformer/HashTransformer.php +++ b/src/Transformer/HashTransformer.php @@ -6,18 +6,15 @@ use Aubes\ShadowLoggerBundle\Encoder\EncoderInterface; -class HashTransformer implements TransformerInterface +final class HashTransformer implements TransformerInterface { - protected EncoderInterface $encoder; - - public function __construct(EncoderInterface $encoder) + public function __construct(private readonly EncoderInterface $encoder) { - $this->encoder = $encoder; } - public function transform($data): string + public function transform(mixed $data): string { - if (empty($data)) { + if ($data === null) { return ''; } diff --git a/src/Transformer/IpTransformer.php b/src/Transformer/IpTransformer.php index 2d020e7..b0033a8 100644 --- a/src/Transformer/IpTransformer.php +++ b/src/Transformer/IpTransformer.php @@ -6,14 +6,12 @@ use Symfony\Component\HttpFoundation\IpUtils; -class IpTransformer implements TransformerInterface +final class IpTransformer implements TransformerInterface { /** - * @param mixed $data - * * @SuppressWarnings(PMD.StaticAccess) */ - public function transform($data): string + public function transform(mixed $data): string { if (empty($data) || !\is_string($data)) { throw new \InvalidArgumentException('Ip must be a string'); diff --git a/src/Transformer/RemoveTransformer.php b/src/Transformer/RemoveTransformer.php index bcbb6b9..7dd898c 100644 --- a/src/Transformer/RemoveTransformer.php +++ b/src/Transformer/RemoveTransformer.php @@ -4,9 +4,9 @@ namespace Aubes\ShadowLoggerBundle\Transformer; -class RemoveTransformer implements TransformerInterface +final class RemoveTransformer implements TransformerInterface { - public function transform($data): string + public function transform(mixed $data): string { return '--obfuscated--'; } diff --git a/src/Transformer/StringTransformer.php b/src/Transformer/StringTransformer.php index 09de34d..302abf3 100644 --- a/src/Transformer/StringTransformer.php +++ b/src/Transformer/StringTransformer.php @@ -4,11 +4,11 @@ namespace Aubes\ShadowLoggerBundle\Transformer; -class StringTransformer implements TransformerInterface +final class StringTransformer implements TransformerInterface { - public function transform($data): string + public function transform(mixed $data): string { - if (empty($data)) { + if ($data === null) { return ''; } diff --git a/src/Transformer/TransformerInterface.php b/src/Transformer/TransformerInterface.php index 3920273..7ef30a7 100644 --- a/src/Transformer/TransformerInterface.php +++ b/src/Transformer/TransformerInterface.php @@ -7,9 +7,7 @@ interface TransformerInterface { /** - * @param mixed $data - * * @return array|scalar */ - public function transform($data); + public function transform(mixed $data): mixed; } diff --git a/src/Transformer/TruncateTransformer.php b/src/Transformer/TruncateTransformer.php new file mode 100644 index 0000000..67b0b91 --- /dev/null +++ b/src/Transformer/TruncateTransformer.php @@ -0,0 +1,27 @@ +truncator->truncate((string) $data); + } +} diff --git a/src/Truncator/Truncator.php b/src/Truncator/Truncator.php new file mode 100644 index 0000000..91e1ca7 --- /dev/null +++ b/src/Truncator/Truncator.php @@ -0,0 +1,29 @@ +keepStart + $this->keepEnd) { + return $this->mask; + } + + $start = $this->keepStart > 0 ? \mb_substr($data, 0, $this->keepStart) : ''; + $end = $this->keepEnd > 0 ? \mb_substr($data, -$this->keepEnd) : ''; + + return $start . $this->mask . $end; + } +} diff --git a/src/Truncator/TruncatorInterface.php b/src/Truncator/TruncatorInterface.php new file mode 100644 index 0000000..ab17ec4 --- /dev/null +++ b/src/Truncator/TruncatorInterface.php @@ -0,0 +1,10 @@ +accessor = $accessor; } public function has(array $record, string $field): bool @@ -25,10 +22,7 @@ public function has(array $record, string $field): bool } } - /** - * @return mixed - */ - public function get(array $record, string $field) + public function get(array $record, string $field): mixed { return $this->accessor->getValue($record, $field); } @@ -36,7 +30,7 @@ public function get(array $record, string $field) /** * @psalm-suppress ReferenceConstraintViolation */ - public function set(array &$record, string $field, $value): void + public function set(array &$record, string $field, mixed $value): void { try { $this->accessor->setValue($record, $field, $value); diff --git a/tests/Transformer/EncryptTransformerTest.php b/tests/Transformer/EncryptTransformerTest.php index 09989b3..5e6db17 100644 --- a/tests/Transformer/EncryptTransformerTest.php +++ b/tests/Transformer/EncryptTransformerTest.php @@ -31,15 +31,27 @@ public function testTransform() $this->assertSame('encrypted', $result['value']); } - public function testTransformEmpty() + public function testTransformNull() { $encryptor = $this->prophesize(EncryptorInterface::class); $transformer = new EncryptTransformer($encryptor->reveal()); - $this->assertSame([], $transformer->transform('')); $this->assertSame([], $transformer->transform(null)); } + public function testTransformEmptyString() + { + $encryptor = $this->prophesize(EncryptorInterface::class); + $encryptor->generateIv()->willReturn('initialVector'); + $encryptor->encrypt('', 'initialVector')->willReturn('encrypted'); + + $transformer = new EncryptTransformer($encryptor->reveal()); + $result = $transformer->transform(''); + + $this->assertSame('initialVector', $result['iv']); + $this->assertSame('encrypted', $result['value']); + } + public function testTransformNotScalar() { $encryptor = $this->prophesize(EncryptorInterface::class); diff --git a/tests/Transformer/HashTransformerTest.php b/tests/Transformer/HashTransformerTest.php index 32cf63e..a404345 100644 --- a/tests/Transformer/HashTransformerTest.php +++ b/tests/Transformer/HashTransformerTest.php @@ -26,15 +26,23 @@ public function testTransform() $this->assertSame('encoded', $transformer->transform(true)); } - public function testTransformEmpty() + public function testTransformNull() { $encoder = $this->prophesize(EncoderInterface::class); $transformer = new HashTransformer($encoder->reveal()); - $this->assertSame('', $transformer->transform('')); $this->assertSame('', $transformer->transform(null)); } + public function testTransformEmptyString() + { + $encoder = $this->prophesize(EncoderInterface::class); + $encoder->hash('')->willReturn('encoded'); + + $transformer = new HashTransformer($encoder->reveal()); + $this->assertSame('encoded', $transformer->transform('')); + } + public function testTransformNotScalar() { $encoder = $this->prophesize(EncoderInterface::class); diff --git a/tests/Transformer/StringTransformerTest.php b/tests/Transformer/StringTransformerTest.php index 17ba9fc..3e6f479 100644 --- a/tests/Transformer/StringTransformerTest.php +++ b/tests/Transformer/StringTransformerTest.php @@ -18,7 +18,7 @@ public function testTransform() $this->assertSame('123', $transformer->transform(123)); $this->assertSame('1.23', $transformer->transform(1.23)); $this->assertSame('1', $transformer->transform(true)); - $this->assertSame('Stringable, I am', $transformer->transform(new class() { + $this->assertSame('Stringable, I am', $transformer->transform(new class { public function __toString() { return 'Stringable, I am';