diff --git a/CHANGELOG.md b/CHANGELOG.md index 3518a33..e3a7bb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ All notable changes to `phalcon/debugbar` are documented here. The format is bas - `RequestContext` holds the client address and the AJAX and HTTPS flags for each decision, and `Injector::isHtml()` tells if a response is an HTML page. [#62](https://github.com/phalcon/debugbar/issues/62) - `Security\Redactor::redactUri()` applies the redaction rule to the query of a URI. [#62](https://github.com/phalcon/debugbar/issues/62) - A shared fixture, `tests/support/assets/DebugBar/panel-contract.json`, makes the PHP and JavaScript tests use the same payload. [#62](https://github.com/phalcon/debugbar/issues/62) +- `redact.unmask` takes patterns for masked keys whose values show, and `Security\Redactor` takes them as an optional third argument. A pattern matches the whole key, and `*` matches zero or more characters. A hidden key stays hidden. [#67](https://github.com/phalcon/debugbar/issues/67) ### Changed @@ -41,6 +42,7 @@ All notable changes to `phalcon/debugbar` are documented here. The format is bas - `DebugBarTypes` holds the wire contract and the config shapes, and it lists the panel names, so static analysis rejects an unknown panel name. [#62](https://github.com/phalcon/debugbar/issues/62) - The debug page in ADR applications uses `Injector::isHtml()` and no longer depends on the injection rules of the bar. [#62](https://github.com/phalcon/debugbar/issues/62) - `DebugBarTypes` lists the icon names as `icon_type`, so static analysis rejects an unknown `indicator.icon`. A test keeps the list equal to the drawings in `debugbar.js`. An indicator with an unknown icon name now shows no icon, not the gears. [#64](https://github.com/phalcon/debugbar/issues/64) +- `redact.mask` and `redact.hidden` also match the word parts of a key. The redactor splits a key at `_`, `-`, `.` and camelCase, so `token` now masks `reset_token` and `accessToken`. Some values that showed before now show `***`. [#67](https://github.com/phalcon/debugbar/issues/67) ### Fixed @@ -48,6 +50,7 @@ All notable changes to `phalcon/debugbar` are documented here. The format is bas - The browser asset exports test helpers only outside a DOM, and no longer hard-codes the request collector name. - The `request` panel and request history no longer show or store a query value under a masked key in the URI. [#62](https://github.com/phalcon/debugbar/issues/62) - The documentation states the effect of a long-lived worker on request history. [#62](https://github.com/phalcon/debugbar/issues/62) +- The documentation states that `access.callback` can run more than once for a request. [#66](https://github.com/phalcon/debugbar/issues/66) ## [0.4.0](https://github.com/phalcon/debugbar/releases/tag/v0.4.0) (2026-07-14) diff --git a/docs/index.md b/docs/index.md index 2143e09..9406310 100644 --- a/docs/index.md +++ b/docs/index.md @@ -312,6 +312,7 @@ The second argument to `Provider` is a nested array. Every key is optional. `Pha | `history.ttl_seconds` | `int` | `86400` | Lifetime in seconds; active-browser entries are checked immediately. | | `redact.hidden` | `list` | `[]` | Keys dropped from the output entirely. | | `redact.mask` | `list` | `[]` | Extra keys whose values are masked (added to the defaults). | +| `redact.unmask` | `list` | `[]` | Patterns for masked keys whose values show. `*` matches any characters. | ```php 10, 'ttl_seconds' => 86400, ], - 'redact' => ['mask' => ['api_key'], 'hidden' => ['secret_question']], + 'redact' => ['mask' => ['iban'], 'hidden' => ['secret_question'], 'unmask' => ['primary_key']], ]))->boot(); ``` @@ -494,7 +495,7 @@ Debug::getBar()?->addCollector(new OrdersCollector()); `addCollector()` calls `subscribe()` when the bar has an events manager. A collector with no events does not implement `Subscriber`, and you add it the same way. A collector with the name of a built-in collector replaces it. The bridges do not feed the replacement, so it must attach its own listeners. -The bar does not redact the data of a custom collector. Run sensitive data through `Phalcon\DebugBar\Security\Redactor` in the collector. The collector does not receive the `redact.mask` and `redact.hidden` values, so give them to its `Redactor`. +The bar does not redact the data of a custom collector. Run sensitive data through `Phalcon\DebugBar\Security\Redactor` in the collector. The collector does not receive the `redact.mask`, `redact.hidden` and `redact.unmask` values, so give them to its `Redactor`. ## Sharing the Events Manager @@ -598,13 +599,17 @@ use Phalcon\DebugBar\Provider; - **Masked** keys keep their key but replace the value with `***`. - **Hidden** keys are dropped entirely. -The default masked keys are `authorization`, `cookie`, `csrf`, `key`, `password`, `secret`, and `token`. The redactor matches whole key names, so `token` does not match `reset_token`. Add other keys to `redact.mask`. +A key matches an entry when it has the same name, or when it contains the word parts of the entry next to each other. The redactor splits a name into word parts at `_`, `-`, `.` and camelCase. For example, `token` matches `reset_token` and `accessToken`, and `card_number` matches `customer_card_number`. `token` does not match `tokens`, and `key` does not match `monkey`. + +The default masked keys are `authorization`, `cookie`, `csrf`, `key`, `password`, `secret`, and `token`. Add other keys to `redact.mask`, and keys to drop to `redact.hidden`. + +A word part can mask a key that is not sensitive, such as `primary_key`. To show its value, add a pattern to `redact.unmask`. A pattern must match the whole key, and the match is not case-sensitive. `*` matches zero or more characters, and each other character matches only itself. For example, `card` matches only `card`, `card*` also matches `card_number`, and `*card*` also matches `customer_card_number` and `discard`. A pattern does not use word parts. A hidden key stays hidden, even when a pattern matches it. A pattern of `*` alone shows all masked values. The `request`, `config`, `session`, and `logger` collectors run their data through the redactor. The `request` panel and request history also redact the query of the URI. When the redactor changes a value in a query, it rebuilds the query, so the encoding of the other values can change. The `session` collector also omits the raw session id and every `$PHALCON/*` internal key, including the CSRF token. The `database` panel and request history show and store query bindings as they are, because generated names such as `:APL0` do not match a key. The redactor does not change log messages, exception messages, or stack traces. -`Phalcon\DebugBar\Security\AccessGate` decides who receives the bar within an allowed environment. An empty IP allowlist admits any client. When both an allowlist and a callback are given, both must pass. +`Phalcon\DebugBar\Security\AccessGate` decides who receives the bar within an allowed environment. An empty IP allowlist admits any client. When both an allowlist and a callback are given, both must pass. The callback can run more than once for a request, for example for a server error in an ADR application with `debug_page` on. Do not put side effects in the callback. ```php historyOptions = HistoryOptions::fromConfig($config['history'] ?? []); $this->redactor = new Redactor( [...Redactor::DEFAULT_KEYS, ...($redact['mask'] ?? [])], - $redact['hidden'] ?? [] + $redact['hidden'] ?? [], + $redact['unmask'] ?? [] ); } diff --git a/src/DebugBar/DebugBarTypes.php b/src/DebugBar/DebugBarTypes.php index 1754241..f096781 100644 --- a/src/DebugBar/DebugBarTypes.php +++ b/src/DebugBar/DebugBarTypes.php @@ -76,7 +76,7 @@ * debug_page?: bool|\Phalcon\Debug, * headers?: bool, * history?: history_config, - * redact?: array{mask?: list, hidden?: list} + * redact?: array{mask?: list, hidden?: list, unmask?: list} * } */ final class DebugBarTypes diff --git a/src/DebugBar/Security/Redactor.php b/src/DebugBar/Security/Redactor.php index 0e05fa4..ac32923 100644 --- a/src/DebugBar/Security/Redactor.php +++ b/src/DebugBar/Security/Redactor.php @@ -13,22 +13,33 @@ namespace Phalcon\DebugBar\Security; +use function array_map; +use function array_slice; +use function count; +use function explode; use function http_build_query; -use function in_array; +use function implode; use function is_array; use function mb_strtolower; use function parse_str; +use function preg_match; +use function preg_quote; +use function preg_split; use function rawurlencode; use function str_replace; use function strpos; use function substr; +use const PREG_SPLIT_NO_EMPTY; + /** - * Guards data before it leaves PHP, matching keys case-insensitively through - * nested arrays. Sensitive keys are masked (key shown, value replaced); "hidden" - * keys are dropped entirely - mirroring the blacklist `Debug` already applies. - * Applied by the Request/Session/Config/Logger collectors, and to the URI that - * request history stores. + * Guards data before it leaves PHP. It looks at the keys through nested arrays, and + * the match is not case-sensitive. A key matches an entry when it has the same name, + * or when it has the word parts of the entry next to each other. The word parts come + * from `_`, `-`, `.` and camelCase. A hidden key is dropped. A masked key keeps its + * name and its value becomes `***`, but an unmask pattern that matches the whole key + * stops the mask. The Request, Session, Config and Logger collectors use it, and so + * does the URI that request history stores. */ final class Redactor { @@ -45,23 +56,36 @@ final class Redactor public const MASK = '***'; /** - * @var list + * Splits on `_`, `-` and `.`, before an upper case letter that follows a lower + * case letter or a digit, and between an acronym and the next word. + */ + private const PARTS = '/[_.\-]+|(?<=[a-z\d])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/'; + + /** + * @var list}> */ private array $hidden; /** - * @var list + * @var list}> */ private array $keys; + /** + * @var list + */ + private array $unmask; + /** * @param list $keys * @param list $hidden + * @param list $unmask */ - public function __construct(array $keys = self::DEFAULT_KEYS, array $hidden = []) + public function __construct(array $keys = self::DEFAULT_KEYS, array $hidden = [], array $unmask = []) { - $this->keys = $this->lower($keys); - $this->hidden = $this->lower($hidden); + $this->keys = $this->entries($keys); + $this->hidden = $this->entries($hidden); + $this->unmask = array_map($this->pattern(...), $unmask); } /** @@ -75,12 +99,13 @@ public function redact(array $data): array foreach ($data as $key => $value) { if (is_string($key)) { $lower = mb_strtolower($key); + $parts = $this->parts($key); - if (in_array($lower, $this->hidden, true)) { + if ($this->matches($lower, $parts, $this->hidden)) { continue; } - if (in_array($lower, $this->keys, true)) { + if ($this->matches($lower, $parts, $this->keys) && !$this->isUnmasked($lower)) { $result[$key] = self::MASK; continue; @@ -117,17 +142,77 @@ public function redactUri(string $uri): string } /** - * @param list $keys + * @param list $names * - * @return list + * @return list}> */ - private function lower(array $keys): array + private function entries(array $names): array { - $lowered = []; - foreach ($keys as $key) { - $lowered[] = mb_strtolower($key); + $entries = []; + foreach ($names as $name) { + $entries[] = [mb_strtolower($name), $this->parts($name)]; } - return $lowered; + return $entries; + } + + private function isUnmasked(string $lower): bool + { + foreach ($this->unmask as $pattern) { + if (1 === preg_match($pattern, $lower)) { + return true; + } + } + + return false; + } + + /** + * @param list $parts + * @param list}> $entries + */ + private function matches(string $lower, array $parts, array $entries): bool + { + foreach ($entries as [$name, $needle]) { + if ($lower === $name) { + return true; + } + + $last = count($parts) - count($needle); + for ($index = 0; $index <= $last; $index++) { + if (array_slice($parts, $index, count($needle)) === $needle) { + return true; + } + } + } + + return false; + } + + /** + * Returns the word parts of a name in lower case. A name with no word part, such + * as `_`, is one part. + * + * @return list + */ + private function parts(string $name): array + { + $parts = preg_split(self::PARTS, $name, -1, PREG_SPLIT_NO_EMPTY) ?: [$name]; + + return array_map(mb_strtolower(...), $parts); + } + + /** + * Returns a pattern that matches the whole key in lower case. `*` matches zero or + * more characters. Each other character matches only itself. + */ + private function pattern(string $entry): string + { + $literals = array_map( + static fn (string $literal): string => preg_quote($literal, '/'), + explode('*', mb_strtolower($entry)) + ); + + return '/\A' . implode('.*', $literals) . '\z/s'; } } diff --git a/tests/Unit/DebugBar/CoreTest.php b/tests/Unit/DebugBar/CoreTest.php index b7027a0..d8dc10f 100644 --- a/tests/Unit/DebugBar/CoreTest.php +++ b/tests/Unit/DebugBar/CoreTest.php @@ -183,14 +183,15 @@ public function testConfigSourceReachesTheCollectorRedacted(): void $_ENV[self::ENV_VAR] = 'dev'; $config = new Config([ 'section' => [ - 'password' => 'sekret', - 'maskme' => 'mask-value', - 'dropme' => 'drop-value', - 'plain' => 'keepme', + 'password' => 'sekret', + 'maskme' => 'mask-value', + 'dropme' => 'drop-value', + 'plain' => 'keepme', + 'primary_key' => 'id', ], ]); - $this->core(['redact' => ['mask' => ['maskme'], 'hidden' => ['dropme']]]) + $this->core(['redact' => ['mask' => ['maskme'], 'hidden' => ['dropme'], 'unmask' => ['primary_key']]]) ->boot(null, null, $config); $panel = $this->bootedBar()->collect()['data']['config']['panel']; @@ -201,6 +202,7 @@ public function testConfigSourceReachesTheCollectorRedacted(): void $this->assertSame('keepme', $panel['section.plain']); $this->assertSame('***', $panel['section.password']); $this->assertSame('***', $panel['section.maskme']); + $this->assertSame('id', $panel['section.primary_key']); $this->assertArrayNotHasKey('section.dropme', $panel); } @@ -532,7 +534,7 @@ private function bootedBar(): DebugBar * collectors?: array, * headers?: bool, * history?: array{enabled?: bool, path?: string, base_uri?: string}, - * redact?: array{mask?: list, hidden?: list} + * redact?: array{mask?: list, hidden?: list, unmask?: list} * } $config */ private function core(array $config = []): Core diff --git a/tests/Unit/DebugBar/Security/RedactorTest.php b/tests/Unit/DebugBar/Security/RedactorTest.php index 463c9a1..6baefc1 100644 --- a/tests/Unit/DebugBar/Security/RedactorTest.php +++ b/tests/Unit/DebugBar/Security/RedactorTest.php @@ -19,6 +19,57 @@ final class RedactorTest extends AbstractUnitTestCase { + /** + * @return array + */ + public static function keptKeys(): array + { + return [ + 'word that contains key' => ['keyword'], + 'word that ends with key' => ['monkey'], + 'plural of a sensitive word' => ['tokens'], + 'no sensitive part' => ['user_name'], + 'sensitive word with a tail' => ['passwordless'], + ]; + } + + /** + * @return array + */ + public static function maskedKeys(): array + { + return [ + 'underscore' => ['reset_token'], + 'camelCase' => ['accessToken'], + 'hyphen' => ['X-Api-Key'], + 'dot' => ['db.password'], + 'acronym before a word' => ['APIKey'], + 'digit before a word' => ['oauth2Token'], + 'two parts' => ['client_secret'], + 'header name' => ['Set-Cookie'], + 'three parts' => ['x-csrf-token'], + ]; + } + + /** + * @return array, 1: string, 2: string}> + */ + public static function unmaskPatterns(): array + { + return [ + 'name shows the same key' => [['card'], 'card', 'value'], + 'name keeps a longer key masked' => [['card'], 'card_number', '***'], + 'trailing wildcard shows the name' => [['card*'], 'card', 'value'], + 'trailing wildcard shows a longer key' => [['card*'], 'card_number', 'value'], + 'trailing wildcard keeps other prefix' => [['card*'], 'customer_card', '***'], + 'wildcards on both sides' => [['*card*'], 'customer_card_number', 'value'], + 'pattern in another case' => [['Primary_Key'], 'PRIMARY_KEY', 'value'], + 'dot is not a wildcard' => [['primary.key'], 'primary_key', '***'], + 'dot matches a dot' => [['primary.key'], 'primary.key', 'value'], + 'wildcard alone shows each masked key' => [['*'], 'card', 'value'], + ]; + } + /** * @return array */ @@ -30,6 +81,7 @@ public static function uris(): array 'no sensitive key' => ['/orders?a[]=1&b=x%20y', '/orders?a[]=1&b=x%20y'], 'sensitive key' => ['/reset?token=abc&page=2', '/reset?token=***&page=2'], 'key in another case' => ['/reset?TOKEN=abc', '/reset?TOKEN=***'], + 'part of a key' => ['/reset?reset_token=abc', '/reset?reset_token=***'], 'absolute URL' => ['https://example.test/a?password=abc', 'https://example.test/a?password=***'], 'nested sensitive key' => ['/a?filter[token]=abc', '/a?filter%5Btoken%5D=***'], 'hidden key' => ['/a?internal=abc&page=2', '/a?page=2'], @@ -46,6 +98,39 @@ public function testConfiguredMultibyteKeysAreLowered(): void $this->assertSame('***', $redacted['пароль']); } + public function testEntryWithManyPartsMatchesAdjacentParts(): void + { + $redacted = (new Redactor(['card_number']))->redact([ + 'cardNumber' => 1, + 'customer_card_number' => 2, + 'CARD-NUMBER' => 3, + 'card_id_number' => 4, + 'number_card' => 5, + ]); + + $this->assertSame( + [ + 'cardNumber' => '***', + 'customer_card_number' => '***', + 'CARD-NUMBER' => '***', + 'card_id_number' => 4, + 'number_card' => 5, + ], + $redacted + ); + } + + public function testEntryWithNoWordPartsMatchesOnlyTheWholeKey(): void + { + $redacted = (new Redactor(['', '_']))->redact([ + '_' => 1, + 'a_b' => 2, + 'a' => 3, + ]); + + $this->assertSame(['_' => '***', 'a_b' => 2, 'a' => 3], $redacted); + } + public function testHiddenKeysAreOmittedEntirely(): void { $redacted = (new Redactor(['password'], ['internal']))->redact([ @@ -60,6 +145,29 @@ public function testHiddenKeysAreOmittedEntirely(): void $this->assertSame('***', $redacted['password']); } + public function testHiddenMatchesWordParts(): void + { + $redacted = (new Redactor([], ['card']))->redact([ + 'card_number' => 1, + 'cardExpiry' => 2, + 'discard' => 3, + ]); + + $this->assertSame(['discard' => 3], $redacted); + } + + #[DataProvider('keptKeys')] + public function testKeepsAKeyWithNoSensitiveWordPart(string $key): void + { + $this->assertSame('value', (new Redactor())->redact([$key => 'value'])[$key]); + } + + #[DataProvider('maskedKeys')] + public function testMasksAKeyWithASensitiveWordPart(string $key): void + { + $this->assertSame('***', (new Redactor())->redact([$key => 'value'])[$key]); + } + public function testRedactsConfiguredKeysCaseInsensitively(): void { $redacted = (new Redactor())->redact([ @@ -97,6 +205,36 @@ public function testRedactUri(string $uri, string $expected): void $this->assertSame($expected, (new Redactor(Redactor::DEFAULT_KEYS, ['internal']))->redactUri($uri)); } + /** + * @param list $unmask + */ + #[DataProvider('unmaskPatterns')] + public function testUnmask(array $unmask, string $key, string $expected): void + { + $redacted = (new Redactor(['card', 'key'], [], $unmask))->redact([$key => 'value']); + + $this->assertSame($expected, $redacted[$key]); + } + + public function testUnmaskDoesNotShowAHiddenKey(): void + { + $redacted = (new Redactor(['password'], ['internal'], ['*']))->redact([ + 'internal' => 'gone', + 'password' => 'hunter2', + ]); + + $this->assertSame(['password' => 'hunter2'], $redacted); + } + + public function testUnmaskedKeyRedactsItsNestedValues(): void + { + $redacted = (new Redactor(Redactor::DEFAULT_KEYS, [], ['primary_key']))->redact([ + 'primary_key' => ['password' => 'hunter2', 'id' => 1], + ]); + + $this->assertSame(['primary_key' => ['password' => '***', 'id' => 1]], $redacted); + } + public function testUsesCustomKeys(): void { $redacted = (new Redactor(['pin']))->redact([ @@ -107,4 +245,14 @@ public function testUsesCustomKeys(): void $this->assertSame('***', $redacted['pin']); $this->assertSame('kept', $redacted['password']); } + + public function testWholeKeyNameStillMatches(): void + { + $redacted = (new Redactor(['apiKey']))->redact([ + 'APIKEY' => 'x', + 'apikey' => 'y', + ]); + + $this->assertSame(['APIKEY' => '***', 'apikey' => '***'], $redacted); + } }