diff --git a/src/DataCollection/RequestDataCollector.php b/src/DataCollection/RequestDataCollector.php new file mode 100644 index 000000000..3db6cee1c --- /dev/null +++ b/src/DataCollection/RequestDataCollector.php @@ -0,0 +1,189 @@ +dataCollection = $dataCollection; + $this->sendDefaultPii = $sendDefaultPii; + $this->piiSanitizeHeaders = $piiSanitizeHeaders; + } + + public function usesDataCollection(): bool + { + return $this->dataCollection !== null; + } + + public function shouldCollectUserInfo(): bool + { + if ($this->dataCollection === null) { + return $this->sendDefaultPii; + } + + return $this->dataCollection->shouldCollectUserInfo(); + } + + public function collectQueryString(string $queryString): ?string + { + if ($this->dataCollection === null) { + // The legacy behavior collects the query string untouched and + // skips it when it evaluates to a falsy value + return $queryString ?: null; + } + + $behavior = $this->dataCollection->getQueryParams(); + + if ($behavior['mode'] === 'off' || $queryString === '') { + return null; + } + + return SensitiveDataScrubber::scrubQueryString($queryString, $behavior); + } + + /** + * @param array $cookies + * + * @return array|null + */ + public function collectCookies(array $cookies): ?array + { + if ($this->dataCollection === null) { + return $this->sendDefaultPii ? $cookies : null; + } + + $behavior = $this->dataCollection->getCookies(); + + if ($behavior['mode'] === 'off') { + return null; + } + + return SensitiveDataScrubber::scrubKeyValueData($cookies, $behavior); + } + + /** + * @param array $headers + * + * @return array|null + */ + public function collectHeaders(array $headers): ?array + { + if ($this->dataCollection === null) { + return $this->sendDefaultPii ? $headers : $this->sanitizeLegacyHeaders($headers); + } + + $behavior = $this->dataCollection->getHttpHeaders()['request']; + + if ($behavior['mode'] === 'off') { + return null; + } + + return SensitiveDataScrubber::scrubHeaders($headers, $behavior); + } + + public function shouldCollectRequestBody(): bool + { + if ($this->dataCollection === null) { + // The legacy behavior always captures the body, subject only to + // the `max_request_body_size` option enforced by the caller + return true; + } + + return \in_array('incomingRequest', $this->dataCollection->getHttpBodies(), true); + } + + /** + * @param mixed $body + * + * @return mixed + */ + public function collectRequestBody($body) + { + if (empty($body)) { + return null; + } + + if ($this->dataCollection === null) { + return $body; + } + + if (!$this->shouldCollectRequestBody()) { + return null; + } + + return \is_array($body) ? $body : '[Filtered]'; + } + + /** + * @param array $headers + * + * @return array + */ + private function sanitizeLegacyHeaders(array $headers): array + { + foreach ($headers as $name => $values) { + $name = (string) $name; + + if (!\in_array(strtolower($name), $this->piiSanitizeHeaders, true)) { + continue; + } + + foreach ($values as $headerLine => $headerValue) { + $headers[$name][$headerLine] = '[Filtered]'; + } + } + + return $headers; + } +} diff --git a/src/DataCollection/SensitiveDataScrubber.php b/src/DataCollection/SensitiveDataScrubber.php new file mode 100644 index 000000000..605f62c7f --- /dev/null +++ b/src/DataCollection/SensitiveDataScrubber.php @@ -0,0 +1,168 @@ + $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/src/Integration/RequestIntegration.php b/src/Integration/RequestIntegration.php index 8f4949bcd..e453a913b 100644 --- a/src/Integration/RequestIntegration.php +++ b/src/Integration/RequestIntegration.php @@ -6,6 +6,7 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\UploadedFileInterface; +use Sentry\DataCollection\RequestDataCollector; use Sentry\Event; use Sentry\Exception\JsonException; use Sentry\Options; @@ -48,19 +49,6 @@ final class RequestIntegration implements IntegrationInterface 'always' => \PHP_INT_MAX, ]; - /** - * This constant defines the default list of headers that may contain - * sensitive data and that will be sanitized if sending PII is disabled. - */ - private const DEFAULT_SENSITIVE_HEADERS = [ - 'Authorization', - 'Proxy-Authorization', - 'Cookie', - 'Set-Cookie', - 'X-Forwarded-For', - 'X-Real-IP', - ]; - /** * @var RequestFetcherInterface PSR-7 request fetcher */ @@ -128,69 +116,73 @@ private function processEvent(Event $event, Options $options): void return; } + $collector = new RequestDataCollector( + $options->getDataCollection(), + $options->shouldSendDefaultPii(), + $this->options['pii_sanitize_headers'] + ); + + $queryString = $collector->collectQueryString($request->getUri()->getQuery()); + $requestData = [ - 'url' => (string) $request->getUri(), + 'url' => $collector->usesDataCollection() + ? (string) $request->getUri()->withQuery($queryString ?? '') + : (string) $request->getUri(), 'method' => $request->getMethod(), ]; - if ($request->getUri()->getQuery()) { - $requestData['query_string'] = $request->getUri()->getQuery(); + if ($queryString !== null) { + $requestData['query_string'] = $queryString; } - if ($options->shouldSendDefaultPii()) { - $serverParams = $request->getServerParams(); + if ($collector->shouldCollectUserInfo()) { + $this->addRequestUserInfo($event, $request, $requestData); + } - if (!empty($serverParams['REMOTE_ADDR'])) { - $user = $event->getUser(); - $requestData['env']['REMOTE_ADDR'] = $serverParams['REMOTE_ADDR']; + $cookies = $collector->collectCookies($request->getCookieParams()); - if ($user === null) { - $user = UserDataBag::createFromUserIpAddress($serverParams['REMOTE_ADDR']); - } elseif ($user->getIpAddress() === null) { - $user->setIpAddress($serverParams['REMOTE_ADDR']); - } + if ($cookies !== null) { + $requestData['cookies'] = $cookies; + } - $event->setUser($user); - } + $headers = $collector->collectHeaders($request->getHeaders()); - $requestData['cookies'] = $request->getCookieParams(); - $requestData['headers'] = $request->getHeaders(); - } else { - $requestData['headers'] = $this->sanitizeHeaders($request->getHeaders()); + if ($headers !== null) { + $requestData['headers'] = $headers; } - $requestBody = $this->captureRequestBody($options, $request); + if ($collector->shouldCollectRequestBody()) { + $requestBody = $collector->collectRequestBody($this->captureRequestBody($options, $request)); - if (!empty($requestBody)) { - $requestData['data'] = $requestBody; + if ($requestBody !== null) { + $requestData['data'] = $requestBody; + } } $event->setRequest($requestData); } /** - * Removes headers containing potential PII. - * - * @param array $headers Array containing request headers - * - * @return array + * @param array $requestData */ - private function sanitizeHeaders(array $headers): array + private function addRequestUserInfo(Event $event, ServerRequestInterface $request, array &$requestData): void { - foreach ($headers as $name => $values) { - // Cast the header name into a string, to avoid errors on numeric headers - $name = (string) $name; + $serverParams = $request->getServerParams(); - if (!\in_array(strtolower($name), $this->options['pii_sanitize_headers'], true)) { - continue; - } + if (empty($serverParams['REMOTE_ADDR'])) { + return; + } - foreach ($values as $headerLine => $headerValue) { - $headers[$name][$headerLine] = '[Filtered]'; - } + $user = $event->getUser(); + $requestData['env'] = ['REMOTE_ADDR' => $serverParams['REMOTE_ADDR']]; + + if ($user === null) { + $user = UserDataBag::createFromUserIpAddress($serverParams['REMOTE_ADDR']); + } elseif ($user->getIpAddress() === null) { + $user->setIpAddress($serverParams['REMOTE_ADDR']); } - return $headers; + $event->setUser($user); } /** @@ -260,6 +252,7 @@ private function parseUploadedFiles(array $uploadedFiles): array { $result = []; + /** @mago-ignore analysis:mixed-assignment */ foreach ($uploadedFiles as $key => $item) { if ($item instanceof UploadedFileInterface) { $result[$key] = [ @@ -309,6 +302,6 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setNormalizer('pii_sanitize_headers', static function (array $value): array { return array_map('strtolower', $value); }); - $resolver->setDefault('pii_sanitize_headers', self::DEFAULT_SENSITIVE_HEADERS); + $resolver->setDefault('pii_sanitize_headers', RequestDataCollector::DEFAULT_PII_SANITIZE_HEADERS); } } diff --git a/src/Options.php b/src/Options.php index 03e770271..34e843c1d 100644 --- a/src/Options.php +++ b/src/Options.php @@ -352,9 +352,9 @@ public function setContextLines(?int $contextLines): self return $this->updateOptions(['context_lines' => $contextLines]); } - public function getDataCollection(): DataCollectionOptions + public function getDataCollection(): ?DataCollectionOptions { - /** @var DataCollectionOptions $dataCollection */ + /** @var DataCollectionOptions|null $dataCollection */ $dataCollection = $this->options['data_collection']; return $dataCollection; @@ -1267,7 +1267,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('capture_silenced_errors', 'bool'); $resolver->setAllowedTypes('max_request_body_size', 'string'); $resolver->setAllowedTypes('class_serializers', 'array'); - $resolver->setAllowedTypes('data_collection', ['array', DataCollectionOptions::class]); + $resolver->setAllowedTypes('data_collection', ['null', 'array', DataCollectionOptions::class]); $resolver->setAllowedValues('max_request_body_size', ['none', 'never', 'small', 'medium', 'always']); $resolver->setAllowedValues('dsn', \Closure::fromCallable([$this, 'validateDsnOption'])); @@ -1376,7 +1376,7 @@ private function configureOptions(OptionsResolver $resolver): void 'capture_silenced_errors' => false, 'max_request_body_size' => 'medium', 'class_serializers' => [], - 'data_collection' => new DataCollectionOptions(), + 'data_collection' => null, ]); } @@ -1427,11 +1427,11 @@ private function normalizeSpotlightUrl(string $url): string } /** - * @param array|DataCollectionOptions $value + * @param array|DataCollectionOptions|null $value */ - private function normalizeDataCollectionOption($value): DataCollectionOptions + private function normalizeDataCollectionOption($value): ?DataCollectionOptions { - if ($value instanceof DataCollectionOptions) { + if ($value === null || $value instanceof DataCollectionOptions) { return $value; } diff --git a/src/functions.php b/src/functions.php index e5e0e58a7..43645d6dd 100644 --- a/src/functions.php +++ b/src/functions.php @@ -43,7 +43,7 @@ * gen_ai?: array{inputs?: bool, outputs?: bool}, * stack_frame_variables?: bool, * frame_context_lines?: int, - * }, + * }|null, * default_integrations?: bool, * dsn?: string|bool|Dsn|null, * enable_logs?: bool, diff --git a/tests/DataCollection/RequestDataCollectorTest.php b/tests/DataCollection/RequestDataCollectorTest.php new file mode 100644 index 000000000..2790b6a47 --- /dev/null +++ b/tests/DataCollection/RequestDataCollectorTest.php @@ -0,0 +1,390 @@ +legacyCollector(true); + $piiDisabledCollector = $this->legacyCollector(false); + + $this->assertFalse($piiEnabledCollector->usesDataCollection()); + $this->assertFalse($piiDisabledCollector->usesDataCollection()); + } + + public function testUsesDataCollectionIsTrueWhenConfigured(): void + { + $collector = $this->collector([]); + + $this->assertTrue($collector->usesDataCollection()); + } + + public function testShouldCollectUserInfoFollowsLegacySendDefaultPii(): void + { + $piiEnabledCollector = $this->legacyCollector(true); + $piiDisabledCollector = $this->legacyCollector(false); + + $this->assertTrue($piiEnabledCollector->shouldCollectUserInfo()); + $this->assertFalse($piiDisabledCollector->shouldCollectUserInfo()); + } + + public function testShouldCollectUserInfoFollowsDataCollection(): void + { + $enabledCollector = $this->collector(['user_info' => true]); + $disabledCollector = $this->collector(['user_info' => false]); + + $this->assertTrue($enabledCollector->shouldCollectUserInfo()); + $this->assertFalse($disabledCollector->shouldCollectUserInfo()); + } + + public function testShouldCollectUserInfoIgnoresSendDefaultPiiWhenDataCollectionIsConfigured(): void + { + $collector = new RequestDataCollector(new DataCollectionOptions(['user_info' => false]), true); + + $this->assertFalse($collector->shouldCollectUserInfo()); + } + + public function testCollectQueryStringInLegacyModeIsCollectedUntouched(): void + { + $piiEnabledCollector = $this->legacyCollector(true); + $piiDisabledCollector = $this->legacyCollector(false); + + $collectedWithPii = $piiEnabledCollector->collectQueryString('token=secret'); + $collectedWithoutPii = $piiDisabledCollector->collectQueryString('token=secret'); + + $this->assertSame('token=secret', $collectedWithPii); + $this->assertSame('token=secret', $collectedWithoutPii); + } + + public function testCollectQueryStringInLegacyModeSkipsEmptyValues(): void + { + $collector = $this->legacyCollector(true); + + $collected = $collector->collectQueryString(''); + + $this->assertNull($collected); + } + + public function testCollectQueryStringScrubsSensitiveParams(): void + { + $collector = $this->collector([]); + + $collected = $collector->collectQueryString('token=secret&page=5'); + + $this->assertSame('token=[Filtered]&page=5', $collected); + } + + public function testCollectQueryStringAppliesCustomDenyList(): void + { + $collector = $this->collector(['query_params' => ['mode' => 'denyList', 'terms' => ['page']]]); + + $collected = $collector->collectQueryString('page=5&foo=bar'); + + $this->assertSame('page=[Filtered]&foo=bar', $collected); + } + + public function testCollectQueryStringIsSkippedWhenModeIsOff(): void + { + $collector = $this->collector(['query_params' => ['mode' => 'off']]); + + $collected = $collector->collectQueryString('token=secret'); + + $this->assertNull($collected); + } + + public function testCollectQueryStringSkipsEmptyValues(): void + { + $collector = $this->collector([]); + + $collected = $collector->collectQueryString(''); + + $this->assertNull($collected); + } + + public function testCollectCookiesInLegacyModeCollectsRawCookiesWhenPiiIsEnabled(): void + { + $collector = $this->legacyCollector(true); + $cookies = ['session_id' => 'secret']; + + $collected = $collector->collectCookies($cookies); + + $this->assertSame($cookies, $collected); + } + + public function testCollectCookiesInLegacyModeIsSkippedWhenPiiIsDisabled(): void + { + $collector = $this->legacyCollector(false); + + $collected = $collector->collectCookies(['session_id' => 'secret']); + + $this->assertNull($collected); + } + + public function testCollectCookiesScrubsSensitiveCookies(): void + { + $collector = $this->collector([]); + + $collected = $collector->collectCookies([ + 'session_id' => 'secret', + 'theme' => 'dark', + ]); + + $this->assertSame([ + 'session_id' => '[Filtered]', + 'theme' => 'dark', + ], $collected); + } + + public function testCollectCookiesAppliesAllowList(): void + { + $collector = $this->collector(['cookies' => ['mode' => 'allowList', 'terms' => ['theme']]]); + + $collected = $collector->collectCookies([ + 'theme' => 'dark', + 'tracking_id' => '12345', + ]); + + $this->assertSame([ + 'theme' => 'dark', + 'tracking_id' => '[Filtered]', + ], $collected); + } + + public function testCollectCookiesIsSkippedWhenModeIsOff(): void + { + $collector = $this->collector(['cookies' => ['mode' => 'off']]); + + $collected = $collector->collectCookies(['theme' => 'dark']); + + $this->assertNull($collected); + } + + public function testCollectHeadersInLegacyModeCollectsRawHeadersWhenPiiIsEnabled(): void + { + $collector = $this->legacyCollector(true); + $headers = ['Authorization' => ['secret']]; + + $collected = $collector->collectHeaders($headers); + + $this->assertSame($headers, $collected); + } + + public function testCollectHeadersInLegacyModeSanitizesConfiguredHeadersWhenPiiIsDisabled(): void + { + $collector = $this->legacyCollector(false, ['authorization']); + + $collected = $collector->collectHeaders([ + 'Authorization' => ['secret'], + 'X-Request-Id' => ['request-id'], + ]); + + $this->assertSame([ + 'Authorization' => ['[Filtered]'], + 'X-Request-Id' => ['request-id'], + ], $collected); + } + + public function testCollectHeadersInLegacyModeMatchesHeaderNamesExactly(): void + { + $collector = $this->legacyCollector(false, ['authorization']); + + // Unlike the data collection scrubbing, the legacy sanitization does + // not match on partial header names + $collected = $collector->collectHeaders(['X-Authorization-Token' => ['untouched']]); + + $this->assertSame(['X-Authorization-Token' => ['untouched']], $collected); + } + + public function testCollectHeadersInLegacyModeSupportsNumericHeaderNames(): void + { + $collector = $this->legacyCollector(false); + + $collected = $collector->collectHeaders([123 => ['test']]); + + $this->assertSame(['123' => ['test']], $collected); + } + + public function testCollectHeadersScrubsSensitiveHeaders(): void + { + $collector = $this->collector([]); + + $collected = $collector->collectHeaders([ + 'Authorization' => ['secret'], + 'X-Request-Id' => ['request-id'], + ]); + + $this->assertSame([ + 'Authorization' => ['[Filtered]'], + 'X-Request-Id' => ['request-id'], + ], $collected); + } + + public function testCollectHeadersAlwaysScrubsCookieHeaders(): void + { + $collector = $this->collector([]); + + $collected = $collector->collectHeaders([ + 'Cookie' => ['session_id=secret; theme=dark'], + 'Set-Cookie' => ['session_id=secret'], + 'X-Request-Id' => ['request-id'], + ]); + + $this->assertSame([ + 'Cookie' => ['[Filtered]'], + 'Set-Cookie' => ['[Filtered]'], + 'X-Request-Id' => ['request-id'], + ], $collected); + } + + public function testCollectHeadersAppliesExtendedDenyTermsToClientIpHeaders(): void + { + $defaultCollector = $this->collector([]); + $extendedCollector = $this->collector(['http_headers' => ['request' => ['mode' => 'denyList', 'terms' => ['forwarded', '-ip']]]]); + $headers = ['X-Forwarded-For' => ['203.0.113.7']]; + + $this->assertSame($headers, $defaultCollector->collectHeaders($headers)); + $this->assertSame(['X-Forwarded-For' => ['[Filtered]']], $extendedCollector->collectHeaders($headers)); + } + + public function testCollectHeadersScrubsEveryLineOfSensitiveHeaders(): void + { + $collector = $this->collector([]); + + $collected = $collector->collectHeaders(['X-Api-Key' => ['first', 'second']]); + + $this->assertSame(['X-Api-Key' => ['[Filtered]', '[Filtered]']], $collected); + } + + public function testCollectHeadersAppliesAllowList(): void + { + $collector = $this->collector(['http_headers' => ['request' => ['mode' => 'allowList', 'terms' => ['x-request-id']]]]); + + $collected = $collector->collectHeaders([ + 'X-Request-Id' => ['request-id'], + 'Host' => ['www.example.com'], + ]); + + $this->assertSame([ + 'X-Request-Id' => ['request-id'], + 'Host' => ['[Filtered]'], + ], $collected); + } + + public function testCollectHeadersAllowListCannotOverrideMandatoryDenyList(): void + { + $collector = $this->collector(['http_headers' => ['request' => ['mode' => 'allowList', 'terms' => ['authorization']]]]); + + $collected = $collector->collectHeaders(['Authorization' => ['secret']]); + + $this->assertSame(['Authorization' => ['[Filtered]']], $collected); + } + + public function testCollectHeadersIsSkippedWhenModeIsOff(): void + { + $collector = $this->collector(['http_headers' => ['request' => ['mode' => 'off']]]); + + $collected = $collector->collectHeaders(['X-Request-Id' => ['request-id']]); + + $this->assertNull($collected); + } + + public function testShouldCollectRequestBodyIsAlwaysTrueInLegacyMode(): void + { + $piiEnabledCollector = $this->legacyCollector(true); + $piiDisabledCollector = $this->legacyCollector(false); + + $this->assertTrue($piiEnabledCollector->shouldCollectRequestBody()); + $this->assertTrue($piiDisabledCollector->shouldCollectRequestBody()); + } + + public function testShouldCollectRequestBodyIsTrueWhenCollectingIncomingRequests(): void + { + $collector = $this->collector(['http_bodies' => ['incomingRequest']]); + + $this->assertTrue($collector->shouldCollectRequestBody()); + } + + public function testShouldCollectRequestBodyIsFalseWhenNotCollectingIncomingRequests(): void + { + $disabledCollector = $this->collector(['http_bodies' => []]); + $outgoingOnlyCollector = $this->collector(['http_bodies' => ['outgoingRequest']]); + + $this->assertFalse($disabledCollector->shouldCollectRequestBody()); + $this->assertFalse($outgoingOnlyCollector->shouldCollectRequestBody()); + } + + public function testCollectRequestBodyInLegacyModeCollectsRawBody(): void + { + $piiEnabledCollector = $this->legacyCollector(true); + $piiDisabledCollector = $this->legacyCollector(false); + $body = ['password' => 'secret']; + + $collectedWithPii = $piiEnabledCollector->collectRequestBody($body); + $collectedWithoutPii = $piiDisabledCollector->collectRequestBody('raw body'); + + $this->assertSame($body, $collectedWithPii); + $this->assertSame('raw body', $collectedWithoutPii); + } + + public function testCollectRequestBodyCollectsRawBodyWhenCollectingIncomingRequests(): void + { + $collector = $this->collector(['http_bodies' => ['incomingRequest']]); + $body = [ + 'password' => 'secret', + 'username' => 'alice', + ]; + + $this->assertSame($body, $collector->collectRequestBody($body)); + $this->assertSame('[Filtered]', $collector->collectRequestBody('raw body')); + } + + public function testCollectRequestBodyIsSkippedWhenNotCollectingIncomingRequests(): void + { + $collector = $this->collector(['http_bodies' => []]); + + $collected = $collector->collectRequestBody('raw body'); + + $this->assertNull($collected); + } + + public function testCollectRequestBodyInLegacyModeSkipsEmptyValues(): void + { + $collector = $this->legacyCollector(true); + + $this->assertNull($collector->collectRequestBody('')); + $this->assertNull($collector->collectRequestBody([])); + $this->assertNull($collector->collectRequestBody(null)); + } + + public function testCollectRequestBodySkipsEmptyValues(): void + { + $collector = $this->collector([]); + + $this->assertNull($collector->collectRequestBody('')); + $this->assertNull($collector->collectRequestBody([])); + $this->assertNull($collector->collectRequestBody(null)); + } + + /** + * @param string[] $piiSanitizeHeaders + */ + private function legacyCollector(bool $sendDefaultPii, array $piiSanitizeHeaders = RequestDataCollector::DEFAULT_PII_SANITIZE_HEADERS): RequestDataCollector + { + return new RequestDataCollector(null, $sendDefaultPii, $piiSanitizeHeaders); + } + + /** + * @param array $dataCollection + */ + private function collector(array $dataCollection): RequestDataCollector + { + return new RequestDataCollector(new DataCollectionOptions($dataCollection), false); + } +} diff --git a/tests/DataCollection/SensitiveDataScrubberTest.php b/tests/DataCollection/SensitiveDataScrubberTest.php new file mode 100644 index 000000000..8068094b6 --- /dev/null +++ b/tests/DataCollection/SensitiveDataScrubberTest.php @@ -0,0 +1,183 @@ + 'denyList', 'terms' => []]; + + $scrubbed = SensitiveDataScrubber::scrubKeyValueData([ + 'authorization' => 'secret', + 'public' => 'visible', + ], $behavior); + + $this->assertSame([ + 'authorization' => '[Filtered]', + 'public' => 'visible', + ], $scrubbed); + } + + public function testScrubKeyValueDataCombinesMandatoryAndCustomDenyListTerms(): 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 testScrubKeyValueDataMatchesKeysCaseInsensitively(): void + { + $behavior = ['mode' => 'denyList', 'terms' => []]; + + $scrubbed = SensitiveDataScrubber::scrubKeyValueData(['AUTHORIZATION' => 'secret'], $behavior); + + $this->assertSame(['AUTHORIZATION' => '[Filtered]'], $scrubbed); + } + + public function testScrubKeyValueDataAllowListScrubsEverythingElse(): 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 testScrubHeadersCollectsClientIpHeadersUnlessExtendedDenyTermsAreConfigured(): 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); + } +} diff --git a/tests/Integration/RequestIntegrationTest.php b/tests/Integration/RequestIntegrationTest.php index e53a432ac..941d47a09 100644 --- a/tests/Integration/RequestIntegrationTest.php +++ b/tests/Integration/RequestIntegrationTest.php @@ -232,6 +232,33 @@ public static function invokeDataProvider(): iterable yield [ [ + 'send_default_pii' => true, + 'max_request_body_size' => 'always', + ], + (new ServerRequest('POST', 'http://www.example.com/foo?token=secret')) + ->withCookieParams(['session_id' => 'secret']) + ->withHeader('Authorization', 'Bearer secret') + ->withHeader('Content-Length', '3') + ->withBody(Utils::streamFor('foo')), + [ + 'url' => 'http://www.example.com/foo?token=secret', + 'method' => 'POST', + 'query_string' => 'token=secret', + 'cookies' => ['session_id' => 'secret'], + 'headers' => [ + 'Host' => ['www.example.com'], + 'Authorization' => ['Bearer secret'], + 'Content-Length' => ['3'], + ], + 'data' => 'foo', + ], + null, + null, + ]; + + yield [ + [ + 'send_default_pii' => false, 'max_request_body_size' => 'none', ], (new ServerRequest('POST', 'http://www.example.com/foo')) @@ -251,6 +278,7 @@ public static function invokeDataProvider(): iterable yield [ [ + 'send_default_pii' => false, 'max_request_body_size' => 'never', ], (new ServerRequest('POST', 'http://www.example.com/foo')) @@ -270,6 +298,7 @@ public static function invokeDataProvider(): iterable yield [ [ + 'send_default_pii' => false, 'max_request_body_size' => 'small', ], (new ServerRequest('POST', 'http://www.example.com/foo')) @@ -290,6 +319,7 @@ public static function invokeDataProvider(): iterable yield [ [ + 'send_default_pii' => false, 'max_request_body_size' => 'small', ], (new ServerRequest('POST', 'http://www.example.com/foo')) @@ -316,6 +346,7 @@ public static function invokeDataProvider(): iterable yield [ [ + 'send_default_pii' => false, 'max_request_body_size' => 'small', ], (new ServerRequest('POST', 'http://www.example.com/foo')) @@ -338,6 +369,7 @@ public static function invokeDataProvider(): iterable yield [ [ + 'send_default_pii' => false, 'max_request_body_size' => 'medium', ], (new ServerRequest('POST', 'http://www.example.com/foo')) @@ -364,6 +396,7 @@ public static function invokeDataProvider(): iterable yield [ [ + 'send_default_pii' => false, 'max_request_body_size' => 'medium', ], (new ServerRequest('POST', 'http://www.example.com/foo')) @@ -386,6 +419,7 @@ public static function invokeDataProvider(): iterable yield [ [ + 'send_default_pii' => false, 'max_request_body_size' => 'always', ], (new ServerRequest('POST', 'http://www.example.com/foo')) @@ -424,6 +458,7 @@ public static function invokeDataProvider(): iterable yield [ [ + 'send_default_pii' => false, 'max_request_body_size' => 'always', ], (new ServerRequest('POST', 'http://www.example.com/foo')) @@ -449,6 +484,7 @@ public static function invokeDataProvider(): iterable yield [ [ + 'send_default_pii' => false, 'max_request_body_size' => 'always', ], (new ServerRequest('POST', 'http://www.example.com/foo')) @@ -473,6 +509,7 @@ public static function invokeDataProvider(): iterable yield [ [ + 'send_default_pii' => false, 'max_request_body_size' => 'always', ], (new ServerRequest('POST', 'http://www.example.com/foo')) @@ -492,6 +529,157 @@ public static function invokeDataProvider(): iterable yield [ [], + (new ServerRequest('POST', 'http://www.example.com/foo?token=secret&page=5', [], null, '1.1', ['REMOTE_ADDR' => '127.0.0.1'])) + ->withCookieParams([ + 'session_id' => 'secret', + 'theme' => 'dark', + ]) + ->withHeader('Authorization', 'Bearer secret') + ->withHeader('X-Request-Id', 'request-id') + ->withHeader('Content-Length', '3') + ->withBody(Utils::streamFor('foo')), + [ + 'url' => 'http://www.example.com/foo?token=secret&page=5', + 'method' => 'POST', + 'query_string' => 'token=secret&page=5', + 'headers' => [ + 'Host' => ['www.example.com'], + 'Authorization' => ['[Filtered]'], + 'X-Request-Id' => ['request-id'], + 'Content-Length' => ['3'], + ], + 'data' => 'foo', + ], + null, + null, + ]; + + yield [ + [ + 'data_collection' => [ + 'user_info' => false, + 'cookies' => ['mode' => 'off'], + 'http_headers' => ['request' => ['mode' => 'off']], + 'http_bodies' => [], + 'query_params' => ['mode' => 'off'], + ], + ], + (new ServerRequest('POST', 'http://www.example.com/foo?token=secret', [], null, '1.1', ['REMOTE_ADDR' => '127.0.0.1'])) + ->withCookieParams(['session_id' => 'secret']) + ->withHeader('Authorization', 'Bearer secret') + ->withHeader('Content-Length', '3') + ->withBody(Utils::streamFor('foo')), + [ + 'url' => 'http://www.example.com/foo', + 'method' => 'POST', + ], + null, + null, + ]; + + yield [ + [ + 'data_collection' => [ + 'user_info' => false, + 'cookies' => ['mode' => 'allowList', 'terms' => ['theme']], + 'http_headers' => ['request' => ['mode' => 'allowList', 'terms' => ['x-request-id']]], + 'http_bodies' => [], + 'query_params' => ['mode' => 'denyList', 'terms' => ['page']], + ], + ], + (new ServerRequest('GET', 'http://www.example.com/foo?token=secret&page=5')) + ->withCookieParams([ + 'session_id' => 'secret', + 'theme' => 'dark', + ]) + ->withHeader('Authorization', 'Bearer secret') + ->withHeader('X-Request-Id', 'request-id'), + [ + 'url' => 'http://www.example.com/foo?token=%5BFiltered%5D&page=%5BFiltered%5D', + 'method' => 'GET', + 'query_string' => 'token=[Filtered]&page=[Filtered]', + 'cookies' => [ + 'session_id' => '[Filtered]', + 'theme' => 'dark', + ], + 'headers' => [ + 'Host' => ['[Filtered]'], + 'Authorization' => ['[Filtered]'], + 'X-Request-Id' => ['request-id'], + ], + ], + null, + null, + ]; + + yield [ + [ + 'data_collection' => [ + 'user_info' => false, + ], + ], + (new ServerRequest('GET', 'http://www.example.com/foo', [], null, '1.1', ['REMOTE_ADDR' => '127.0.0.1'])) + ->withCookieParams([ + 'session_id' => 'secret', + 'theme' => 'dark', + ]) + ->withHeader('Cookie', 'session_id=secret; theme=dark') + ->withHeader('Set-Cookie', 'session_id=secret') + ->withHeader('X-Forwarded-For', '203.0.113.7') + ->withHeader('X-Request-Id', 'request-id'), + [ + 'url' => 'http://www.example.com/foo', + 'method' => 'GET', + 'cookies' => [ + 'session_id' => '[Filtered]', + 'theme' => 'dark', + ], + 'headers' => [ + 'Host' => ['www.example.com'], + 'Cookie' => ['[Filtered]'], + 'Set-Cookie' => ['[Filtered]'], + 'X-Forwarded-For' => ['203.0.113.7'], + 'X-Request-Id' => ['request-id'], + ], + ], + null, + null, + ]; + + yield [ + [ + 'data_collection' => [ + 'http_bodies' => ['incomingRequest'], + ], + 'max_request_body_size' => 'always', + ], + (new ServerRequest('POST', 'http://www.example.com/foo')) + ->withHeader('Content-Length', '10') + ->withParsedBody([ + 'username' => 'alice', + 'password' => 'secret', + 'nested' => ['api_token' => 'secret'], + ]), + [ + 'url' => 'http://www.example.com/foo', + 'method' => 'POST', + 'cookies' => [], + 'headers' => [ + 'Host' => ['www.example.com'], + 'Content-Length' => ['10'], + ], + 'data' => [ + 'username' => 'alice', + 'password' => 'secret', + 'nested' => ['api_token' => 'secret'], + ], + ], + null, + null, + ]; + + yield [ + ['send_default_pii' => false], (new ServerRequest('GET', 'http://www.example.com/foo')) ->withHeader('123', 'test'), [ diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index fada65cbe..9382c4489 100644 --- a/tests/OptionsTest.php +++ b/tests/OptionsTest.php @@ -590,9 +590,6 @@ public function testDefaultOptionValues(): void $actual[$callbackOption] = \Closure::class; } - $this->assertInstanceOf(DataCollectionOptions::class, $actual['data_collection']); - $actual['data_collection'] = DataCollectionOptions::class; - $expected = [ 'integrations' => [], 'default_integrations' => true, @@ -636,7 +633,7 @@ public function testDefaultOptionValues(): void 'in_app_exclude' => [], 'in_app_include' => [], 'send_default_pii' => false, - 'data_collection' => DataCollectionOptions::class, + 'data_collection' => null, 'max_value_length' => 1024, 'transport' => null, 'http_client' => null, @@ -699,6 +696,14 @@ public function testDataCollectionOptionNormalizesNestedArray(): void $this->assertSame(['inputs' => true, 'outputs' => false], $dataCollection->getGenAi()); } + public function testDataCollectionIsNullUnlessExplicitlyConfigured(): void + { + $this->assertNull((new Options())->getDataCollection()); + $this->assertNull((new Options(['send_default_pii' => true]))->getDataCollection()); + $this->assertNull((new Options(['send_default_pii' => false]))->getDataCollection()); + $this->assertNull((new Options(['data_collection' => null]))->getDataCollection()); + } + public function testDataCollectionOptionPreservesObjectIdentityAndCanBeUpdatedThroughGetter(): void { $dataCollection = (new DataCollectionOptions())->setUserInfo(false);