From 3686aef3feb4b9c49b4d9ad21e2172a8c9929f98 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 23 Jul 2026 18:09:04 +0200 Subject: [PATCH] feat(pii): add sensitive data scrubber --- src/DataCollection/SensitiveDataScrubber.php | 164 +++++++++++++++ .../SensitiveDataScrubberTest.php | 192 ++++++++++++++++++ 2 files changed, 356 insertions(+) create mode 100644 src/DataCollection/SensitiveDataScrubber.php create mode 100644 tests/DataCollection/SensitiveDataScrubberTest.php diff --git a/src/DataCollection/SensitiveDataScrubber.php b/src/DataCollection/SensitiveDataScrubber.php new file mode 100644 index 000000000..2c387e69e --- /dev/null +++ b/src/DataCollection/SensitiveDataScrubber.php @@ -0,0 +1,164 @@ + $headers + * + * @phpstan-param KeyValueCollectionBehavior $behavior + * + * @return array + */ + public static function scrubHeaders(array $headers, array $behavior): array + { + $scrubbed = []; + + foreach ($headers as $name => $values) { + $name = (string) $name; + + if (\in_array(strtolower($name), self::SENSITIVE_HEADERS, true) || self::shouldScrubValue($name, $behavior)) { + foreach ($values as $headerLine => $headerValue) { + $values[$headerLine] = '[Filtered]'; + } + } + + $scrubbed[$name] = $values; + } + + return $scrubbed; + } + + /** + * @param array $data + * + * @phpstan-param KeyValueCollectionBehavior $behavior + * + * @return array + */ + public static function scrubKeyValueData(array $data, array $behavior): array + { + $scrubbed = []; + + /** @mago-ignore analysis:mixed-assignment */ + foreach ($data as $key => $value) { + $key = (string) $key; + $scrubbed[$key] = self::shouldScrubValue($key, $behavior) ? '[Filtered]' : $value; + } + + return $scrubbed; + } + + /** + * @phpstan-param KeyValueCollectionBehavior $behavior + */ + public static function scrubQueryString(string $queryString, array $behavior): string + { + $parts = explode('&', $queryString); + + foreach ($parts as $index => $part) { + $separatorPosition = strpos($part, '='); + $encodedKey = $separatorPosition === false ? $part : substr($part, 0, $separatorPosition); + $key = urldecode($encodedKey); + + if (self::shouldScrubValue($key, $behavior)) { + $parts[$index] = $encodedKey . '=[Filtered]'; + } + } + + return implode('&', $parts); + } + + /** + * @phpstan-param KeyValueCollectionBehavior $behavior + */ + private static function shouldScrubValue(string $key, array $behavior): bool + { + if (self::matchesMandatoryDenyList($key)) { + return true; + } + + if ($behavior['mode'] === 'allowList') { + return !self::matchesAnyTerm($key, $behavior['terms'], false); + } + + return $behavior['terms'] !== [] && self::matchesAnyTerm($key, $behavior['terms'], true); + } + + private static function matchesMandatoryDenyList(string $key): bool + { + if (self::$sensitiveDataDenyListRegex === null) { + self::$sensitiveDataDenyListRegex = '/' . implode('|', array_map(static function (string $term): string { + return preg_quote($term, '/'); + }, self::SENSITIVE_DATA_DENYLIST)) . '/i'; + } + + return preg_match(self::$sensitiveDataDenyListRegex, $key) === 1; + } + + /** + * @param string[] $terms + */ + private static function matchesAnyTerm(string $key, array $terms, bool $partial): bool + { + $key = strtolower($key); + + foreach ($terms as $term) { + $term = strtolower($term); + + if (($partial && strpos($key, $term) !== false) || (!$partial && $key === $term)) { + return true; + } + } + + return false; + } +} diff --git a/tests/DataCollection/SensitiveDataScrubberTest.php b/tests/DataCollection/SensitiveDataScrubberTest.php new file mode 100644 index 000000000..78c6d94e5 --- /dev/null +++ b/tests/DataCollection/SensitiveDataScrubberTest.php @@ -0,0 +1,192 @@ + 'denyList', 'terms' => []]; + + $scrubbed = SensitiveDataScrubber::scrubKeyValueData([ + 'authorization' => 'secret', + 'public' => 'visible', + ], $behavior); + + $this->assertSame([ + 'authorization' => '[Filtered]', + 'public' => 'visible', + ], $scrubbed); + } + + public function testScrubCustomAndMandatory(): void + { + $behavior = ['mode' => 'denyList', 'terms' => ['custom']]; + + $scrubbed = SensitiveDataScrubber::scrubKeyValueData([ + 'authorization' => 'secret', + 'custom-field' => 'private', + 'public' => 'visible', + ], $behavior); + + $this->assertSame([ + 'authorization' => '[Filtered]', + 'custom-field' => '[Filtered]', + 'public' => 'visible', + ], $scrubbed); + } + + public function testScrubCaseInsensitiveKeys(): void + { + $behavior = ['mode' => 'denyList', 'terms' => []]; + + $scrubbed = SensitiveDataScrubber::scrubKeyValueData(['AUTHORIZATION' => 'secret'], $behavior); + + $this->assertSame(['AUTHORIZATION' => '[Filtered]'], $scrubbed); + } + + public function testAllowList(): void + { + $behavior = ['mode' => 'allowList', 'terms' => ['theme']]; + + $scrubbed = SensitiveDataScrubber::scrubKeyValueData([ + 'theme' => 'dark', + 'tracking_id' => '12345', + ], $behavior); + + $this->assertSame([ + 'theme' => 'dark', + 'tracking_id' => '[Filtered]', + ], $scrubbed); + } + + public function testScrubHeadersAppliesDenyList(): void + { + $behavior = ['mode' => 'denyList', 'terms' => []]; + + $scrubbed = SensitiveDataScrubber::scrubHeaders([ + 'Authorization' => ['secret'], + 'X-Request-Id' => ['request-id'], + ], $behavior); + + $this->assertSame([ + 'Authorization' => ['[Filtered]'], + 'X-Request-Id' => ['request-id'], + ], $scrubbed); + } + + public function testScrubHeadersScrubsEveryLineOfMatchingHeaders(): void + { + $behavior = ['mode' => 'denyList', 'terms' => []]; + + $scrubbed = SensitiveDataScrubber::scrubHeaders(['X-Api-Key' => ['first', 'second']], $behavior); + + $this->assertSame(['X-Api-Key' => ['[Filtered]', '[Filtered]']], $scrubbed); + } + + public function testScrubHeadersAlwaysScrubsCookieHeaders(): void + { + $behavior = ['mode' => 'denyList', 'terms' => []]; + + $scrubbed = SensitiveDataScrubber::scrubHeaders([ + 'Cookie' => ['session_id=secret; theme=dark'], + 'Set-Cookie' => ['session_id=secret'], + 'X-Request-Id' => ['request-id'], + ], $behavior); + + $this->assertSame([ + 'Cookie' => ['[Filtered]'], + 'Set-Cookie' => ['[Filtered]'], + 'X-Request-Id' => ['request-id'], + ], $scrubbed); + } + + public function testScrubHeadersAllowListCannotOverrideCookieHeaders(): void + { + $behavior = ['mode' => 'allowList', 'terms' => ['cookie', 'set-cookie']]; + + $scrubbed = SensitiveDataScrubber::scrubHeaders([ + 'Cookie' => ['session_id=secret'], + 'Set-Cookie' => ['session_id=secret'], + ], $behavior); + + $this->assertSame([ + 'Cookie' => ['[Filtered]'], + 'Set-Cookie' => ['[Filtered]'], + ], $scrubbed); + } + + public function testExtendedDenyTerms(): void + { + $defaultBehavior = ['mode' => 'denyList', 'terms' => []]; + $extendedBehavior = ['mode' => 'denyList', 'terms' => ['forwarded', '-ip', 'remote-', 'via', '-user']]; + $headers = [ + 'X-Forwarded-For' => ['203.0.113.7'], + 'X-Real-IP' => ['203.0.113.7'], + ]; + + $this->assertSame($headers, SensitiveDataScrubber::scrubHeaders($headers, $defaultBehavior)); + $this->assertSame([ + 'X-Forwarded-For' => ['[Filtered]'], + 'X-Real-IP' => ['[Filtered]'], + ], SensitiveDataScrubber::scrubHeaders($headers, $extendedBehavior)); + } + + public function testScrubHeadersAllowListCannotOverrideMandatoryDenyList(): void + { + $behavior = ['mode' => 'allowList', 'terms' => ['authorization', 'x-request-id']]; + + $scrubbed = SensitiveDataScrubber::scrubHeaders([ + 'Authorization' => ['secret'], + 'X-Request-Id' => ['request-id'], + 'Host' => ['example.com'], + ], $behavior); + + $this->assertSame([ + 'Authorization' => ['[Filtered]'], + 'X-Request-Id' => ['request-id'], + 'Host' => ['[Filtered]'], + ], $scrubbed); + } + + public function testScrubQueryStringAppliesMandatoryDenyList(): void + { + $behavior = ['mode' => 'denyList', 'terms' => []]; + + $scrubbed = SensitiveDataScrubber::scrubQueryString('token=secret&page=1', $behavior); + + $this->assertSame('token=[Filtered]&page=1', $scrubbed); + } + + public function testScrubQueryStringAppliesCustomDenyListTerms(): void + { + $behavior = ['mode' => 'denyList', 'terms' => ['page']]; + + $scrubbed = SensitiveDataScrubber::scrubQueryString('token=secret&page=1&flag', $behavior); + + $this->assertSame('token=[Filtered]&page=[Filtered]&flag', $scrubbed); + } + + public function testScrubQueryStringDecodesKeysBeforeMatching(): void + { + $behavior = ['mode' => 'denyList', 'terms' => []]; + + $scrubbed = SensitiveDataScrubber::scrubQueryString('api%5Ftoken=secret&page=1', $behavior); + + $this->assertSame('api%5Ftoken=[Filtered]&page=1', $scrubbed); + } + + public function testCookieNameIsAllowedInQueryParams(): void + { + $behaviour = ['mode' => 'denyList', 'terms' => []]; + + $scrubbed = SensitiveDataScrubber::scrubQueryString('cookie=foo&set-cookie=bar', $behaviour); + + $this->assertSame('cookie=foo&set-cookie=bar', $scrubbed); + } +}