diff --git a/README.md b/README.md index 391a1da..904b094 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,23 @@ # Cloak -A PHP package that redacts sensitive data from strings and reveals them later using placeholder tokens with cached key-value storage. +A simple, extensible PHP package for redacting sensitive data from strings and revealing them later. + +```php +use DynamikDev\Cloak\Cloak; + +$cloak = Cloak::make(); + +$cloaked = $cloak->cloak('Email me at john@example.com'); +// "Email me at {{EMAIL_x7k2m9_1}}" + +$original = $cloak->uncloak($cloaked); +// "Email me at john@example.com" +``` ## Installation ```bash -composer require dynamikdev/cloak-php +composer require dynamik-dev/cloak-php ``` ## Requirements @@ -13,145 +25,126 @@ composer require dynamikdev/cloak-php - PHP 8.2+ - ext-mbstring (required by libphonenumber) -## Dependencies - -- [giggsey/libphonenumber-for-php](https://github.com/giggsey/libphonenumber-for-php) - Industry-standard phone number validation and detection - -## Usage +## Quick Start ### Basic Usage ```php use DynamikDev\Cloak\Cloak; -// Uses ArrayStore by default (in-memory storage) $cloak = Cloak::make(); -// Cloak sensitive data -$text = 'Contact me at john@example.com or 555-123-4567'; +$text = 'Contact: john@example.com, Phone: 555-123-4567'; $cloaked = $cloak->cloak($text); -// "Contact me at {{EMAIL_x7k2m9_1}} or {{PHONE_x7k2m9_1}}" +// "Contact: {{EMAIL_x7k2m9_1}}, Phone: {{PHONE_x7k2m9_1}}" -// Uncloak to reveal original data $original = $cloak->uncloak($cloaked); -// "Contact me at john@example.com or 555-123-4567" +// "Contact: john@example.com, Phone: 555-123-4567" ``` -### Using a Custom Store +### Using Specific Detectors ```php -use DynamikDev\Cloak\Cloak; -use DynamikDev\Cloak\Stores\ArrayStore; +use DynamikDev\Cloak\Detector; -// Explicitly provide a store instance -$store = new ArrayStore(); -$cloak = Cloak::using($store); +// Only detect emails +$cloaked = $cloak->cloak($text, [Detector::email()]); -// Or pass to make() -$cloak = Cloak::make($store); +// Multiple detectors +$cloaked = $cloak->cloak($text, [ + Detector::email(), + Detector::phone('US'), + Detector::ssn(), +]); ``` -### Placeholder Format +### Configuring with Builder Methods -Placeholders follow the format `{{TYPE_KEY_INDEX}}`: +```php +use DynamikDev\Cloak\Cloak; +use DynamikDev\Cloak\Detector; +use DynamikDev\Cloak\Encryptors\OpenSslEncryptor; -- `TYPE`: Uppercase type (EMAIL, PHONE, SSN, CREDIT_CARD) -- `KEY`: 6-character alphanumeric unique key -- `INDEX`: Integer counter per type, starting at 1 +$cloak = Cloak::make() + ->withDetectors([Detector::email()]) + ->withTtl(7200) + ->withEncryptor(new OpenSslEncryptor(OpenSslEncryptor::generateKey())); + +$cloaked = $cloak->cloak('Sensitive: john@example.com'); +``` -Example: `{{EMAIL_x7k2m9_1}}` +## Built-in Detectors -### Built-in Detectors +Cloak provides several built-in detectors for common sensitive data types: ```php use DynamikDev\Cloak\Detector; -Detector::email(); // Detects email addresses -Detector::phone(); // Detects phone numbers (defaults to US region) -Detector::phone('GB'); // Detects phone numbers for specific region (e.g., UK) -Detector::ssn(); // Detects SSN (XXX-XX-XXXX) -Detector::creditCard(); // Detects 16-digit credit card numbers -Detector::all(); // Returns array of all built-in detectors (uses US for phone) +Detector::email(); // Email addresses +Detector::phone('US'); // Phone numbers (specify region code) +Detector::ssn(); // Social Security Numbers (XXX-XX-XXXX) +Detector::creditCard(); // Credit card numbers +Detector::all(); // All built-in detectors (uses US for phone) ``` -#### Phone Number Detection +### Phone Number Detection + +Phone detection uses [libphonenumber-for-php](https://github.com/giggsey/libphonenumber-for-php) for robust international phone number validation with intelligent false positive prevention. -Phone detection uses [libphonenumber-for-php](https://github.com/giggsey/libphonenumber-for-php) (Google's libphonenumber) for robust international phone number detection with **intelligent false positive prevention**. +**Features:** -**Key Features:** -- Supports international formats from all countries -- Validates actual phone numbers (not just patterns) -- Filters out false positives like order IDs, timestamps, and serial numbers -- Handles various formats: `(212) 456-7890`, `212-456-7890`, `212.456.7890`, `2124567890` +- International format support for all countries +- Validates actual phone numbers (not just digit patterns) +- Filters out order IDs, timestamps, serial numbers, etc. +- Handles various formats: `(212) 456-7890`, `212-456-7890`, `+44 117 496 0123` **Examples:** + ```php -// Detects US numbers -$detector = Detector::phone('US'); -$result = $detector->detect('Call 212-456-7890'); // ✓ Detected +// US numbers +Detector::phone('US')->detect('Call 212-456-7890'); -// Detects UK numbers -$detector = Detector::phone('GB'); -$result = $detector->detect('Ring 0117 496 0123'); // ✓ Detected +// UK numbers +Detector::phone('GB')->detect('Ring 0117 496 0123'); // International format -$detector = Detector::phone(); -$result = $detector->detect('Call +44 117 496 0123'); // ✓ Detected +Detector::phone()->detect('Call +44 117 496 0123'); // Filters false positives -$detector = Detector::phone('US'); -$result = $detector->detect('Order #123456789012'); // ✗ Not detected -$result = $detector->detect('Timestamp: 20231225123456'); // ✗ Not detected +Detector::phone('US')->detect('Order #123456789012'); // Not detected ``` -### Using Specific Detectors +## Custom Detectors -```php -// Only detect emails -$cloaked = $cloak->cloak($text, [Detector::email()]); +Create custom detectors using three convenient methods: -// Detect emails and US phone numbers -$cloaked = $cloak->cloak($text, [ - Detector::email(), - Detector::phone('US'), -]); - -// Detect UK phone numbers -$cloaked = $cloak->cloak($text, [Detector::phone('GB')]); -``` - -### Custom Detectors - -#### Pattern-based Detector +### Pattern-based Detector ```php use DynamikDev\Cloak\Detector; -// Detect passport numbers $passportDetector = Detector::pattern('/\b[A-Z]{2}\d{6}\b/', 'passport'); $cloaked = $cloak->cloak('Passport: AB123456', [$passportDetector]); // "Passport: {{PASSPORT_x7k2m9_1}}" ``` -#### Word-based Detector +### Word-based Detector ```php use DynamikDev\Cloak\Detector; -// Detect specific sensitive words (case-insensitive) $sensitiveDetector = Detector::words(['password', 'secret'], 'sensitive'); $cloaked = $cloak->cloak('The password is secret123', [$sensitiveDetector]); // "The {{SENSITIVE_x7k2m9_1}} is {{SENSITIVE_x7k2m9_2}}123" ``` -#### Callable Detector +### Callable Detector ```php use DynamikDev\Cloak\Detector; -// Custom detection logic $apiKeyDetector = Detector::using(function (string $text): array { $matches = []; if (preg_match_all('/\bAPI_KEY_\w+\b/', $text, $found)) { @@ -161,21 +154,131 @@ $apiKeyDetector = Detector::using(function (string $text): array { } return $matches; }); +``` + +### Implementing DetectorInterface -$cloaked = $cloak->cloak('Use API_KEY_abc123', [$apiKeyDetector]); -// "Use {{API_KEY_x7k2m9_1}}" +For full control, implement the `DetectorInterface`: + +```php +use DynamikDev\Cloak\Contracts\DetectorInterface; + +class IpAddressDetector implements DetectorInterface +{ + public function detect(string $text): array + { + $matches = []; + $pattern = '/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/'; + + if (preg_match_all($pattern, $text, $found)) { + foreach ($found[0] as $match) { + $matches[] = ['match' => $match, 'type' => 'ip_address']; + } + } + + return $matches; + } +} + +$cloaked = $cloak->cloak('Server: 192.168.1.1', [new IpAddressDetector()]); +// "Server: {{IP_ADDRESS_x7k2m9_1}}" +``` + +## Advanced Features + +### Filtering Detections + +Use filters to exclude certain detections from being cloaked: + +```php +// Exclude test emails +$cloak = Cloak::make() + ->filter(fn ($detection) => !str_ends_with($detection['match'], '@test.local')); + +$text = 'prod@company.com and test@test.local'; +$cloaked = $cloak->cloak($text, [Detector::email()]); +// "{{EMAIL_x7k2m9_1}} and test@test.local" +``` + +### Multiple Filters + +Filters are applied in sequence, and all must return `true` for a detection to be included: + +```php +$cloak = Cloak::make() + ->filter(fn ($d) => $d['type'] === 'email') + ->filter(fn ($d) => !str_contains($d['match'], 'noreply')); +``` + +### Lifecycle Callbacks + +Hook into the cloaking/uncloaking process: + +```php +$cloak = Cloak::make() + ->beforeCloak(function (string $text) { + // Normalize whitespace before processing + return preg_replace('/\s+/', ' ', $text); + }) + ->afterCloak(function (string $original, string $cloaked) { + // Log the cloaking operation + logger()->info('Cloaked text', ['original_length' => strlen($original)]); + }) + ->beforeUncloak(function (string $text) { + // Validate before uncloaking + return $text; + }) + ->afterUncloak(function (string $text) { + // Post-process after uncloaking + return trim($text); + }); ``` -### Storage +### Encryption -#### Default Store (ArrayStore) +Encrypt sensitive values at rest using the built-in `OpenSslEncryptor`: + +```php +use DynamikDev\Cloak\Encryptors\OpenSslEncryptor; + +$key = OpenSslEncryptor::generateKey(); // Generate a secure key +$encryptor = new OpenSslEncryptor($key); + +$cloak = Cloak::make() + ->withEncryptor($encryptor); + +$cloaked = $cloak->cloak('Secret: john@example.com', [Detector::email()]); +// Values are encrypted in storage, but placeholders remain the same +``` + +**Environment Variable Support:** + +```php +// Reads from CLOAK_PRIVATE_KEY environment variable +$encryptor = new OpenSslEncryptor(); + +// Or specify a custom environment variable +$encryptor = new OpenSslEncryptor(null, 'MY_ENCRYPTION_KEY'); +``` + +## Storage + +### Default Store (ArrayStore) + +By default, Cloak uses `ArrayStore` for in-memory storage. This is perfect for: -Cloak uses `ArrayStore` by default, which provides in-memory storage. This is ideal for: - Testing - Single-request scenarios -- Simple use cases without persistence needs +- Simple use cases without persistence -#### Custom Store Implementation +```php +use DynamikDev\Cloak\Stores\ArrayStore; + +$store = new ArrayStore(); +$cloak = Cloak::using($store); +``` + +### Custom Store Implementation For persistent storage across requests, implement `StoreInterface`: @@ -203,16 +306,175 @@ class RedisStore implements StoreInterface } } -$store = new RedisStore($redis); -$cloak = Cloak::using($store); +$cloak = Cloak::using(new RedisStore($redis)); +``` + +### Laravel Cache Store Example + +```php +use DynamikDev\Cloak\Contracts\StoreInterface; +use Illuminate\Support\Facades\Cache; + +class LaravelCacheStore implements StoreInterface +{ + public function put(string $key, array $map, int $ttl = 3600): void + { + Cache::put($key, $map, $ttl); + } + + public function get(string $key): ?array + { + return Cache::get($key); + } + + public function forget(string $key): void + { + Cache::forget($key); + } +} +``` + +## Extending Cloak + +Cloak follows a compositional architecture, making it easy to extend with custom implementations. + +### Custom Placeholder Generator + +Create custom placeholder formats by implementing `PlaceholderGeneratorInterface`: + +```php +use DynamikDev\Cloak\Contracts\PlaceholderGeneratorInterface; +use Ramsey\Uuid\Uuid; + +class UuidPlaceholderGenerator implements PlaceholderGeneratorInterface +{ + public function generate(array $detections): array + { + $key = Uuid::uuid4()->toString(); + $map = []; + + foreach ($detections as $detection) { + $uuid = Uuid::uuid4()->toString(); + $placeholder = "[{$detection['type']}:{$uuid}]"; + $map[$placeholder] = $detection['match']; + } + + return ['key' => $key, 'map' => $map]; + } + + public function replace(string $text, array $map): string + { + foreach ($map as $placeholder => $original) { + $text = str_replace($original, $placeholder, $text); + } + return $text; + } + + public function parse(string $text): array + { + // Extract [TYPE:UUID] placeholders and group by key + // Implementation details... + return []; + } +} + +$cloak = Cloak::make() + ->withPlaceholderGenerator(new UuidPlaceholderGenerator()); +``` + +### Custom Encryptor + +Implement `EncryptorInterface` for custom encryption strategies: + +```php +use DynamikDev\Cloak\Contracts\EncryptorInterface; + +class SodiumEncryptor implements EncryptorInterface +{ + public function __construct(private string $key) {} + + public function encrypt(string $value): string + { + $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $encrypted = sodium_crypto_secretbox($value, $nonce, $this->key); + + return base64_encode($nonce . $encrypted); + } + + public function decrypt(string $encrypted): string + { + $decoded = base64_decode($encrypted); + $nonce = substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $ciphertext = substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + + $decrypted = sodium_crypto_secretbox_open($ciphertext, $nonce, $this->key); + + if ($decrypted === false) { + throw new \RuntimeException('Decryption failed'); + } + + return $decrypted; + } +} + +$cloak = Cloak::make() + ->withEncryptor(new SodiumEncryptor($key)); +``` + +## Placeholder Format + +Placeholders follow the format `{{TYPE_KEY_INDEX}}`: + +- `TYPE`: Uppercase detector type (EMAIL, PHONE, SSN, CREDIT_CARD) +- `KEY`: 6-character alphanumeric unique key +- `INDEX`: Integer counter per type, starting at 1 + +**Example:** `{{EMAIL_x7k2m9_1}}` + +The default format can be customized by implementing a custom `PlaceholderGeneratorInterface`. + +## API Reference + +### Factory Methods + +```php +Cloak::make(?StoreInterface $store = null): self +Cloak::using(StoreInterface $store): self +``` + +### Builder Methods + +```php +->withDetectors(array $detectors): self +->withTtl(int $ttl): self +->filter(callable $callback): self +->withPlaceholderGenerator(PlaceholderGeneratorInterface $generator): self +->withEncryptor(EncryptorInterface $encryptor): self +``` + +### Lifecycle Callbacks + +```php +->beforeCloak(callable $callback): self +->afterCloak(callable $callback): self +->beforeUncloak(callable $callback): self +->afterUncloak(callable $callback): self +``` + +### Core Methods + +```php +->cloak(string $text, ?array $detectors = null): string +->uncloak(string $text): string ``` ## Edge Cases -- **Same value multiple times**: Reuses the same placeholder -- **No detections**: Returns original text unchanged +- **Same value appears multiple times**: Reuses the same placeholder +- **No detections found**: Returns original text unchanged - **Missing cache on uncloak**: Leaves placeholder in place - **Empty input**: Returns empty string +- **Overlapping patterns**: All patterns are processed independently ## Testing diff --git a/src/Cloak.php b/src/Cloak.php index 8114e42..ce70134 100644 --- a/src/Cloak.php +++ b/src/Cloak.php @@ -5,12 +5,13 @@ namespace DynamikDev\Cloak; use DynamikDev\Cloak\Concerns\HasLifecycleCallbacks; +use DynamikDev\Cloak\Concerns\ManagesStorage; use DynamikDev\Cloak\Contracts\DetectorInterface; use DynamikDev\Cloak\Contracts\EncryptorInterface; +use DynamikDev\Cloak\Contracts\PlaceholderGeneratorInterface; use DynamikDev\Cloak\Contracts\StoreInterface; use DynamikDev\Cloak\Encryptors\NullEncryptor; -use DynamikDev\Cloak\Encryptors\OpenSslEncryptor; -use DynamikDev\Cloak\Stores\ArrayStore; +use DynamikDev\Cloak\PlaceholderGenerators\DefaultPlaceholderGenerator; /** * Main class for cloaking and uncloaking sensitive data. @@ -18,28 +19,18 @@ class Cloak { use HasLifecycleCallbacks; - - protected const PLACEHOLDER_PATTERN = '/\{\{([A-Z_]+)_([a-zA-Z0-9]{6})_(\d+)\}\}/'; - protected const KEY_LENGTH = 6; - protected const KEY_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - - protected static ?StoreInterface $defaultStore = null; + use ManagesStorage; /** @var array|null */ protected ?array $defaultDetectors = null; - protected int $ttl = 3600; - /** @var array */ protected array $filters = []; - protected ?EncryptorInterface $encryptor = null; - - /** @var callable|null */ - protected $encryptorCallback = null; - protected function __construct( - protected readonly StoreInterface $store + protected readonly StoreInterface $store, + protected PlaceholderGeneratorInterface $placeholderGenerator = new DefaultPlaceholderGenerator(), + protected EncryptorInterface $encryptor = new NullEncryptor() ) { } @@ -53,15 +44,6 @@ public static function make(?StoreInterface $store = null): self return new self($store ?? self::getDefaultStore()); } - protected static function getDefaultStore(): StoreInterface - { - if (self::$defaultStore === null) { - self::$defaultStore = new ArrayStore(); - } - - return self::$defaultStore; - } - /** * Set the default detectors to use when none are specified. * @@ -75,19 +57,6 @@ public function withDetectors(array $detectors): self return $this; } - /** - * Set the TTL (time to live) for stored mappings in seconds. - * - * @param int $ttl Time to live in seconds - * @return $this - */ - public function withTtl(int $ttl): self - { - $this->ttl = $ttl; - - return $this; - } - /** * Add a filter to exclude certain detections. * Multiple filters can be added - all must return true for a detection to be included. @@ -102,37 +71,16 @@ public function filter(callable $callback): self return $this; } - /** - * Enable encryption using the default OpenSslEncryptor. - * If no key is provided, it will attempt to read from CLOAK_PRIVATE_KEY environment variable. - * - * @param string|null $key The encryption key (32 bytes raw or base64-encoded) - * @return $this - * @throws \RuntimeException If the key is invalid or not found - */ - public function encrypt(?string $key = null): self + public function withPlaceholderGenerator(PlaceholderGeneratorInterface $generator): self { - $this->encryptor = new OpenSslEncryptor($key); - $this->encryptorCallback = null; + $this->placeholderGenerator = $generator; return $this; } - /** - * Set a custom encryptor instance or callback. - * - * @param EncryptorInterface|callable(): EncryptorInterface $encryptor - * @return $this - */ - public function encryptUsing(EncryptorInterface|callable $encryptor): self + public function withEncryptor(EncryptorInterface $encryptor): self { - if (is_callable($encryptor)) { - $this->encryptorCallback = $encryptor; - $this->encryptor = null; - } else { - $this->encryptor = $encryptor; - $this->encryptorCallback = null; - } + $this->encryptor = $encryptor; return $this; } @@ -154,29 +102,30 @@ public function cloak(string $text, ?array $detectors = null): string return $processedText; } - $key = $this->generateKey(); - $map = $this->buildPlaceholderMap($detections, $key); + $result = $this->placeholderGenerator->generate($detections); + $key = $result['key']; + $map = $result['map']; $this->store->put('cloak:' . $key, $this->encryptMap($map), $this->ttl); - $result = $this->replaceWithPlaceholders($processedText, $map); + $cloaked = $this->placeholderGenerator->replace($processedText, $map); - $this->executeAfterCloakCallbacks($text, $result); + $this->executeAfterCloakCallbacks($text, $cloaked); - return $result; + return $cloaked; } public function uncloak(string $text): string { $text = $this->executeBeforeUncloakCallbacks($text); - preg_match_all(self::PLACEHOLDER_PATTERN, $text, $matches, PREG_SET_ORDER); + $grouped = $this->placeholderGenerator->parse($text); - if ($matches === []) { + if ($grouped === []) { return $text; } - foreach ($this->groupPlaceholdersByKey($matches) as $key => $placeholders) { + foreach ($grouped as $key => $placeholders) { $encryptedMap = $this->store->get('cloak:' . $key); if ($encryptedMap === null) { @@ -230,100 +179,7 @@ protected function applyFilters(array $detections): array return array_values($detections); } - protected function generateKey(): string - { - $key = ''; - $charsLength = strlen(self::KEY_CHARS); - - for ($i = 0; $i < self::KEY_LENGTH; $i++) { - $key .= self::KEY_CHARS[random_int(0, $charsLength - 1)]; - } - - return $key; - } - /** - * @param array $detections - * @return array Placeholder to original value mapping - */ - protected function buildPlaceholderMap(array $detections, string $key): array - { - $map = []; - /** @var array $typeCounters */ - $typeCounters = []; - /** @var array $valueToPlaceholder */ - $valueToPlaceholder = []; - - foreach ($detections as $detection) { - if (isset($valueToPlaceholder[$detection['match']])) { - continue; - } - - $type = strtoupper($detection['type']); - - if (!isset($typeCounters[$type])) { - $typeCounters[$type] = 0; - } - - $typeCounters[$type]++; - $placeholder = '{{' . $type . '_' . $key . '_' . $typeCounters[$type] . '}}'; - - $map[$placeholder] = $detection['match']; - $valueToPlaceholder[$detection['match']] = $placeholder; - } - - return $map; - } - - /** - * @param array $map Placeholder to original value - */ - protected function replaceWithPlaceholders(string $text, array $map): string - { - foreach ($map as $placeholder => $original) { - $text = str_replace($original, $placeholder, $text); - } - - return $text; - } - - /** - * @param array> $matches - * @return array> - */ - protected function groupPlaceholdersByKey(array $matches): array - { - $groups = []; - - foreach ($matches as $match) { - if (!isset($groups[$match[2]])) { - $groups[$match[2]] = []; - } - - $groups[$match[2]][] = $match[0]; - } - - return $groups; - } - - /** - * Get the encryptor instance, initializing from callback if needed. - */ - protected function getEncryptor(): EncryptorInterface - { - if ($this->encryptorCallback !== null && $this->encryptor === null) { - $result = ($this->encryptorCallback)(); - assert($result instanceof EncryptorInterface); - $this->encryptor = $result; - $this->encryptorCallback = null; - } - - return $this->encryptor ?? new NullEncryptor(); - } - - /** - * Encrypt the values in the placeholder map. - * * @param array $map * @return array */ @@ -332,15 +188,13 @@ protected function encryptMap(array $map): array $encrypted = []; foreach ($map as $placeholder => $value) { - $encrypted[$placeholder] = $this->getEncryptor()->encrypt($value); + $encrypted[$placeholder] = $this->encryptor->encrypt($value); } return $encrypted; } /** - * Decrypt the values in the placeholder map. - * * @param array $map * @return array */ @@ -349,7 +203,7 @@ protected function decryptMap(array $map): array $decrypted = []; foreach ($map as $placeholder => $value) { - $decrypted[$placeholder] = $this->getEncryptor()->decrypt($value); + $decrypted[$placeholder] = $this->encryptor->decrypt($value); } return $decrypted; diff --git a/src/Concerns/ManagesStorage.php b/src/Concerns/ManagesStorage.php new file mode 100644 index 0000000..d812b31 --- /dev/null +++ b/src/Concerns/ManagesStorage.php @@ -0,0 +1,31 @@ +ttl = $ttl; + + return $this; + } + + protected static function getDefaultStore(): StoreInterface + { + if (self::$defaultStore === null) { + self::$defaultStore = new ArrayStore(); + } + + return self::$defaultStore; + } +} diff --git a/src/Contracts/PlaceholderGeneratorInterface.php b/src/Contracts/PlaceholderGeneratorInterface.php new file mode 100644 index 0000000..c302fc2 --- /dev/null +++ b/src/Contracts/PlaceholderGeneratorInterface.php @@ -0,0 +1,33 @@ + $detections + * @return array{key: string, map: array} Key and placeholder mapping + */ + public function generate(array $detections): array; + + /** + * Replace original values with placeholders in text. + * + * @param string $text The text to process + * @param array $map Placeholder to original value mapping + * @return string Text with placeholders + */ + public function replace(string $text, array $map): string; + + /** + * Parse placeholders from text and group them by key. + * + * @param string $text The text containing placeholders + * @return array> Grouped placeholders by key + */ + public function parse(string $text): array; +} diff --git a/src/PlaceholderGenerators/DefaultPlaceholderGenerator.php b/src/PlaceholderGenerators/DefaultPlaceholderGenerator.php new file mode 100644 index 0000000..e3601b5 --- /dev/null +++ b/src/PlaceholderGenerators/DefaultPlaceholderGenerator.php @@ -0,0 +1,82 @@ +generateKey(); + $map = []; + /** @var array $typeCounters */ + $typeCounters = []; + /** @var array $valueToPlaceholder */ + $valueToPlaceholder = []; + + foreach ($detections as $detection) { + if (isset($valueToPlaceholder[$detection['match']])) { + continue; + } + + $type = strtoupper($detection['type']); + + if (!isset($typeCounters[$type])) { + $typeCounters[$type] = 0; + } + + $typeCounters[$type]++; + $placeholder = '{{' . $type . '_' . $key . '_' . $typeCounters[$type] . '}}'; + + $map[$placeholder] = $detection['match']; + $valueToPlaceholder[$detection['match']] = $placeholder; + } + + return ['key' => $key, 'map' => $map]; + } + + public function replace(string $text, array $map): string + { + foreach ($map as $placeholder => $original) { + $text = str_replace($original, $placeholder, $text); + } + + return $text; + } + + public function parse(string $text): array + { + preg_match_all(self::PLACEHOLDER_PATTERN, $text, $matches, PREG_SET_ORDER); + + $groups = []; + + foreach ($matches as $match) { + if (!isset($groups[$match[2]])) { + $groups[$match[2]] = []; + } + + $groups[$match[2]][] = $match[0]; + } + + return $groups; + } + + protected function generateKey(): string + { + $key = ''; + $charsLength = strlen(self::KEY_CHARS); + + for ($i = 0; $i < self::KEY_LENGTH; $i++) { + $key .= self::KEY_CHARS[random_int(0, $charsLength - 1)]; + } + + return $key; + } +} diff --git a/tests/EncryptionTest.php b/tests/EncryptionTest.php index 3062483..dde4216 100644 --- a/tests/EncryptionTest.php +++ b/tests/EncryptionTest.php @@ -26,11 +26,11 @@ expect($stored[$cloaked])->toBe('test@example.com'); }); -it('encrypts values when encrypt() is called', function () { +it('encrypts values when withEncryptor() is called', function () { $encryptionKey = OpenSslEncryptor::generateKey(); $store = new ArrayStore(); $cloak = Cloak::using($store) - ->encrypt($encryptionKey); + ->withEncryptor(new OpenSslEncryptor($encryptionKey)); $cloaked = $cloak->cloak('test@example.com', [Detector::email()]); preg_match('/\{\{EMAIL_([a-zA-Z0-9]{6})_1\}\}/', $cloaked, $matches); @@ -46,7 +46,7 @@ $encryptionKey = OpenSslEncryptor::generateKey(); $store = new ArrayStore(); $cloak = Cloak::using($store) - ->encrypt($encryptionKey); + ->withEncryptor(new OpenSslEncryptor($encryptionKey)); $original = 'Contact: test@example.com Phone: 555-123-4567'; $cloaked = $cloak->cloak($original, [Detector::email(), Detector::phone()]); @@ -55,12 +55,12 @@ expect($uncloaked)->toBe($original); }); -it('uses custom encryptor via encryptUsing()', function () { +it('uses custom encryptor via withEncryptor()', function () { $encryptionKey = OpenSslEncryptor::generateKey(); $customEncryptor = new OpenSslEncryptor($encryptionKey); $store = new ArrayStore(); $cloak = Cloak::using($store) - ->encryptUsing($customEncryptor); + ->withEncryptor($customEncryptor); $original = 'test@example.com'; $cloaked = $cloak->cloak($original, [Detector::email()]); @@ -69,30 +69,6 @@ expect($uncloaked)->toBe($original); }); -it('uses callback-based encryptor via encryptUsing()', function () { - $encryptionKey = OpenSslEncryptor::generateKey(); - $store = new ArrayStore(); - $callbackCalled = false; - - $cloak = Cloak::using($store) - ->encryptUsing(function () use ($encryptionKey, &$callbackCalled) { - $callbackCalled = true; - - return new OpenSslEncryptor($encryptionKey); - }); - - // Callback not called yet - expect($callbackCalled)->toBe(false); - - $original = 'test@example.com'; - $cloaked = $cloak->cloak($original, [Detector::email()]); - - // Callback called during cloak - expect($callbackCalled)->toBe(true); - - $uncloaked = $cloak->uncloak($cloaked); - expect($uncloaked)->toBe($original); -}); it('reads encryption key from environment variable', function () { $key = OpenSslEncryptor::generateKey(); @@ -100,7 +76,7 @@ $store = new ArrayStore(); $cloak = Cloak::using($store) - ->encryptUsing(new OpenSslEncryptor(null, 'CLOAK_TEST_PRIVATE_KEY')); + ->withEncryptor(new OpenSslEncryptor(null, 'CLOAK_TEST_PRIVATE_KEY')); $original = 'test@example.com'; $cloaked = $cloak->cloak($original, [Detector::email()]); @@ -113,7 +89,7 @@ $encryptionKey = OpenSslEncryptor::generateKey(); $store = new ArrayStore(); $cloak = Cloak::using($store) - ->encrypt($encryptionKey); + ->withEncryptor(new OpenSslEncryptor($encryptionKey)); $original = 'Email: test@example.com, Phone: 555-123-4567, SSN: 123-45-6789'; $cloaked = $cloak->cloak($original, [ @@ -130,7 +106,7 @@ $encryptionKey = OpenSslEncryptor::generateKey(); $store = new ArrayStore(); $cloak = Cloak::using($store) - ->encrypt($encryptionKey); + ->withEncryptor(new OpenSslEncryptor($encryptionKey)); $original = 'test@example.com and test@example.com'; $cloaked = $cloak->cloak($original, [Detector::email()]); @@ -147,7 +123,7 @@ $encryptionKey = OpenSslEncryptor::generateKey(); $store = new ArrayStore(); $cloak = Cloak::using($store) - ->encrypt($encryptionKey) + ->withEncryptor(new OpenSslEncryptor($encryptionKey)) ->filter(fn ($d) => !str_ends_with($d['match'], '.local')); $text = 'prod@company.com test@test.local'; @@ -168,7 +144,7 @@ $log = []; $cloak = Cloak::using($store) - ->encrypt($encryptionKey) + ->withEncryptor(new OpenSslEncryptor($encryptionKey)) ->beforeCloak(function ($text) use (&$log) { $log[] = 'before'; @@ -191,10 +167,10 @@ $key2 = OpenSslEncryptor::generateKey(); $store = new ArrayStore(); - $cloak1 = Cloak::using($store)->encrypt($key1); + $cloak1 = Cloak::using($store)->withEncryptor(new OpenSslEncryptor($key1)); $cloaked = $cloak1->cloak('test@example.com', [Detector::email()]); - $cloak2 = Cloak::using($store)->encrypt($key2); + $cloak2 = Cloak::using($store)->withEncryptor(new OpenSslEncryptor($key2)); $cloak2->uncloak($cloaked); })->throws(RuntimeException::class); @@ -203,7 +179,7 @@ $store = new ArrayStore(); $cloak = Cloak::using($store) ->withTtl(7200) - ->encrypt($encryptionKey); + ->withEncryptor(new OpenSslEncryptor($encryptionKey)); $original = 'test@example.com'; $cloaked = $cloak->cloak($original, [Detector::email()]); @@ -217,7 +193,7 @@ $store = new ArrayStore(); $cloak = Cloak::using($store) ->withDetectors([Detector::email()]) - ->encrypt($encryptionKey); + ->withEncryptor(new OpenSslEncryptor($encryptionKey)); $text = 'Email: test@example.com Phone: 555-123-4567'; $cloaked = $cloak->cloak($text); @@ -228,14 +204,14 @@ expect($uncloaked)->toContain('555-123-4567'); }); -it('switching from encrypt() to encryptUsing() replaces encryptor', function () { +it('switching encryptors replaces the encryptor', function () { $key1 = OpenSslEncryptor::generateKey(); $key2 = OpenSslEncryptor::generateKey(); $store = new ArrayStore(); $cloak = Cloak::using($store) - ->encrypt($key1) - ->encryptUsing(new OpenSslEncryptor($key2)); + ->withEncryptor(new OpenSslEncryptor($key1)) + ->withEncryptor(new OpenSslEncryptor($key2)); $original = 'test@example.com'; $cloaked = $cloak->cloak($original, [Detector::email()]); @@ -248,7 +224,7 @@ $encryptionKey = OpenSslEncryptor::generateKey(); $store = new ArrayStore(); $cloak = Cloak::using($store) - ->encrypt($encryptionKey); + ->withEncryptor(new OpenSslEncryptor($encryptionKey)); $result = $cloak->cloak('No sensitive data here', [Detector::email()]);