From 365d8d1800ff4bf217d0e89b2f159d844fd606e8 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 16 Jul 2026 13:58:12 +0200 Subject: [PATCH 01/10] feat(pii): add data collection options --- src/DataCollection/DataCollectionOptions.php | 329 ++++++++++++++++++ src/Options.php | 24 ++ src/functions.php | 13 + .../DataCollectionOptionsTest.php | 135 +++++++ tests/OptionsTest.php | 44 ++- 5 files changed, 543 insertions(+), 2 deletions(-) create mode 100644 src/DataCollection/DataCollectionOptions.php create mode 100644 tests/DataCollection/DataCollectionOptionsTest.php diff --git a/src/DataCollection/DataCollectionOptions.php b/src/DataCollection/DataCollectionOptions.php new file mode 100644 index 000000000..b2af626b2 --- /dev/null +++ b/src/DataCollection/DataCollectionOptions.php @@ -0,0 +1,329 @@ +, + * value-of + * > + */ +final class DataCollectionOptions implements \ArrayAccess +{ + private const COLLECTION_MODES = [ + 'off', + 'denyList', + 'allowList', + ]; + + private const COLLECTION_DEFAULT = [ + 'mode' => 'denyList', + 'terms' => [], + ]; + + /** + * @internal + */ + public const HTTP_BODY_TYPES = [ + 'incomingRequest', + 'outgoingRequest', + 'incomingResponse', + 'outgoingResponse', + ]; + + private const DEFAULTS = [ + 'user_info' => true, + 'cookies' => self::COLLECTION_DEFAULT, + 'http_headers' => [ + 'request' => self::COLLECTION_DEFAULT, + 'response' => self::COLLECTION_DEFAULT, + ], + 'http_bodies' => self::HTTP_BODY_TYPES, + 'query_params' => self::COLLECTION_DEFAULT, + 'gen_ai' => [ + 'inputs' => true, + 'outputs' => true, + ], + 'stack_frame_variables' => true, + 'frame_context_lines' => 5, + ]; + + /** + * @var array + * + * @phpstan-var ResolvedDataCollectionOptions + */ + private $options; + + /** + * @var OptionsResolver + */ + private $resolver; + + /** + * @param array $options + */ + public function __construct(array $options = []) + { + $this->resolver = new OptionsResolver(); + $this->configureOptions($this->resolver); + + /** @var ResolvedDataCollectionOptions $resolvedOptions */ + $resolvedOptions = $this->resolver->resolve($options); + $this->options = $resolvedOptions; + } + + public function shouldCollectUserInfo(): bool + { + return $this->options['user_info']; + } + + public function setUserInfo(bool $userInfo): self + { + return $this->updateOptions(['user_info' => $userInfo]); + } + + /** + * @phpstan-return KeyValueCollectionBehavior + */ + public function getCookies(): array + { + return $this->options['cookies']; + } + + /** + * @param array $cookies + * + * @phpstan-param array{mode?: 'off'|'denyList'|'allowList', terms?: string[]} $cookies + */ + public function setCookies(array $cookies): self + { + return $this->updateOptions(['cookies' => $cookies]); + } + + /** + * @phpstan-return HttpHeaders + */ + public function getHttpHeaders(): array + { + return $this->options['http_headers']; + } + + /** + * @param array $httpHeaders + * + * @phpstan-param array{ + * mode?: 'off'|'denyList'|'allowList', + * terms?: string[], + * request?: array{mode?: 'off'|'denyList'|'allowList', terms?: string[]}, + * response?: array{mode?: 'off'|'denyList'|'allowList', terms?: string[]} + * } $httpHeaders + */ + public function setHttpHeaders(array $httpHeaders): self + { + return $this->updateOptions(['http_headers' => $httpHeaders]); + } + + /** + * @return string[] + */ + public function getHttpBodies(): array + { + return $this->options['http_bodies']; + } + + /** + * @param string[] $httpBodies + */ + public function setHttpBodies(array $httpBodies): self + { + return $this->updateOptions(['http_bodies' => $httpBodies]); + } + + /** + * @phpstan-return KeyValueCollectionBehavior + */ + public function getQueryParams(): array + { + return $this->options['query_params']; + } + + /** + * @param array $queryParams + * + * @phpstan-param array{mode?: 'off'|'denyList'|'allowList', terms?: string[]} $queryParams + */ + public function setQueryParams(array $queryParams): self + { + return $this->updateOptions(['query_params' => $queryParams]); + } + + /** + * @phpstan-return GenAi + */ + public function getGenAi(): array + { + return $this->options['gen_ai']; + } + + /** + * @param array $genAi + * + * @phpstan-param array{inputs?: bool, outputs?: bool} $genAi + */ + public function setGenAi(array $genAi): self + { + return $this->updateOptions(['gen_ai' => $genAi]); + } + + public function shouldCollectStackFrameVariables(): bool + { + return $this->options['stack_frame_variables']; + } + + public function setStackFrameVariables(bool $stackFrameVariables): self + { + return $this->updateOptions(['stack_frame_variables' => $stackFrameVariables]); + } + + public function getFrameContextLines(): int + { + return $this->options['frame_context_lines']; + } + + public function setFrameContextLines(int $frameContextLines): self + { + return $this->updateOptions(['frame_context_lines' => $frameContextLines]); + } + + /** + * @param mixed $offset + */ + public function offsetExists($offset): bool + { + return \is_string($offset) && \array_key_exists($offset, $this->options); + } + + /** + * @template TKey of key-of + * + * @param TKey $offset + * + * @return ResolvedDataCollectionOptions[TKey] + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + if (!$this->offsetExists($offset)) { + /** @phpstan-ignore-next-line Runtime access to unknown offsets is intentionally non-throwing. */ + return null; + } + + return $this->options[$offset]; + } + + /** + * @param mixed $offset + * @param mixed $value + */ + public function offsetSet($offset, $value): void + { + if (!\is_string($offset)) { + return; + } + + $this->updateOptions([$offset => $value]); + } + + /** + * @param mixed $offset + */ + public function offsetUnset($offset): void + { + if (!\is_string($offset) || !\array_key_exists($offset, self::DEFAULTS)) { + return; + } + + /** @var mixed $default */ + $default = self::DEFAULTS[$offset]; + $this->updateOptions([$offset => $default]); + } + + private function configureOptions(OptionsResolver $resolver): void + { + $resolver->setAllowedTypes('user_info', 'bool'); + $resolver->setAllowedTypes('cookies', 'array'); + $resolver->setAllowedTypes('cookies.mode', 'string'); + $resolver->setAllowedTypes('cookies.terms', 'string[]'); + $resolver->setAllowedTypes('http_headers', 'array'); + $resolver->setAllowedTypes('http_headers.request', 'array'); + $resolver->setAllowedTypes('http_headers.request.mode', 'string'); + $resolver->setAllowedTypes('http_headers.request.terms', 'string[]'); + $resolver->setAllowedTypes('http_headers.response', 'array'); + $resolver->setAllowedTypes('http_headers.response.mode', 'string'); + $resolver->setAllowedTypes('http_headers.response.terms', 'string[]'); + $resolver->setAllowedTypes('http_bodies', 'string[]'); + $resolver->setAllowedTypes('query_params', 'array'); + $resolver->setAllowedTypes('query_params.mode', 'string'); + $resolver->setAllowedTypes('query_params.terms', 'string[]'); + $resolver->setAllowedTypes('gen_ai', 'array'); + $resolver->setAllowedTypes('gen_ai.inputs', 'bool'); + $resolver->setAllowedTypes('gen_ai.outputs', 'bool'); + $resolver->setAllowedTypes('stack_frame_variables', 'bool'); + $resolver->setAllowedTypes('frame_context_lines', 'int'); + + $resolver->setAllowedValues('cookies.mode', self::COLLECTION_MODES); + $resolver->setAllowedValues('http_headers.request.mode', self::COLLECTION_MODES); + $resolver->setAllowedValues('http_headers.response.mode', self::COLLECTION_MODES); + $resolver->setAllowedValues('query_params.mode', self::COLLECTION_MODES); + $resolver->setAllowedValues('http_bodies', static function (array $value): bool { + return array_diff($value, self::HTTP_BODY_TYPES) === []; + }); + $resolver->setAllowedValues('frame_context_lines', static function (int $value): bool { + return $value >= 0; + }); + + $resolver->setNormalizer('http_headers', static function (array $value): array { + if (!\array_key_exists('request', $value) && !\array_key_exists('response', $value)) { + return [ + 'request' => $value, + 'response' => $value, + ]; + } + + return $value; + }); + $resolver->setDefaults(self::DEFAULTS); + } + + /** + * @param array $override + */ + private function updateOptions(array $override): self + { + $resolved = $this->resolver->resolveOnly($override, $this->options); + /** @var ResolvedDataCollectionOptions $options */ + $options = array_merge($this->options, $resolved); + $this->options = $options; + + return $this; + } +} diff --git a/src/Options.php b/src/Options.php index 73d8ffad7..03e770271 100644 --- a/src/Options.php +++ b/src/Options.php @@ -6,6 +6,7 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Sentry\DataCollection\DataCollectionOptions; use Sentry\HttpClient\HttpClientInterface; use Sentry\Integration\ErrorListenerIntegration; use Sentry\Integration\IntegrationInterface; @@ -351,6 +352,14 @@ public function setContextLines(?int $contextLines): self return $this->updateOptions(['context_lines' => $contextLines]); } + public function getDataCollection(): DataCollectionOptions + { + /** @var DataCollectionOptions $dataCollection */ + $dataCollection = $this->options['data_collection']; + + return $dataCollection; + } + /** * Gets the environment. */ @@ -1258,6 +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->setAllowedValues('max_request_body_size', ['none', 'never', 'small', 'medium', 'always']); $resolver->setAllowedValues('dsn', \Closure::fromCallable([$this, 'validateDsnOption'])); @@ -1268,6 +1278,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedValues('metric_flush_threshold', \Closure::fromCallable([$this, 'validateMetricFlushThresholdOption'])); $resolver->setNormalizer('dsn', \Closure::fromCallable([$this, 'normalizeDsnOption'])); + $resolver->setNormalizer('data_collection', \Closure::fromCallable([$this, 'normalizeDataCollectionOption'])); $resolver->setNormalizer('prefixes', function (array $value) { return array_map([$this, 'normalizeAbsolutePath'], $value); @@ -1365,6 +1376,7 @@ private function configureOptions(OptionsResolver $resolver): void 'capture_silenced_errors' => false, 'max_request_body_size' => 'medium', 'class_serializers' => [], + 'data_collection' => new DataCollectionOptions(), ]); } @@ -1414,6 +1426,18 @@ private function normalizeSpotlightUrl(string $url): string return $url; } + /** + * @param array|DataCollectionOptions $value + */ + private function normalizeDataCollectionOption($value): DataCollectionOptions + { + if ($value instanceof DataCollectionOptions) { + return $value; + } + + return new DataCollectionOptions($value); + } + /** * Normalizes the DSN option by parsing the host, public and secret keys and * an optional path. diff --git a/src/functions.php b/src/functions.php index ecbc8a349..e5e0e58a7 100644 --- a/src/functions.php +++ b/src/functions.php @@ -31,6 +31,19 @@ * before_send_transaction?: callable, * capture_silenced_errors?: bool, * context_lines?: int|null, + * data_collection?: DataCollection\DataCollectionOptions|array{ + * user_info?: bool, + * cookies?: array{mode?: "off"|"denyList"|"allowList", terms?: array}, + * http_headers?: array{mode?: "off"|"denyList"|"allowList", terms?: array}|array{ + * request?: array{mode?: "off"|"denyList"|"allowList", terms?: array}, + * response?: array{mode?: "off"|"denyList"|"allowList", terms?: array}, + * }, + * http_bodies?: array<"incomingRequest"|"outgoingRequest"|"incomingResponse"|"outgoingResponse">, + * query_params?: array{mode?: "off"|"denyList"|"allowList", terms?: array}, + * gen_ai?: array{inputs?: bool, outputs?: bool}, + * stack_frame_variables?: bool, + * frame_context_lines?: int, + * }, * default_integrations?: bool, * dsn?: string|bool|Dsn|null, * enable_logs?: bool, diff --git a/tests/DataCollection/DataCollectionOptionsTest.php b/tests/DataCollection/DataCollectionOptionsTest.php new file mode 100644 index 000000000..1df694aa2 --- /dev/null +++ b/tests/DataCollection/DataCollectionOptionsTest.php @@ -0,0 +1,135 @@ + 'denyList', 'terms' => []]; + + $this->assertTrue($options->shouldCollectUserInfo()); + $this->assertSame($collectionDefault, $options->getCookies()); + $this->assertSame([ + 'request' => $collectionDefault, + 'response' => $collectionDefault, + ], $options->getHttpHeaders()); + $this->assertSame(DataCollectionOptions::HTTP_BODY_TYPES, $options->getHttpBodies()); + $this->assertSame($collectionDefault, $options->getQueryParams()); + $this->assertSame(['inputs' => true, 'outputs' => true], $options->getGenAi()); + $this->assertTrue($options->shouldCollectStackFrameVariables()); + $this->assertSame(5, $options->getFrameContextLines()); + } + + public function testSharedHttpHeadersConfigurationAppliesToBothDirections(): void + { + $options = new DataCollectionOptions([ + 'http_headers' => [ + 'mode' => 'allowList', + 'terms' => ['x-request-id'], + ], + ]); + + $expected = ['mode' => 'allowList', 'terms' => ['x-request-id']]; + $this->assertSame(['request' => $expected, 'response' => $expected], $options->getHttpHeaders()); + } + + public function testSetterPreservesUnchangedNestedValues(): void + { + $options = new DataCollectionOptions([ + 'cookies' => ['mode' => 'allowList', 'terms' => ['first']], + ]); + + $result = $options->setCookies(['terms' => ['second']]); + + $this->assertSame($options, $result); + $this->assertSame(['mode' => 'allowList', 'terms' => ['second']], $options->getCookies()); + } + + public function testNullHttpBodiesUsesDefault(): void + { + $options = new DataCollectionOptions(['http_bodies' => null]); + + $this->assertSame(DataCollectionOptions::HTTP_BODY_TYPES, $options->getHttpBodies()); + } + + public function testInvalidValuesUseDefaultsAndSettersKeepCurrentValues(): void + { + $options = new DataCollectionOptions([ + 'cookies' => ['mode' => 'invalid', 'terms' => [42]], + 'http_bodies' => ['invalid'], + 'gen_ai' => ['inputs' => 'invalid'], + 'frame_context_lines' => -1, + ]); + + $this->assertSame(['mode' => 'denyList', 'terms' => []], $options->getCookies()); + $this->assertSame(DataCollectionOptions::HTTP_BODY_TYPES, $options->getHttpBodies()); + $this->assertSame(['inputs' => true, 'outputs' => true], $options->getGenAi()); + $this->assertSame(5, $options->getFrameContextLines()); + + $options->setCookies(['mode' => 'allowList'])->setCookies(['mode' => 'invalid']); + $options->setHttpBodies(['incomingRequest'])->setHttpBodies(['invalid']); + $options->setFrameContextLines(2)->setFrameContextLines(-1); + + $this->assertSame('allowList', $options->getCookies()['mode']); + $this->assertSame(['incomingRequest'], $options->getHttpBodies()); + $this->assertSame(2, $options->getFrameContextLines()); + } + + public function testArrayAccessReadsNestedOptions(): void + { + $options = new DataCollectionOptions([ + 'http_headers' => [ + 'request' => ['mode' => 'allowList'], + ], + ]); + + $this->assertTrue(isset($options['http_headers'])); + $this->assertFalse(isset($options['unknown'])); + $this->assertSame('allowList', $options['http_headers']['request']['mode']); + $this->assertNull($options['unknown']); + $this->assertNull($options[0]); + } + + public function testArrayAccessWritesUseResolver(): void + { + $options = new DataCollectionOptions(); + + $options['http_headers'] = [ + 'request' => ['mode' => 'off'], + ]; + $this->assertSame('off', $options['http_headers']['request']['mode']); + $this->assertSame('denyList', $options['http_headers']['response']['mode']); + + $options['http_headers'] = ['request' => ['mode' => 'invalid']]; + $options['frame_context_lines'] = -1; + $options['http_bodies'] = ['incomingRequest']; + $options['http_bodies'] = null; + $options['unknown'] = true; + $options[] = true; + + $this->assertSame('off', $options['http_headers']['request']['mode']); + $this->assertSame(5, $options['frame_context_lines']); + $this->assertSame(['incomingRequest'], $options['http_bodies']); + $this->assertNull($options['unknown']); + } + + public function testArrayAccessUnsetRestoresDefault(): void + { + $options = new DataCollectionOptions([ + 'user_info' => false, + 'http_bodies' => [], + ]); + + unset($options['user_info'], $options['http_bodies'], $options['unknown'], $options[0]); + + $this->assertTrue($options['user_info']); + $this->assertSame(DataCollectionOptions::HTTP_BODY_TYPES, $options['http_bodies']); + } +} diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index 32972eefb..fada65cbe 100644 --- a/tests/OptionsTest.php +++ b/tests/OptionsTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; use Sentry\ClientBuilder; +use Sentry\DataCollection\DataCollectionOptions; use Sentry\Dsn; use Sentry\Event; use Sentry\HttpClient\HttpClient; @@ -376,6 +377,13 @@ static function (): void {}, 'setSendDefaultPii', ]; + yield [ + 'option' => 'data_collection', + 'value' => (new DataCollectionOptions())->setUserInfo(false), + 'getter' => 'getDataCollection', + 'setter' => null, + ]; + yield [ 'default_integrations', false, @@ -524,7 +532,7 @@ static function (array $integrations): array { public static function optionsWithSettersDataProvider(): \Generator { foreach (self::optionsDataProvider() as $testCase) { - if ($testCase[3] !== null) { + if (($testCase['setter'] ?? $testCase[3] ?? null) !== null) { yield $testCase; } } @@ -536,7 +544,7 @@ public function testAllOptionsAreCoveredByOptionsDataProvider(): void $testedOptions = []; foreach (self::optionsDataProvider() as $testCase) { - $testedOptions[] = $testCase[0]; + $testedOptions[] = $testCase['option'] ?? $testCase[0]; } $testedOptions = array_values(array_unique($testedOptions)); @@ -582,6 +590,9 @@ 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, @@ -625,6 +636,7 @@ public function testDefaultOptionValues(): void 'in_app_exclude' => [], 'in_app_include' => [], 'send_default_pii' => false, + 'data_collection' => DataCollectionOptions::class, 'max_value_length' => 1024, 'transport' => null, 'http_client' => null, @@ -669,6 +681,34 @@ public function testAllDefaultValuesPassValidation(): void $this->assertSame([], StubLogger::$logs); } + public function testDataCollectionOptionNormalizesNestedArray(): void + { + $dataCollection = (new Options([ + 'data_collection' => [ + 'user_info' => false, + 'http_headers' => [ + 'request' => ['mode' => 'off'], + ], + 'gen_ai' => ['outputs' => false], + ], + ]))->getDataCollection(); + + $this->assertFalse($dataCollection->shouldCollectUserInfo()); + $this->assertSame('off', $dataCollection->getHttpHeaders()['request']['mode']); + $this->assertSame('denyList', $dataCollection->getHttpHeaders()['response']['mode']); + $this->assertSame(['inputs' => true, 'outputs' => false], $dataCollection->getGenAi()); + } + + public function testDataCollectionOptionPreservesObjectIdentityAndCanBeUpdatedThroughGetter(): void + { + $dataCollection = (new DataCollectionOptions())->setUserInfo(false); + $options = new Options(['data_collection' => $dataCollection]); + + $this->assertSame($dataCollection, $options->getDataCollection()); + $options->getDataCollection()->setFrameContextLines(0); + $this->assertSame(0, $dataCollection->getFrameContextLines()); + } + /** * @dataProvider dsnOptionDataProvider */ From 03e50841287ced627de0dd5583c17a08e2ecfd69 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 16 Jul 2026 14:07:41 +0200 Subject: [PATCH 02/10] mago --- src/DataCollection/DataCollectionOptions.php | 16 ++++++++++++---- src/OptionsResolver.php | 2 ++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/DataCollection/DataCollectionOptions.php b/src/DataCollection/DataCollectionOptions.php index b2af626b2..70632362a 100644 --- a/src/DataCollection/DataCollectionOptions.php +++ b/src/DataCollection/DataCollectionOptions.php @@ -21,10 +21,12 @@ * frame_context_lines: int * } * - * @implements \ArrayAccess< + * @phpstan-implements \ArrayAccess< * key-of, * value-of * > + * + * @mago-ignore analysis:missing-template-parameter */ final class DataCollectionOptions implements \ArrayAccess { @@ -223,11 +225,17 @@ public function offsetExists($offset): bool } /** - * @template TKey of key-of + * @phpstan-template TKey of key-of + * + * @param mixed $offset + * + * @return mixed * - * @param TKey $offset + * @phpstan-param TKey $offset + * @phpstan-return ResolvedDataCollectionOptions[TKey] * - * @return ResolvedDataCollectionOptions[TKey] + * @mago-ignore analysis:incompatible-parameter-type + * @mago-ignore analysis:invalid-return-statement */ #[\ReturnTypeWillChange] public function offsetGet($offset) diff --git a/src/OptionsResolver.php b/src/OptionsResolver.php index 6dad26e3b..465a51d2b 100644 --- a/src/OptionsResolver.php +++ b/src/OptionsResolver.php @@ -225,8 +225,10 @@ private function applyOptions( continue; } + /** @mago-ignore analysis:mixed-assignment */ $base = $resolved[$option] ?? null; if (!\is_array($base)) { + /** @mago-ignore analysis:mixed-assignment */ $base = $default; } From 71a1512cae3b0796e476ef5b0e40e580a0721a02 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 16 Jul 2026 14:09:57 +0200 Subject: [PATCH 03/10] mago --- src/DataCollection/DataCollectionOptions.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/DataCollection/DataCollectionOptions.php b/src/DataCollection/DataCollectionOptions.php index 70632362a..3902ec614 100644 --- a/src/DataCollection/DataCollectionOptions.php +++ b/src/DataCollection/DataCollectionOptions.php @@ -229,9 +229,10 @@ public function offsetExists($offset): bool * * @param mixed $offset * + * @phpstan-param TKey $offset + * * @return mixed * - * @phpstan-param TKey $offset * @phpstan-return ResolvedDataCollectionOptions[TKey] * * @mago-ignore analysis:incompatible-parameter-type From 3686aef3feb4b9c49b4d9ad21e2172a8c9929f98 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 23 Jul 2026 18:09:04 +0200 Subject: [PATCH 04/10] 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); + } +} From 763ca041bbbde9a6d515f7c1f195ae6692b54a82 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 30 Jul 2026 15:54:52 -0400 Subject: [PATCH 05/10] handle off --- ...ataScrubber.php => KeyValueDataFilter.php} | 57 +++--- .../DataCollection/KeyValueDataFilterTest.php | 186 +++++++++++++++++ .../SensitiveDataScrubberTest.php | 192 ------------------ 3 files changed, 218 insertions(+), 217 deletions(-) rename src/DataCollection/{SensitiveDataScrubber.php => KeyValueDataFilter.php} (69%) create mode 100644 tests/DataCollection/KeyValueDataFilterTest.php delete mode 100644 tests/DataCollection/SensitiveDataScrubberTest.php diff --git a/src/DataCollection/SensitiveDataScrubber.php b/src/DataCollection/KeyValueDataFilter.php similarity index 69% rename from src/DataCollection/SensitiveDataScrubber.php rename to src/DataCollection/KeyValueDataFilter.php index 2c387e69e..c90c6d5ab 100644 --- a/src/DataCollection/SensitiveDataScrubber.php +++ b/src/DataCollection/KeyValueDataFilter.php @@ -9,7 +9,7 @@ * * @phpstan-type KeyValueCollectionBehavior array{mode: 'off'|'denyList'|'allowList', terms: string[]} */ -final class SensitiveDataScrubber +final class KeyValueDataFilter { private const SENSITIVE_DATA_DENYLIST = [ 'auth', @@ -32,7 +32,7 @@ final class SensitiveDataScrubber ]; /** - * cookie headers that we always want to redact. + * Cookie headers that must always be filtered when headers are collected. */ private const SENSITIVE_HEADERS = [ 'cookie', @@ -44,9 +44,6 @@ final class SensitiveDataScrubber */ private static $sensitiveDataDenyListRegex; - /** - * This class contains only static methods and should not be instantiated. - */ private function __construct() { } @@ -56,25 +53,29 @@ private function __construct() * * @phpstan-param KeyValueCollectionBehavior $behavior * - * @return array + * @return array|null Returns null when collection is off */ - public static function scrubHeaders(array $headers, array $behavior): array + public static function filterHeaders(array $headers, array $behavior): ?array { - $scrubbed = []; + if ($behavior['mode'] === 'off') { + return null; + } + + $filtered = []; foreach ($headers as $name => $values) { $name = (string) $name; - if (\in_array(strtolower($name), self::SENSITIVE_HEADERS, true) || self::shouldScrubValue($name, $behavior)) { + if (\in_array(strtolower($name), self::SENSITIVE_HEADERS, true) || self::shouldFilterValue($name, $behavior)) { foreach ($values as $headerLine => $headerValue) { $values[$headerLine] = '[Filtered]'; } } - $scrubbed[$name] = $values; + $filtered[$name] = $values; } - return $scrubbed; + return $filtered; } /** @@ -82,26 +83,34 @@ public static function scrubHeaders(array $headers, array $behavior): array * * @phpstan-param KeyValueCollectionBehavior $behavior * - * @return array + * @return array|null Returns null when collection is off */ - public static function scrubKeyValueData(array $data, array $behavior): array + public static function filterKeyValueData(array $data, array $behavior): ?array { - $scrubbed = []; + if ($behavior['mode'] === 'off') { + return null; + } + + $filtered = []; /** @mago-ignore analysis:mixed-assignment */ foreach ($data as $key => $value) { $key = (string) $key; - $scrubbed[$key] = self::shouldScrubValue($key, $behavior) ? '[Filtered]' : $value; + $filtered[$key] = self::shouldFilterValue($key, $behavior) ? '[Filtered]' : $value; } - return $scrubbed; + return $filtered; } /** * @phpstan-param KeyValueCollectionBehavior $behavior */ - public static function scrubQueryString(string $queryString, array $behavior): string + public static function filterQueryString(string $queryString, array $behavior): ?string { + if ($behavior['mode'] === 'off') { + return null; + } + $parts = explode('&', $queryString); foreach ($parts as $index => $part) { @@ -109,7 +118,7 @@ public static function scrubQueryString(string $queryString, array $behavior): s $encodedKey = $separatorPosition === false ? $part : substr($part, 0, $separatorPosition); $key = urldecode($encodedKey); - if (self::shouldScrubValue($key, $behavior)) { + if (self::shouldFilterValue($key, $behavior)) { $parts[$index] = $encodedKey . '=[Filtered]'; } } @@ -120,17 +129,17 @@ public static function scrubQueryString(string $queryString, array $behavior): s /** * @phpstan-param KeyValueCollectionBehavior $behavior */ - private static function shouldScrubValue(string $key, array $behavior): bool + private static function shouldFilterValue(string $key, array $behavior): bool { if (self::matchesMandatoryDenyList($key)) { return true; } if ($behavior['mode'] === 'allowList') { - return !self::matchesAnyTerm($key, $behavior['terms'], false); + return !self::matchesAnyTerm($key, $behavior['terms']); } - return $behavior['terms'] !== [] && self::matchesAnyTerm($key, $behavior['terms'], true); + return self::matchesAnyTerm($key, $behavior['terms']); } private static function matchesMandatoryDenyList(string $key): bool @@ -147,14 +156,12 @@ private static function matchesMandatoryDenyList(string $key): bool /** * @param string[] $terms */ - private static function matchesAnyTerm(string $key, array $terms, bool $partial): bool + private static function matchesAnyTerm(string $key, array $terms): bool { $key = strtolower($key); foreach ($terms as $term) { - $term = strtolower($term); - - if (($partial && strpos($key, $term) !== false) || (!$partial && $key === $term)) { + if (strpos($key, strtolower($term)) !== false) { return true; } } diff --git a/tests/DataCollection/KeyValueDataFilterTest.php b/tests/DataCollection/KeyValueDataFilterTest.php new file mode 100644 index 000000000..2662102cb --- /dev/null +++ b/tests/DataCollection/KeyValueDataFilterTest.php @@ -0,0 +1,186 @@ + 'off', 'terms' => ['public']]; + + $this->assertNull(KeyValueDataFilter::filterKeyValueData([ + 'authorization' => 'secret', + 'public' => 'visible', + ], $behavior)); + } + + public function testFilterKeyValueDataAppliesMandatoryDenyList(): void + { + $behavior = ['mode' => 'denyList', 'terms' => []]; + + $filtered = KeyValueDataFilter::filterKeyValueData([ + 'AUTHORIZATION' => 'secret', + 'public' => 'visible', + ], $behavior); + + $this->assertSame([ + 'AUTHORIZATION' => '[Filtered]', + 'public' => 'visible', + ], $filtered); + } + + public function testFilterKeyValueDataCombinesMandatoryAndCustomDenyListTerms(): void + { + $behavior = ['mode' => 'denyList', 'terms' => ['custom']]; + + $filtered = KeyValueDataFilter::filterKeyValueData([ + 'authorization' => 'secret', + 'custom-field' => 'private', + 'public' => 'visible', + ], $behavior); + + $this->assertSame([ + 'authorization' => '[Filtered]', + 'custom-field' => '[Filtered]', + 'public' => 'visible', + ], $filtered); + } + + public function testFilterKeyValueDataAppliesAllowList(): void + { + $behavior = ['mode' => 'allowList', 'terms' => ['theme']]; + + $filtered = KeyValueDataFilter::filterKeyValueData([ + 'preferred-theme' => 'dark', + 'tracking_id' => '12345', + ], $behavior); + + $this->assertSame([ + 'preferred-theme' => 'dark', + 'tracking_id' => '[Filtered]', + ], $filtered); + } + + public function testFilterKeyValueDataAllowListCannotOverrideMandatoryDenyList(): void + { + $behavior = ['mode' => 'allowList', 'terms' => ['authorization']]; + + $filtered = KeyValueDataFilter::filterKeyValueData([ + 'authorization' => 'secret', + ], $behavior); + + $this->assertSame(['authorization' => '[Filtered]'], $filtered); + } + + public function testFilterHeadersReturnsNullWhenCollectionIsOff(): void + { + $behavior = ['mode' => 'off', 'terms' => ['x-request-id']]; + + $this->assertNull(KeyValueDataFilter::filterHeaders([ + 'Authorization' => ['secret'], + 'X-Request-Id' => ['request-id'], + ], $behavior)); + } + + public function testFilterHeadersAppliesDenyListToEveryHeaderLine(): void + { + $behavior = ['mode' => 'denyList', 'terms' => []]; + + $filtered = KeyValueDataFilter::filterHeaders([ + 'X-Api-Key' => ['first', 'second'], + 'X-Request-Id' => ['request-id'], + ], $behavior); + + $this->assertSame([ + 'X-Api-Key' => ['[Filtered]', '[Filtered]'], + 'X-Request-Id' => ['request-id'], + ], $filtered); + } + + public function testFilterHeadersAlwaysFiltersCookieHeaders(): void + { + $behavior = ['mode' => 'allowList', 'terms' => ['cookie', 'set-cookie', 'x-request-id']]; + + $filtered = KeyValueDataFilter::filterHeaders([ + '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'], + ], $filtered); + } + + public function testFilterHeadersAppliesExtendedDenyTerms(): 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, KeyValueDataFilter::filterHeaders($headers, $defaultBehavior)); + $this->assertSame([ + 'X-Forwarded-For' => ['[Filtered]'], + 'X-Real-IP' => ['[Filtered]'], + ], KeyValueDataFilter::filterHeaders($headers, $extendedBehavior)); + } + + public function testFilterHeadersAppliesAllowList(): void + { + $behavior = ['mode' => 'allowList', 'terms' => ['request-id']]; + + $filtered = KeyValueDataFilter::filterHeaders([ + 'X-Request-Id' => ['request-id'], + 'Host' => ['example.com'], + ], $behavior); + + $this->assertSame([ + 'X-Request-Id' => ['request-id'], + 'Host' => ['[Filtered]'], + ], $filtered); + } + + public function testFilterQueryStringReturnsNullWhenCollectionIsOff(): void + { + $behavior = ['mode' => 'off', 'terms' => ['page']]; + + $this->assertNull(KeyValueDataFilter::filterQueryString('token=secret&page=1', $behavior)); + } + + public function testFilterQueryStringAppliesMandatoryAndCustomDenyListTerms(): void + { + $behavior = ['mode' => 'denyList', 'terms' => ['page']]; + + $filtered = KeyValueDataFilter::filterQueryString('token=secret&page=1&flag', $behavior); + + $this->assertSame('token=[Filtered]&page=[Filtered]&flag', $filtered); + } + + public function testFilterQueryStringDecodesKeysBeforeMatching(): void + { + $behavior = ['mode' => 'denyList', 'terms' => []]; + + $filtered = KeyValueDataFilter::filterQueryString('api%5Ftoken=secret&page=1', $behavior); + + $this->assertSame('api%5Ftoken=[Filtered]&page=1', $filtered); + } + + public function testFilterQueryStringDoesNotTreatCookieNamesAsCookieHeaders(): void + { + $behavior = ['mode' => 'denyList', 'terms' => []]; + + $filtered = KeyValueDataFilter::filterQueryString('cookie=foo&set-cookie=bar', $behavior); + + $this->assertSame('cookie=foo&set-cookie=bar', $filtered); + } +} diff --git a/tests/DataCollection/SensitiveDataScrubberTest.php b/tests/DataCollection/SensitiveDataScrubberTest.php deleted file mode 100644 index 78c6d94e5..000000000 --- a/tests/DataCollection/SensitiveDataScrubberTest.php +++ /dev/null @@ -1,192 +0,0 @@ - '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); - } -} From 93324e5bc2bfd421d76dfa05e7f7c9f6eca59521 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Tue, 25 Aug 2026 15:35:42 +0200 Subject: [PATCH 06/10] feat(pii): update config to newest spec --- src/DataCollection/DataCollectionOptions.php | 98 ++++++++++++++++--- src/DataCollection/KeyValueDataFilter.php | 2 +- src/functions.php | 6 +- .../DataCollectionOptionsTest.php | 57 ++++++++++- .../DataCollection/KeyValueDataFilterTest.php | 21 +++- tests/OptionsTest.php | 11 +++ 6 files changed, 171 insertions(+), 24 deletions(-) diff --git a/src/DataCollection/DataCollectionOptions.php b/src/DataCollection/DataCollectionOptions.php index 3902ec614..62f60f4b3 100644 --- a/src/DataCollection/DataCollectionOptions.php +++ b/src/DataCollection/DataCollectionOptions.php @@ -15,9 +15,11 @@ * cookies: KeyValueCollectionBehavior, * http_headers: HttpHeaders, * http_bodies: string[], - * query_params: KeyValueCollectionBehavior, + * url_query_params: KeyValueCollectionBehavior, * gen_ai: GenAi, - * stack_frame_variables: bool, + * database_query_data: bool, + * queues: bool, + * stack_frame_variables: KeyValueCollectionBehavior, * frame_context_lines: int * } * @@ -59,11 +61,13 @@ final class DataCollectionOptions implements \ArrayAccess 'response' => self::COLLECTION_DEFAULT, ], 'http_bodies' => self::HTTP_BODY_TYPES, - 'query_params' => self::COLLECTION_DEFAULT, + 'url_query_params' => self::COLLECTION_DEFAULT, 'gen_ai' => [ 'inputs' => true, 'outputs' => true, ], + 'database_query_data' => true, + 'queues' => true, 'stack_frame_variables' => true, 'frame_context_lines' => 5, ]; @@ -163,19 +167,19 @@ public function setHttpBodies(array $httpBodies): self /** * @phpstan-return KeyValueCollectionBehavior */ - public function getQueryParams(): array + public function getUrlQueryParams(): array { - return $this->options['query_params']; + return $this->options['url_query_params']; } /** - * @param array $queryParams + * @param array $urlQueryParams * - * @phpstan-param array{mode?: 'off'|'denyList'|'allowList', terms?: string[]} $queryParams + * @phpstan-param array{mode?: 'off'|'denyList'|'allowList', terms?: string[]} $urlQueryParams */ - public function setQueryParams(array $queryParams): self + public function setUrlQueryParams(array $urlQueryParams): self { - return $this->updateOptions(['query_params' => $queryParams]); + return $this->updateOptions(['url_query_params' => $urlQueryParams]); } /** @@ -196,12 +200,45 @@ public function setGenAi(array $genAi): self return $this->updateOptions(['gen_ai' => $genAi]); } - public function shouldCollectStackFrameVariables(): bool + public function shouldCollectDatabaseQueryData(): bool + { + return $this->options['database_query_data']; + } + + public function setDatabaseQueryData(bool $databaseQueryData): self + { + return $this->updateOptions(['database_query_data' => $databaseQueryData]); + } + + public function shouldCollectQueues(): bool + { + return $this->options['queues']; + } + + public function setQueues(bool $queues): self + { + return $this->updateOptions(['queues' => $queues]); + } + + /** + * @phpstan-return KeyValueCollectionBehavior + */ + public function getStackFrameVariables(): array { return $this->options['stack_frame_variables']; } - public function setStackFrameVariables(bool $stackFrameVariables): self + public function shouldCollectStackFrameVariables(): bool + { + return $this->options['stack_frame_variables']['mode'] !== 'off'; + } + + /** + * @param bool|array $stackFrameVariables + * + * @phpstan-param bool|array{mode?: 'off'|'denyList'|'allowList', terms?: string[]} $stackFrameVariables + */ + public function setStackFrameVariables($stackFrameVariables): self { return $this->updateOptions(['stack_frame_variables' => $stackFrameVariables]); } @@ -290,19 +327,24 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('http_headers.response.mode', 'string'); $resolver->setAllowedTypes('http_headers.response.terms', 'string[]'); $resolver->setAllowedTypes('http_bodies', 'string[]'); - $resolver->setAllowedTypes('query_params', 'array'); - $resolver->setAllowedTypes('query_params.mode', 'string'); - $resolver->setAllowedTypes('query_params.terms', 'string[]'); + $resolver->setAllowedTypes('url_query_params', 'array'); + $resolver->setAllowedTypes('url_query_params.mode', 'string'); + $resolver->setAllowedTypes('url_query_params.terms', 'string[]'); $resolver->setAllowedTypes('gen_ai', 'array'); $resolver->setAllowedTypes('gen_ai.inputs', 'bool'); $resolver->setAllowedTypes('gen_ai.outputs', 'bool'); - $resolver->setAllowedTypes('stack_frame_variables', 'bool'); + $resolver->setAllowedTypes('database_query_data', 'bool'); + $resolver->setAllowedTypes('queues', 'bool'); + $resolver->setAllowedTypes('stack_frame_variables', ['bool', 'array']); + $resolver->setAllowedTypes('stack_frame_variables.mode', 'string'); + $resolver->setAllowedTypes('stack_frame_variables.terms', 'string[]'); $resolver->setAllowedTypes('frame_context_lines', 'int'); $resolver->setAllowedValues('cookies.mode', self::COLLECTION_MODES); $resolver->setAllowedValues('http_headers.request.mode', self::COLLECTION_MODES); $resolver->setAllowedValues('http_headers.response.mode', self::COLLECTION_MODES); - $resolver->setAllowedValues('query_params.mode', self::COLLECTION_MODES); + $resolver->setAllowedValues('url_query_params.mode', self::COLLECTION_MODES); + $resolver->setAllowedValues('stack_frame_variables.mode', self::COLLECTION_MODES); $resolver->setAllowedValues('http_bodies', static function (array $value): bool { return array_diff($value, self::HTTP_BODY_TYPES) === []; }); @@ -320,9 +362,33 @@ private function configureOptions(OptionsResolver $resolver): void return $value; }); + $resolver->setNormalizer( + 'stack_frame_variables', + \Closure::fromCallable([$this, 'normalizeStackFrameVariables']) + ); $resolver->setDefaults(self::DEFAULTS); } + /** + * @param bool|array $value + * + * @phpstan-param bool|array{mode?: 'off'|'denyList'|'allowList', terms?: string[]} $value + * + * @phpstan-return array{mode?: 'off'|'denyList'|'allowList', terms?: string[]} + */ + private function normalizeStackFrameVariables($value): array + { + if ($value === true) { + return self::COLLECTION_DEFAULT; + } + + if ($value === false) { + return ['mode' => 'off', 'terms' => []]; + } + + return $value; + } + /** * @param array $override */ diff --git a/src/DataCollection/KeyValueDataFilter.php b/src/DataCollection/KeyValueDataFilter.php index c90c6d5ab..e29efe9d0 100644 --- a/src/DataCollection/KeyValueDataFilter.php +++ b/src/DataCollection/KeyValueDataFilter.php @@ -118,7 +118,7 @@ public static function filterQueryString(string $queryString, array $behavior): $encodedKey = $separatorPosition === false ? $part : substr($part, 0, $separatorPosition); $key = urldecode($encodedKey); - if (self::shouldFilterValue($key, $behavior)) { + if ($separatorPosition !== false && self::shouldFilterValue($key, $behavior)) { $parts[$index] = $encodedKey . '=[Filtered]'; } } diff --git a/src/functions.php b/src/functions.php index e5e0e58a7..66e76cffb 100644 --- a/src/functions.php +++ b/src/functions.php @@ -39,9 +39,11 @@ * response?: array{mode?: "off"|"denyList"|"allowList", terms?: array}, * }, * http_bodies?: array<"incomingRequest"|"outgoingRequest"|"incomingResponse"|"outgoingResponse">, - * query_params?: array{mode?: "off"|"denyList"|"allowList", terms?: array}, + * url_query_params?: array{mode?: "off"|"denyList"|"allowList", terms?: array}, * gen_ai?: array{inputs?: bool, outputs?: bool}, - * stack_frame_variables?: bool, + * database_query_data?: bool, + * queues?: bool, + * stack_frame_variables?: bool|array{mode?: "off"|"denyList"|"allowList", terms?: array}, * frame_context_lines?: int, * }, * default_integrations?: bool, diff --git a/tests/DataCollection/DataCollectionOptionsTest.php b/tests/DataCollection/DataCollectionOptionsTest.php index 1df694aa2..1ff747466 100644 --- a/tests/DataCollection/DataCollectionOptionsTest.php +++ b/tests/DataCollection/DataCollectionOptionsTest.php @@ -21,8 +21,11 @@ public function testDefaults(): void 'response' => $collectionDefault, ], $options->getHttpHeaders()); $this->assertSame(DataCollectionOptions::HTTP_BODY_TYPES, $options->getHttpBodies()); - $this->assertSame($collectionDefault, $options->getQueryParams()); + $this->assertSame($collectionDefault, $options->getUrlQueryParams()); $this->assertSame(['inputs' => true, 'outputs' => true], $options->getGenAi()); + $this->assertTrue($options->shouldCollectDatabaseQueryData()); + $this->assertTrue($options->shouldCollectQueues()); + $this->assertSame($collectionDefault, $options->getStackFrameVariables()); $this->assertTrue($options->shouldCollectStackFrameVariables()); $this->assertSame(5, $options->getFrameContextLines()); } @@ -59,26 +62,68 @@ public function testNullHttpBodiesUsesDefault(): void $this->assertSame(DataCollectionOptions::HTTP_BODY_TYPES, $options->getHttpBodies()); } + public function testStackFrameVariablesSupportsBooleanAndKeyValueCollectionBehavior(): void + { + $options = new DataCollectionOptions([ + 'stack_frame_variables' => [ + 'mode' => 'allowList', + 'terms' => ['request_id'], + ], + ]); + + $this->assertSame([ + 'mode' => 'allowList', + 'terms' => ['request_id'], + ], $options->getStackFrameVariables()); + $this->assertTrue($options->shouldCollectStackFrameVariables()); + + $options->setStackFrameVariables(['terms' => ['trace_id']]); + $this->assertSame([ + 'mode' => 'allowList', + 'terms' => ['trace_id'], + ], $options->getStackFrameVariables()); + + $options->setStackFrameVariables(false); + $this->assertSame(['mode' => 'off', 'terms' => []], $options->getStackFrameVariables()); + $this->assertFalse($options->shouldCollectStackFrameVariables()); + + $options->setStackFrameVariables(true); + $this->assertSame(['mode' => 'denyList', 'terms' => []], $options->getStackFrameVariables()); + $this->assertTrue($options->shouldCollectStackFrameVariables()); + + $options->setStackFrameVariables(['mode' => 'off']); + $this->assertSame(['mode' => 'off', 'terms' => []], $options->getStackFrameVariables()); + $this->assertFalse($options->shouldCollectStackFrameVariables()); + } + public function testInvalidValuesUseDefaultsAndSettersKeepCurrentValues(): void { $options = new DataCollectionOptions([ 'cookies' => ['mode' => 'invalid', 'terms' => [42]], 'http_bodies' => ['invalid'], 'gen_ai' => ['inputs' => 'invalid'], + 'database_query_data' => 'invalid', + 'queues' => 'invalid', + 'stack_frame_variables' => ['mode' => 'invalid'], 'frame_context_lines' => -1, ]); $this->assertSame(['mode' => 'denyList', 'terms' => []], $options->getCookies()); $this->assertSame(DataCollectionOptions::HTTP_BODY_TYPES, $options->getHttpBodies()); $this->assertSame(['inputs' => true, 'outputs' => true], $options->getGenAi()); + $this->assertTrue($options->shouldCollectDatabaseQueryData()); + $this->assertTrue($options->shouldCollectQueues()); + $this->assertSame(['mode' => 'denyList', 'terms' => []], $options->getStackFrameVariables()); $this->assertSame(5, $options->getFrameContextLines()); $options->setCookies(['mode' => 'allowList'])->setCookies(['mode' => 'invalid']); $options->setHttpBodies(['incomingRequest'])->setHttpBodies(['invalid']); + $options->setStackFrameVariables(['mode' => 'allowList'])->setStackFrameVariables(['terms' => [42]]); $options->setFrameContextLines(2)->setFrameContextLines(-1); $this->assertSame('allowList', $options->getCookies()['mode']); $this->assertSame(['incomingRequest'], $options->getHttpBodies()); + $this->assertSame(['mode' => 'allowList', 'terms' => []], $options->getStackFrameVariables()); $this->assertSame(2, $options->getFrameContextLines()); } @@ -125,11 +170,19 @@ public function testArrayAccessUnsetRestoresDefault(): void $options = new DataCollectionOptions([ 'user_info' => false, 'http_bodies' => [], + 'stack_frame_variables' => false, ]); - unset($options['user_info'], $options['http_bodies'], $options['unknown'], $options[0]); + unset( + $options['user_info'], + $options['http_bodies'], + $options['stack_frame_variables'], + $options['unknown'], + $options[0] + ); $this->assertTrue($options['user_info']); $this->assertSame(DataCollectionOptions::HTTP_BODY_TYPES, $options['http_bodies']); + $this->assertSame(['mode' => 'denyList', 'terms' => []], $options['stack_frame_variables']); } } diff --git a/tests/DataCollection/KeyValueDataFilterTest.php b/tests/DataCollection/KeyValueDataFilterTest.php index 2662102cb..bf2752873 100644 --- a/tests/DataCollection/KeyValueDataFilterTest.php +++ b/tests/DataCollection/KeyValueDataFilterTest.php @@ -166,13 +166,28 @@ public function testFilterQueryStringAppliesMandatoryAndCustomDenyListTerms(): v $this->assertSame('token=[Filtered]&page=[Filtered]&flag', $filtered); } - public function testFilterQueryStringDecodesKeysBeforeMatching(): void + public function testFilterQueryStringDecodesKeysBeforeMatchingAndPreservesEncoding(): void { $behavior = ['mode' => 'denyList', 'terms' => []]; - $filtered = KeyValueDataFilter::filterQueryString('api%5Ftoken=secret&page=1', $behavior); + $filtered = KeyValueDataFilter::filterQueryString( + 'api%5Ftoken=secret&q=a%20b%26c&encoded%20field=encoded%2Bvalue', + $behavior + ); - $this->assertSame('api%5Ftoken=[Filtered]&page=1', $filtered); + $this->assertSame( + 'api%5Ftoken=[Filtered]&q=a%20b%26c&encoded%20field=encoded%2Bvalue', + $filtered + ); + } + + public function testFilterQueryStringPreservesValuelessParameters(): void + { + $behavior = ['mode' => 'denyList', 'terms' => []]; + + $filtered = KeyValueDataFilter::filterQueryString('token&token=&flag', $behavior); + + $this->assertSame('token&token=[Filtered]&flag', $filtered); } public function testFilterQueryStringDoesNotTreatCookieNamesAsCookieHeaders(): void diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index fada65cbe..b886b0df0 100644 --- a/tests/OptionsTest.php +++ b/tests/OptionsTest.php @@ -689,14 +689,25 @@ public function testDataCollectionOptionNormalizesNestedArray(): void 'http_headers' => [ 'request' => ['mode' => 'off'], ], + 'url_query_params' => ['terms' => ['private']], 'gen_ai' => ['outputs' => false], + 'database_query_data' => false, + 'queues' => false, + 'stack_frame_variables' => ['mode' => 'allowList', 'terms' => ['request_id']], ], ]))->getDataCollection(); $this->assertFalse($dataCollection->shouldCollectUserInfo()); $this->assertSame('off', $dataCollection->getHttpHeaders()['request']['mode']); $this->assertSame('denyList', $dataCollection->getHttpHeaders()['response']['mode']); + $this->assertSame(['mode' => 'denyList', 'terms' => ['private']], $dataCollection->getUrlQueryParams()); $this->assertSame(['inputs' => true, 'outputs' => false], $dataCollection->getGenAi()); + $this->assertFalse($dataCollection->shouldCollectDatabaseQueryData()); + $this->assertFalse($dataCollection->shouldCollectQueues()); + $this->assertSame([ + 'mode' => 'allowList', + 'terms' => ['request_id'], + ], $dataCollection->getStackFrameVariables()); } public function testDataCollectionOptionPreservesObjectIdentityAndCanBeUpdatedThroughGetter(): void From 7109a7341068955c732622b530fdbf1d4e3922bb Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Wed, 26 Aug 2026 14:33:21 +0200 Subject: [PATCH 07/10] feat(pii): collect request information --- src/DataCollection/KeyValueDataFilter.php | 9 +- src/DataCollection/RequestDataCollector.php | 177 +++++++++++++ src/Integration/RequestIntegration.php | 95 ++++--- src/Options.php | 14 +- src/functions.php | 2 +- .../DataCollection/KeyValueDataFilterTest.php | 19 ++ .../RequestDataCollectorTest.php | 242 ++++++++++++++++++ tests/Integration/RequestIntegrationTest.php | 109 ++++++++ tests/OptionsTest.php | 10 +- 9 files changed, 611 insertions(+), 66 deletions(-) create mode 100644 src/DataCollection/RequestDataCollector.php create mode 100644 tests/DataCollection/RequestDataCollectorTest.php diff --git a/src/DataCollection/KeyValueDataFilter.php b/src/DataCollection/KeyValueDataFilter.php index e29efe9d0..0af97c5af 100644 --- a/src/DataCollection/KeyValueDataFilter.php +++ b/src/DataCollection/KeyValueDataFilter.php @@ -96,7 +96,14 @@ public static function filterKeyValueData(array $data, array $behavior): ?array /** @mago-ignore analysis:mixed-assignment */ foreach ($data as $key => $value) { $key = (string) $key; - $filtered[$key] = self::shouldFilterValue($key, $behavior) ? '[Filtered]' : $value; + + if (self::shouldFilterValue($key, $behavior)) { + $filtered[$key] = '[Filtered]'; + } elseif (\is_array($value)) { + $filtered[$key] = self::filterKeyValueData($value, $behavior); + } else { + $filtered[$key] = $value; + } } return $filtered; diff --git a/src/DataCollection/RequestDataCollector.php b/src/DataCollection/RequestDataCollector.php new file mode 100644 index 000000000..1a49e24a4 --- /dev/null +++ b/src/DataCollection/RequestDataCollector.php @@ -0,0 +1,177 @@ +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) { + return $queryString !== '' ? $queryString : null; + } + + if ($queryString === '') { + return null; + } + + return KeyValueDataFilter::filterQueryString( + $queryString, + $this->dataCollection->getUrlQueryParams() + ); + } + + /** + * @param array $cookies + * + * @return array|null + */ + public function collectCookies(array $cookies): ?array + { + if ($this->dataCollection === null) { + return $this->sendDefaultPii ? $cookies : null; + } + + return KeyValueDataFilter::filterKeyValueData( + $cookies, + $this->dataCollection->getCookies() + ); + } + + /** + * @param array $headers + * + * @return array|null + */ + public function collectHeaders(array $headers): ?array + { + if ($this->dataCollection === null) { + return $this->sendDefaultPii ? $headers : $this->sanitizeLegacyHeaders($headers); + } + + return KeyValueDataFilter::filterHeaders( + $headers, + $this->dataCollection->getHttpHeaders()['request'] + ); + } + + public function shouldCollectRequestBody(): bool + { + if ($this->dataCollection === null) { + // Legacy request body collection is controlled by max_request_body_size. + return true; + } + + return \in_array('incomingRequest', $this->dataCollection->getHttpBodies(), true); + } + + /** + * @param mixed $body + * + * @return mixed + */ + public function collectRequestBody($body) + { + if (empty($body) || !$this->shouldCollectRequestBody()) { + return null; + } + + if ($this->dataCollection === null) { + return $body; + } + + if (!\is_array($body)) { + return '[Filtered]'; + } + + return KeyValueDataFilter::filterKeyValueData($body, [ + 'mode' => 'denyList', + 'terms' => [], + ]); + } + + /** + * @param array $headers + * + * @return array + */ + private function sanitizeLegacyHeaders(array $headers): array + { + $sanitized = []; + + foreach ($headers as $name => $values) { + $name = (string) $name; + + if (\in_array(strtolower($name), $this->piiSanitizeHeaders, true)) { + foreach ($values as $headerLine => $headerValue) { + $values[$headerLine] = '[Filtered]'; + } + } + + $sanitized[$name] = $values; + } + + return $sanitized; + } +} diff --git a/src/Integration/RequestIntegration.php b/src/Integration/RequestIntegration.php index 8f4949bcd..befd68e64 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,72 @@ 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); } /** @@ -309,6 +300,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 66e76cffb..38d7f451d 100644 --- a/src/functions.php +++ b/src/functions.php @@ -45,7 +45,7 @@ * queues?: bool, * stack_frame_variables?: bool|array{mode?: "off"|"denyList"|"allowList", terms?: array}, * frame_context_lines?: int, - * }, + * }|null, * default_integrations?: bool, * dsn?: string|bool|Dsn|null, * enable_logs?: bool, diff --git a/tests/DataCollection/KeyValueDataFilterTest.php b/tests/DataCollection/KeyValueDataFilterTest.php index bf2752873..4352e28fa 100644 --- a/tests/DataCollection/KeyValueDataFilterTest.php +++ b/tests/DataCollection/KeyValueDataFilterTest.php @@ -77,6 +77,25 @@ public function testFilterKeyValueDataAllowListCannotOverrideMandatoryDenyList() $this->assertSame(['authorization' => '[Filtered]'], $filtered); } + public function testFilterKeyValueDataFiltersNestedData(): void + { + $behavior = ['mode' => 'denyList', 'terms' => []]; + + $filtered = KeyValueDataFilter::filterKeyValueData([ + 'user' => [ + 'password' => 'secret', + 'name' => 'alice', + ], + ], $behavior); + + $this->assertSame([ + 'user' => [ + 'password' => '[Filtered]', + 'name' => 'alice', + ], + ], $filtered); + } + public function testFilterHeadersReturnsNullWhenCollectionIsOff(): void { $behavior = ['mode' => 'off', 'terms' => ['x-request-id']]; diff --git a/tests/DataCollection/RequestDataCollectorTest.php b/tests/DataCollection/RequestDataCollectorTest.php new file mode 100644 index 000000000..d17ec4bc2 --- /dev/null +++ b/tests/DataCollection/RequestDataCollectorTest.php @@ -0,0 +1,242 @@ +assertFalse($this->legacyCollector(false)->usesDataCollection()); + $this->assertFalse($this->legacyCollector(true)->usesDataCollection()); + $this->assertTrue($this->collector([])->usesDataCollection()); + } + + public function testShouldCollectUserInfoFollowsLegacySendDefaultPii(): void + { + $this->assertFalse($this->legacyCollector(false)->shouldCollectUserInfo()); + $this->assertTrue($this->legacyCollector(true)->shouldCollectUserInfo()); + } + + public function testShouldCollectUserInfoUsesDataCollectionWhenConfigured(): void + { + $enabled = new RequestDataCollector(new DataCollectionOptions(['user_info' => true]), false); + $disabled = new RequestDataCollector(new DataCollectionOptions(['user_info' => false]), true); + + $this->assertTrue($enabled->shouldCollectUserInfo()); + $this->assertFalse($disabled->shouldCollectUserInfo()); + } + + public function testCollectQueryStringPreservesLegacyBehavior(): void + { + $queryString = 'api%5Ftoken=secret&q=a%20b%26c'; + + $this->assertSame($queryString, $this->legacyCollector(false)->collectQueryString($queryString)); + $this->assertSame($queryString, $this->legacyCollector(true)->collectQueryString($queryString)); + $this->assertNull($this->legacyCollector(false)->collectQueryString('')); + } + + public function testCollectQueryStringUsesUrlQueryParamsBehavior(): void + { + $collector = $this->collector([ + 'url_query_params' => [ + 'mode' => 'denyList', + 'terms' => ['page'], + ], + ]); + + $this->assertSame( + 'api%5Ftoken=[Filtered]&page=[Filtered]&q=a%20b%26c', + $collector->collectQueryString('api%5Ftoken=secret&page=5&q=a%20b%26c') + ); + } + + public function testCollectQueryStringReturnsNullWhenDisabledOrEmpty(): void + { + $disabled = $this->collector(['url_query_params' => ['mode' => 'off']]); + + $this->assertNull($disabled->collectQueryString('page=5')); + $this->assertNull($this->collector([])->collectQueryString('')); + } + + public function testCollectCookiesPreservesLegacyBehavior(): void + { + $cookies = ['session_id' => 'secret', 'theme' => 'dark']; + + $this->assertSame($cookies, $this->legacyCollector(true)->collectCookies($cookies)); + $this->assertNull($this->legacyCollector(false)->collectCookies($cookies)); + } + + public function testCollectCookiesUsesConfiguredBehavior(): void + { + $collector = $this->collector([ + 'cookies' => [ + 'mode' => 'allowList', + 'terms' => ['theme'], + ], + ]); + + $this->assertSame([ + 'session_id' => '[Filtered]', + 'theme' => 'dark', + 'tracking_id' => '[Filtered]', + ], $collector->collectCookies([ + 'session_id' => 'secret', + 'theme' => 'dark', + 'tracking_id' => '12345', + ])); + } + + public function testCollectCookiesReturnsNullWhenDisabled(): void + { + $collector = $this->collector(['cookies' => ['mode' => 'off']]); + + $this->assertNull($collector->collectCookies(['theme' => 'dark'])); + } + + public function testCollectHeadersPreservesLegacyBehaviorWhenPiiIsEnabled(): void + { + $headers = ['Authorization' => ['secret']]; + + $this->assertSame($headers, $this->legacyCollector(true)->collectHeaders($headers)); + } + + public function testCollectHeadersSanitizesConfiguredLegacyHeadersWhenPiiIsDisabled(): void + { + $collector = $this->legacyCollector(false, ['authorization']); + + $this->assertSame([ + 'Authorization' => ['[Filtered]'], + 'X-Authorization-Token' => ['untouched'], + 'X-Request-Id' => ['request-id'], + ], $collector->collectHeaders([ + 'Authorization' => ['secret'], + 'X-Authorization-Token' => ['untouched'], + 'X-Request-Id' => ['request-id'], + ])); + } + + public function testCollectHeadersSupportsNumericNamesInLegacyMode(): void + { + $this->assertSame( + ['123' => ['test']], + $this->legacyCollector(false)->collectHeaders([123 => ['test']]) + ); + } + + public function testCollectHeadersUsesRequestHeaderBehavior(): void + { + $collector = $this->collector([ + 'http_headers' => [ + 'request' => [ + 'mode' => 'allowList', + 'terms' => ['x-request-id'], + ], + 'response' => ['mode' => 'off'], + ], + ]); + + $this->assertSame([ + 'Authorization' => ['[Filtered]'], + 'X-Request-Id' => ['request-id'], + 'Host' => ['[Filtered]'], + ], $collector->collectHeaders([ + 'Authorization' => ['secret'], + 'X-Request-Id' => ['request-id'], + 'Host' => ['example.com'], + ])); + } + + public function testCollectHeadersReturnsNullWhenRequestHeadersAreDisabled(): void + { + $collector = $this->collector([ + 'http_headers' => [ + 'request' => ['mode' => 'off'], + 'response' => ['mode' => 'denyList'], + ], + ]); + + $this->assertNull($collector->collectHeaders(['X-Request-Id' => ['request-id']])); + } + + public function testShouldCollectRequestBodyPreservesLegacyBehavior(): void + { + $this->assertTrue($this->legacyCollector(false)->shouldCollectRequestBody()); + $this->assertTrue($this->legacyCollector(true)->shouldCollectRequestBody()); + } + + public function testShouldCollectRequestBodyUsesIncomingRequestBodyType(): void + { + $this->assertTrue($this->collector(['http_bodies' => ['incomingRequest']])->shouldCollectRequestBody()); + $this->assertFalse($this->collector(['http_bodies' => []])->shouldCollectRequestBody()); + $this->assertFalse($this->collector(['http_bodies' => ['outgoingRequest']])->shouldCollectRequestBody()); + } + + public function testCollectRequestBodyPreservesLegacyBehavior(): void + { + $body = ['password' => 'secret']; + + $this->assertSame($body, $this->legacyCollector(false)->collectRequestBody($body)); + $this->assertSame('raw body', $this->legacyCollector(true)->collectRequestBody('raw body')); + } + + public function testCollectRequestBodyFiltersStructuredSensitiveDataRecursively(): void + { + $collector = $this->collector(['http_bodies' => ['incomingRequest']]); + + $this->assertSame([ + 'password' => '[Filtered]', + 'user' => [ + 'api_token' => '[Filtered]', + 'name' => 'alice', + ], + ], $collector->collectRequestBody([ + 'password' => 'secret', + 'user' => [ + 'api_token' => 'token', + 'name' => 'alice', + ], + ])); + } + + public function testCollectRequestBodyFiltersRawData(): void + { + $collector = $this->collector(['http_bodies' => ['incomingRequest']]); + + $this->assertSame('[Filtered]', $collector->collectRequestBody('raw body')); + } + + public function testCollectRequestBodyReturnsNullWhenDisabledOrEmpty(): void + { + $disabled = $this->collector(['http_bodies' => []]); + $enabled = $this->collector(['http_bodies' => ['incomingRequest']]); + + $this->assertNull($disabled->collectRequestBody('raw body')); + $this->assertNull($enabled->collectRequestBody('')); + $this->assertNull($enabled->collectRequestBody([])); + $this->assertNull($enabled->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/Integration/RequestIntegrationTest.php b/tests/Integration/RequestIntegrationTest.php index e53a432ac..5acff6a45 100644 --- a/tests/Integration/RequestIntegrationTest.php +++ b/tests/Integration/RequestIntegrationTest.php @@ -490,6 +490,115 @@ public static function invokeDataProvider(): iterable null, ]; + yield 'data collection can disable all incoming request data' => [ + [ + 'data_collection' => [ + 'user_info' => false, + 'cookies' => ['mode' => 'off'], + 'http_headers' => ['request' => ['mode' => 'off']], + 'http_bodies' => [], + 'url_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', + ], + UserDataBag::createFromUserIdentifier('explicit-user'), + UserDataBag::createFromUserIdentifier('explicit-user'), + ]; + + yield 'data collection applies per-category filtering' => [ + [ + 'data_collection' => [ + 'user_info' => false, + 'cookies' => ['mode' => 'allowList', 'terms' => ['theme']], + 'http_headers' => ['request' => ['mode' => 'allowList', 'terms' => ['x-request-id']]], + 'http_bodies' => [], + 'url_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 defaults filter sensitive request data' => [ + [ + 'data_collection' => [], + 'max_request_body_size' => 'always', + ], + (new ServerRequest('POST', 'http://www.example.com/foo?api%5Ftoken=secret&q=a%20b%26c', [], null, '1.1', ['REMOTE_ADDR' => '127.0.0.1'])) + ->withCookieParams([ + 'session_id' => 'secret', + 'theme' => 'dark', + ]) + ->withHeader('Authorization', 'Bearer secret') + ->withHeader('Cookie', 'session_id=secret; theme=dark') + ->withHeader('X-Forwarded-For', '203.0.113.7') + ->withHeader('Content-Length', '100') + ->withParsedBody([ + 'password' => 'secret', + 'user' => [ + 'api_token' => 'secret', + 'name' => 'alice', + ], + ]), + [ + 'url' => 'http://www.example.com/foo?api%5Ftoken=%5BFiltered%5D&q=a%20b%26c', + 'method' => 'POST', + 'query_string' => 'api%5Ftoken=[Filtered]&q=a%20b%26c', + 'env' => [ + 'REMOTE_ADDR' => '127.0.0.1', + ], + 'cookies' => [ + 'session_id' => '[Filtered]', + 'theme' => 'dark', + ], + 'headers' => [ + 'Host' => ['www.example.com'], + 'Authorization' => ['[Filtered]'], + 'Cookie' => ['[Filtered]'], + 'X-Forwarded-For' => ['203.0.113.7'], + 'Content-Length' => ['100'], + ], + 'data' => [ + 'password' => '[Filtered]', + 'user' => [ + 'api_token' => '[Filtered]', + 'name' => 'alice', + ], + ], + ], + null, + UserDataBag::createFromUserIpAddress('127.0.0.1'), + ]; + yield [ [], (new ServerRequest('GET', 'http://www.example.com/foo')) diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index b886b0df0..11a3d3451 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, @@ -697,6 +694,7 @@ public function testDataCollectionOptionNormalizesNestedArray(): void ], ]))->getDataCollection(); + $this->assertInstanceOf(DataCollectionOptions::class, $dataCollection); $this->assertFalse($dataCollection->shouldCollectUserInfo()); $this->assertSame('off', $dataCollection->getHttpHeaders()['request']['mode']); $this->assertSame('denyList', $dataCollection->getHttpHeaders()['response']['mode']); @@ -716,7 +714,9 @@ public function testDataCollectionOptionPreservesObjectIdentityAndCanBeUpdatedTh $options = new Options(['data_collection' => $dataCollection]); $this->assertSame($dataCollection, $options->getDataCollection()); - $options->getDataCollection()->setFrameContextLines(0); + $resolvedDataCollection = $options->getDataCollection(); + $this->assertNotNull($resolvedDataCollection); + $resolvedDataCollection->setFrameContextLines(0); $this->assertSame(0, $dataCollection->getFrameContextLines()); } From fa17a2b631f1bb0d7a068c8cbbadd27b2e9983ce Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 27 Aug 2026 16:16:37 +0200 Subject: [PATCH 08/10] feat(pii): add data collection for stack frame variables --- src/FrameBuilder.php | 10 + .../FrameContextifierIntegration.php | 6 +- .../FrameContextifierIntegrationTest.php | 36 +++- tests/StacktraceBuilderTest.php | 189 ++++++++++++++++++ 4 files changed, 237 insertions(+), 4 deletions(-) diff --git a/src/FrameBuilder.php b/src/FrameBuilder.php index 99712435e..30636c1d3 100644 --- a/src/FrameBuilder.php +++ b/src/FrameBuilder.php @@ -4,6 +4,7 @@ namespace Sentry; +use Sentry\DataCollection\KeyValueDataFilter; use Sentry\Serializer\RepresentationSerializerInterface; use Sentry\Util\PrefixStripper; @@ -196,6 +197,15 @@ private function getFunctionArguments(array $backtraceFrame): array } } + $dataCollection = $this->options->getDataCollection(); + + if ($dataCollection !== null) { + $argumentValues = KeyValueDataFilter::filterKeyValueData( + $argumentValues, + $dataCollection->getStackFrameVariables() + ) ?? []; + } + foreach ($argumentValues as $argumentName => $argumentValue) { $argumentValues[$argumentName] = $this->representationSerializer->representationSerialize($argumentValue); } diff --git a/src/Integration/FrameContextifierIntegration.php b/src/Integration/FrameContextifierIntegration.php index f0ff6f59b..f3b3f104e 100644 --- a/src/Integration/FrameContextifierIntegration.php +++ b/src/Integration/FrameContextifierIntegration.php @@ -47,7 +47,11 @@ public function setupOnce(): void return $event; } - $maxContextLines = $client->getOptions()->getContextLines(); + $options = $client->getOptions(); + $dataCollection = $options->getDataCollection(); + $maxContextLines = $dataCollection === null + ? $options->getContextLines() + : $dataCollection->getFrameContextLines(); $integration = $client->getIntegration(self::class); if ($integration === null || $maxContextLines === null) { diff --git a/tests/Integration/FrameContextifierIntegrationTest.php b/tests/Integration/FrameContextifierIntegrationTest.php index 5667826b0..ff50550e5 100644 --- a/tests/Integration/FrameContextifierIntegrationTest.php +++ b/tests/Integration/FrameContextifierIntegrationTest.php @@ -23,9 +23,21 @@ final class FrameContextifierIntegrationTest extends TestCase /** * @dataProvider invokeDataProvider */ - public function testInvoke(string $fixtureFilePath, int $lineNumber, int $contextLines, int $preContextCount, int $postContextCount): void - { - $options = new Options(['context_lines' => $contextLines]); + public function testInvoke( + string $fixtureFilePath, + int $lineNumber, + int $contextLines, + int $preContextCount, + int $postContextCount, + ?int $dataCollectionContextLines = null + ): void { + $options = ['context_lines' => $contextLines]; + + if ($dataCollectionContextLines !== null) { + $options['data_collection'] = ['frame_context_lines' => $dataCollectionContextLines]; + } + + $options = new Options($options); $integration = new FrameContextifierIntegration(); $integration->setupOnce(); @@ -108,6 +120,24 @@ public static function invokeDataProvider(): \Generator 2, 5, ]; + + yield 'data collection context lines take precedence over legacy option' => [ + realpath(__DIR__ . '/../Fixtures/code/LongFile.php'), + 8, + 1, + 3, + 3, + 3, + ]; + + yield 'data collection can omit surrounding context lines' => [ + realpath(__DIR__ . '/../Fixtures/code/LongFile.php'), + 8, + 5, + 0, + 0, + 0, + ]; } public function testInvokeLogsWarningMessageIfSourceCodeExcerptCannotBeRetrievedForFrame(): void diff --git a/tests/StacktraceBuilderTest.php b/tests/StacktraceBuilderTest.php index 544919063..a17e0c161 100644 --- a/tests/StacktraceBuilderTest.php +++ b/tests/StacktraceBuilderTest.php @@ -50,4 +50,193 @@ public function testBuildFromBacktrace(): void $this->assertSame(__FILE__, $frames[2]->getAbsoluteFilePath()); $this->assertSame($expectedLine, $frames[2]->getLine()); } + + /** + * @dataProvider realExceptionStackFrameVariablesDataProvider + * + * @param array $options + * @param array> $expectedVariables + */ + public function testStackFrameVariablesFromRealException(array $options, array $expectedVariables): void + { + $previousIgnoreArgs = \ini_get('zend.exception_ignore_args'); + + try { + if ($previousIgnoreArgs !== false + && (ini_set('zend.exception_ignore_args', '0') === false + || \ini_get('zend.exception_ignore_args') !== '0')) { + $this->markTestSkipped('zend.exception_ignore_args cannot be disabled.'); + } + + $exception = self::createNestedException(); + $sdkOptions = new Options($options); + $stacktraceBuilder = new StacktraceBuilder( + $sdkOptions, + new RepresentationSerializer($sdkOptions) + ); + $frames = $stacktraceBuilder->buildFromException($exception)->getFrames(); + $actualVariables = []; + + foreach ($frames as $frame) { + $rawFunctionName = $frame->getRawFunctionName(); + + if ($rawFunctionName === null) { + continue; + } + + $separatorPosition = strrpos($rawFunctionName, '::'); + $methodName = $separatorPosition === false + ? $rawFunctionName + : substr($rawFunctionName, $separatorPosition + 2); + + if (\array_key_exists($methodName, $expectedVariables)) { + $actualVariables[$methodName] = $frame->getVars(); + } + } + + ksort($actualVariables); + ksort($expectedVariables); + + $this->assertSame($expectedVariables, $actualVariables); + } finally { + if ($previousIgnoreArgs !== false) { + ini_set('zend.exception_ignore_args', $previousIgnoreArgs); + } + } + } + + public static function realExceptionStackFrameVariablesDataProvider(): \Generator + { + yield 'legacy behavior is unchanged' => [ + [], + [ + 'stackFrameInner' => [ + 'apiToken' => 'nested-secret', + 'safeValue' => 'safe', + ], + 'stackFrameMiddle' => [ + 'metadata' => [ + 'api_token' => 'nested-secret', + 'name' => 'alice', + ], + ], + 'stackFrameOuter' => [ + 'requestId' => 'request-123', + 'password' => 'secret', + ], + ], + ]; + + yield 'default data collection filters mandatory sensitive values' => [ + ['data_collection' => []], + [ + 'stackFrameInner' => [ + 'apiToken' => '[Filtered]', + 'safeValue' => 'safe', + ], + 'stackFrameMiddle' => [ + 'metadata' => [ + 'api_token' => '[Filtered]', + 'name' => 'alice', + ], + ], + 'stackFrameOuter' => [ + 'requestId' => 'request-123', + 'password' => '[Filtered]', + ], + ], + ]; + + yield 'collection can be disabled with boolean shorthand' => [ + ['data_collection' => ['stack_frame_variables' => false]], + [ + 'stackFrameInner' => [], + 'stackFrameMiddle' => [], + 'stackFrameOuter' => [], + ], + ]; + + yield 'allow list filters values not matching configured terms' => [ + [ + 'data_collection' => [ + 'stack_frame_variables' => [ + 'mode' => 'allowList', + 'terms' => ['request'], + ], + ], + ], + [ + 'stackFrameInner' => [ + 'apiToken' => '[Filtered]', + 'safeValue' => '[Filtered]', + ], + 'stackFrameMiddle' => [ + 'metadata' => '[Filtered]', + ], + 'stackFrameOuter' => [ + 'requestId' => 'request-123', + 'password' => '[Filtered]', + ], + ], + ]; + + yield 'deny list combines mandatory and custom terms' => [ + [ + 'data_collection' => [ + 'stack_frame_variables' => [ + 'mode' => 'denyList', + 'terms' => ['request'], + ], + ], + ], + [ + 'stackFrameInner' => [ + 'apiToken' => '[Filtered]', + 'safeValue' => 'safe', + ], + 'stackFrameMiddle' => [ + 'metadata' => [ + 'api_token' => '[Filtered]', + 'name' => 'alice', + ], + ], + 'stackFrameOuter' => [ + 'requestId' => '[Filtered]', + 'password' => '[Filtered]', + ], + ], + ]; + } + + private static function createNestedException(): \RuntimeException + { + try { + self::stackFrameOuter('request-123', 'secret'); + } catch (\RuntimeException $exception) { + return $exception; + } + + throw new \LogicException('Expected the nested stack frame fixture to throw.'); + } + + private static function stackFrameOuter(string $requestId, string $password): void + { + self::stackFrameMiddle([ + 'api_token' => 'nested-secret', + 'name' => 'alice', + ]); + } + + /** + * @param array $metadata + */ + private static function stackFrameMiddle(array $metadata): void + { + self::stackFrameInner($metadata['api_token'], 'safe'); + } + + private static function stackFrameInner(string $apiToken, string $safeValue): void + { + throw new \RuntimeException('Real nested stack frame fixture.'); + } } From ef2b875a6af0689ad6f22cda981ecc9bebdea8cf Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Fri, 4 Sep 2026 12:55:04 +0200 Subject: [PATCH 09/10] feat(pii): add data collection for guzzle --- phpstan-baseline.neon | 2 +- src/DataCollection/KeyValueDataFilter.php | 40 +- src/DataCollection/RequestDataCollector.php | 9 +- src/Options.php | 2 + src/Tracing/GuzzleTracingMiddleware.php | 297 ++++++++++-- src/Util/Arr.php | 6 +- .../DataCollection/KeyValueDataFilterTest.php | 17 + tests/Tracing/GuzzleTracingMiddlewareTest.php | 423 ++++++++++++++++++ 8 files changed, 758 insertions(+), 38 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 4a34c967f..6af0ca773 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -181,7 +181,7 @@ parameters: path: src/Options.php - - message: "#^Method Sentry\\\\Options\\:\\:getMaxRequestBodySize\\(\\) should return string but returns mixed\\.$#" + message: "#^Method Sentry\\\\Options\\:\\:getMaxRequestBodySize\\(\\) should return 'always'\\|'medium'\\|'never'\\|'none'\\|'small' but returns mixed\\.$#" count: 1 path: src/Options.php diff --git a/src/DataCollection/KeyValueDataFilter.php b/src/DataCollection/KeyValueDataFilter.php index 0af97c5af..e5d221837 100644 --- a/src/DataCollection/KeyValueDataFilter.php +++ b/src/DataCollection/KeyValueDataFilter.php @@ -4,6 +4,8 @@ namespace Sentry\DataCollection; +use Sentry\Util\Arr; + /** * @internal * @@ -11,6 +13,13 @@ */ final class KeyValueDataFilter { + public const FILTERED_VALUE = '[Filtered]'; + + private const DEFAULT_BODY_FILTER_BEHAVIOR = [ + 'mode' => 'denyList', + 'terms' => [], + ]; + private const SENSITIVE_DATA_DENYLIST = [ 'auth', 'token', @@ -68,7 +77,7 @@ public static function filterHeaders(array $headers, array $behavior): ?array if (\in_array(strtolower($name), self::SENSITIVE_HEADERS, true) || self::shouldFilterValue($name, $behavior)) { foreach ($values as $headerLine => $headerValue) { - $values[$headerLine] = '[Filtered]'; + $values[$headerLine] = self::FILTERED_VALUE; } } @@ -98,7 +107,7 @@ public static function filterKeyValueData(array $data, array $behavior): ?array $key = (string) $key; if (self::shouldFilterValue($key, $behavior)) { - $filtered[$key] = '[Filtered]'; + $filtered[$key] = self::FILTERED_VALUE; } elseif (\is_array($value)) { $filtered[$key] = self::filterKeyValueData($value, $behavior); } else { @@ -109,6 +118,31 @@ public static function filterKeyValueData(array $data, array $behavior): ?array return $filtered; } + /** + * Filters structured HTTP body data while replacing unkeyed top-level values. + * + * @param array $data + * + * @return array + */ + public static function filterHttpBodyData(array $data): array + { + if (!Arr::isList($data)) { + return self::filterKeyValueData($data, self::DEFAULT_BODY_FILTER_BEHAVIOR) ?? []; + } + + $filtered = []; + + /** @mago-ignore analysis:mixed-assignment */ + foreach ($data as $value) { + $filtered[] = \is_array($value) + ? self::filterHttpBodyData($value) + : self::FILTERED_VALUE; + } + + return $filtered; + } + /** * @phpstan-param KeyValueCollectionBehavior $behavior */ @@ -126,7 +160,7 @@ public static function filterQueryString(string $queryString, array $behavior): $key = urldecode($encodedKey); if ($separatorPosition !== false && self::shouldFilterValue($key, $behavior)) { - $parts[$index] = $encodedKey . '=[Filtered]'; + $parts[$index] = $encodedKey . '=' . self::FILTERED_VALUE; } } diff --git a/src/DataCollection/RequestDataCollector.php b/src/DataCollection/RequestDataCollector.php index 1a49e24a4..45cd82d67 100644 --- a/src/DataCollection/RequestDataCollector.php +++ b/src/DataCollection/RequestDataCollector.php @@ -142,13 +142,10 @@ public function collectRequestBody($body) } if (!\is_array($body)) { - return '[Filtered]'; + return KeyValueDataFilter::FILTERED_VALUE; } - return KeyValueDataFilter::filterKeyValueData($body, [ - 'mode' => 'denyList', - 'terms' => [], - ]); + return KeyValueDataFilter::filterHttpBodyData($body); } /** @@ -165,7 +162,7 @@ private function sanitizeLegacyHeaders(array $headers): array if (\in_array(strtolower($name), $this->piiSanitizeHeaders, true)) { foreach ($values as $headerLine => $headerValue) { - $values[$headerLine] = '[Filtered]'; + $values[$headerLine] = KeyValueDataFilter::FILTERED_VALUE; } } diff --git a/src/Options.php b/src/Options.php index 34e843c1d..0f533dcea 100644 --- a/src/Options.php +++ b/src/Options.php @@ -1131,6 +1131,8 @@ public function setCaptureSilencedErrors(bool $shouldCapture): self /** * Gets the limit up to which integrations should capture the HTTP request * body. + * + * @return 'none'|'never'|'small'|'medium'|'always' */ public function getMaxRequestBodySize(): string { diff --git a/src/Tracing/GuzzleTracingMiddleware.php b/src/Tracing/GuzzleTracingMiddleware.php index 8e277e4c0..45805a609 100644 --- a/src/Tracing/GuzzleTracingMiddleware.php +++ b/src/Tracing/GuzzleTracingMiddleware.php @@ -5,13 +5,19 @@ namespace Sentry\Tracing; use GuzzleHttp\Exception\RequestException as GuzzleRequestException; +use GuzzleHttp\Psr7\Query; use GuzzleHttp\Psr7\Uri; +use GuzzleHttp\Psr7\Utils; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamInterface; use Sentry\Breadcrumb; -use Sentry\ClientInterface; +use Sentry\DataCollection\DataCollectionOptions; +use Sentry\DataCollection\KeyValueDataFilter; +use Sentry\Options; use Sentry\SentrySdk; use Sentry\State\HubInterface; +use Sentry\Util\JSON; use function Sentry\getBaggage; use function Sentry\getTraceparent; @@ -21,6 +27,17 @@ */ final class GuzzleTracingMiddleware { + // Avoid reading arbitrarily large or unknown-sized streams into memory. + private const HTTP_BODY_MAX_CONTENT_LENGTH = 10 ** 5; + + private const MAX_REQUEST_BODY_SIZE_TO_LENGTH = [ + 'none' => 0, + 'never' => 0, + 'small' => 10 ** 3, + 'medium' => 10 ** 4, + 'always' => self::HTTP_BODY_MAX_CONTENT_LENGTH, + ]; + public static function trace(?HubInterface $hub = null): \Closure { return static function (callable $handler) use ($hub): \Closure { @@ -28,32 +45,59 @@ public static function trace(?HubInterface $hub = null): \Closure $hub = $hub ?? SentrySdk::getCurrentHub(); $client = $hub->getClient(); $parentSpan = $hub->getSpan(); + $requestUri = $request->getUri(); + $requestBody = $request->getBody(); $partialUri = Uri::fromParts([ - 'scheme' => $request->getUri()->getScheme(), - 'host' => $request->getUri()->getHost(), - 'port' => $request->getUri()->getPort(), - 'path' => $request->getUri()->getPath(), + 'scheme' => $requestUri->getScheme(), + 'host' => $requestUri->getHost(), + 'port' => $requestUri->getPort(), + 'path' => $requestUri->getPath(), ]); + $sdkOptions = $client !== null ? $client->getOptions() : null; + $dataCollection = $sdkOptions !== null ? $sdkOptions->getDataCollection() : null; $spanAndBreadcrumbData = [ 'http.request.method' => $request->getMethod(), - 'http.request.body.size' => $request->getBody()->getSize(), + 'http.request.body.size' => $requestBody->getSize(), ]; - if ($request->getUri()->getQuery() !== '') { - $spanAndBreadcrumbData['http.query'] = $request->getUri()->getQuery(); + $queryString = self::collectQueryString($dataCollection, $requestUri->getQuery()); + if ($queryString !== null) { + $spanAndBreadcrumbData['http.query'] = $queryString; } - if ($request->getUri()->getFragment() !== '') { - $spanAndBreadcrumbData['http.fragment'] = $request->getUri()->getFragment(); + if ($requestUri->getFragment() !== '') { + $spanAndBreadcrumbData['http.fragment'] = $requestUri->getFragment(); + } + + $collectedUri = $partialUri; + if ($dataCollection !== null) { + $collectedUri = $collectedUri + ->withQuery($queryString ?? '') + ->withFragment($requestUri->getFragment()); + $spanAndBreadcrumbData['url.full'] = (string) $collectedUri; } $childSpan = null; + $spanData = $spanAndBreadcrumbData; if ($parentSpan !== null && $parentSpan->getSampled()) { + if ($dataCollection !== null && $sdkOptions !== null) { + // Headers and bodies can be sizeable, so keep them on the recorded span instead of duplicating them on its breadcrumb. + $spanData = array_merge( + $spanData, + self::collectRequestSpanData( + $dataCollection, + $sdkOptions->getMaxRequestBodySize(), + $request, + $requestBody + ) + ); + } + $spanContext = new SpanContext(); $spanContext->setOp('http.client'); - $spanContext->setData($spanAndBreadcrumbData); + $spanContext->setData($spanData); $spanContext->setOrigin('auto.http.guzzle'); $spanContext->setDescription($request->getMethod() . ' ' . $partialUri); @@ -62,7 +106,7 @@ public static function trace(?HubInterface $hub = null): \Closure $hub->setSpan($childSpan); } - if (self::shouldAttachTracingHeaders($client, $request)) { + if (self::shouldAttachTracingHeaders($sdkOptions, $request)) { $traceParent = getTraceparent(); if ($traceParent !== '') { $request = $request->withHeader('sentry-trace', $traceParent); @@ -74,7 +118,7 @@ public static function trace(?HubInterface $hub = null): \Closure } } - $handlerPromiseCallback = static function ($responseOrException) use ($hub, $spanAndBreadcrumbData, $childSpan, $parentSpan, $partialUri) { + $handlerPromiseCallback = static function ($responseOrException) use ($hub, $spanAndBreadcrumbData, $spanData, $childSpan, $parentSpan, $collectedUri, $dataCollection) { if ($childSpan !== null) { // We finish the span (which means setting the span end timestamp) first to ensure the measured time // the span spans is as close to only the HTTP request time and do the data collection afterwards @@ -83,6 +127,7 @@ public static function trace(?HubInterface $hub = null): \Closure $hub->setSpan($parentSpan); } + /** @var ResponseInterface|null $response */ $response = null; if ($responseOrException instanceof ResponseInterface) { @@ -93,21 +138,28 @@ public static function trace(?HubInterface $hub = null): \Closure $breadcrumbLevel = Breadcrumb::LEVEL_INFO; - if ($response !== null) { - $spanAndBreadcrumbData['http.response.body.size'] = $response->getBody()->getSize(); - $spanAndBreadcrumbData['http.response.status_code'] = $response->getStatusCode(); + if ($response instanceof ResponseInterface) { + $responseBody = $response->getBody(); + $statusCode = $response->getStatusCode(); + $spanAndBreadcrumbData['http.response.body.size'] = $responseBody->getSize(); + $spanAndBreadcrumbData['http.response.status_code'] = $statusCode; - if ($response->getStatusCode() >= 400 && $response->getStatusCode() < 500) { + if ($statusCode >= 400 && $statusCode < 500) { $breadcrumbLevel = Breadcrumb::LEVEL_WARNING; - } elseif ($response->getStatusCode() >= 500) { + } elseif ($statusCode >= 500) { $breadcrumbLevel = Breadcrumb::LEVEL_ERROR; } } if ($childSpan !== null) { - if ($response !== null) { + if ($response instanceof ResponseInterface) { + $spanData = array_merge( + $spanData, + $spanAndBreadcrumbData, + self::collectResponseSpanData($dataCollection, $response) + ); $childSpan->setStatus(SpanStatus::createFromHttpStatusCode($response->getStatusCode())); - $childSpan->setData($spanAndBreadcrumbData); + $childSpan->setData($spanData); } else { $childSpan->setStatus(SpanStatus::internalError()); } @@ -119,7 +171,7 @@ public static function trace(?HubInterface $hub = null): \Closure 'http', null, array_merge([ - 'url' => (string) $partialUri, + 'url' => (string) $collectedUri, ], $spanAndBreadcrumbData) )); @@ -135,16 +187,207 @@ public static function trace(?HubInterface $hub = null): \Closure }; } - private static function shouldAttachTracingHeaders(?ClientInterface $client, RequestInterface $request): bool + private static function collectQueryString(?DataCollectionOptions $dataCollection, string $queryString): ?string { - if ($client === null) { - return false; + if ($queryString === '') { + return null; + } + + if ($dataCollection === null) { + return $queryString; + } + + return KeyValueDataFilter::filterQueryString($queryString, $dataCollection->getUrlQueryParams()); + } + + /** + * @param 'none'|'never'|'small'|'medium'|'always' $maxRequestBodySize + * + * @return array + */ + private static function collectRequestSpanData( + DataCollectionOptions $dataCollection, + string $maxRequestBodySize, + RequestInterface $request, + StreamInterface $body + ): array { + $data = self::collectHeaders($dataCollection, $request->getHeaders(), 'request'); + + if (!\in_array('outgoingRequest', $dataCollection->getHttpBodies(), true)) { + return $data; + } + + $maxBodyLength = self::MAX_REQUEST_BODY_SIZE_TO_LENGTH[$maxRequestBodySize]; + $collectedBody = self::collectBody($body, $request->getHeaderLine('Content-Type'), $maxBodyLength); + + if ($collectedBody !== null) { + $data['http.request.body.data'] = $collectedBody; + } + + return $data; + } + + /** + * @return array + */ + private static function collectResponseSpanData(?DataCollectionOptions $dataCollection, ResponseInterface $response): array + { + if ($dataCollection === null) { + return []; + } + + $data = self::collectHeaders($dataCollection, $response->getHeaders(), 'response'); + + if (!\in_array('incomingResponse', $dataCollection->getHttpBodies(), true)) { + return $data; + } + + $collectedBody = self::collectBody( + $response->getBody(), + $response->getHeaderLine('Content-Type'), + self::HTTP_BODY_MAX_CONTENT_LENGTH + ); + + if ($collectedBody !== null) { + $data['http.response.body.data'] = $collectedBody; + } + + return $data; + } + + /** + * @param array $headers + * @param 'request'|'response' $direction + * + * @return array + */ + private static function collectHeaders(DataCollectionOptions $dataCollection, array $headers, string $direction): array + { + $headerBehavior = $dataCollection->getHttpHeaders()[$direction]; + $cookieBehavior = $dataCollection->getCookies(); + $prefix = 'http.' . $direction . '.header.'; + $regularHeaders = []; + $attributes = []; + + foreach ($headers as $name => $values) { + $name = strtolower((string) $name); + + if ($name === 'cookie' || $name === 'set-cookie') { + if ($cookieBehavior['mode'] !== 'off' && $values !== []) { + // PSR-7 exposes cookies as raw header strings, so use the safe fallback required by the data collection spec. + $attributes[$prefix . $name] = array_fill(0, \count($values), KeyValueDataFilter::FILTERED_VALUE); + } + + continue; + } + + $regularHeaders[$name] = $values; } - $sdkOptions = $client->getOptions(); + $filteredHeaders = KeyValueDataFilter::filterHeaders($regularHeaders, $headerBehavior); + foreach ($filteredHeaders ?? [] as $name => $values) { + $attributes[$prefix . $name] = $values; + } + + return $attributes; + } + + /** + * @return array|string|null + */ + private static function collectBody(StreamInterface $body, string $contentType, int $maxBodyLength) + { + if ($maxBodyLength === 0) { + return null; + } + + $bodySize = $body->getSize(); + if ($bodySize === 0 || ($bodySize !== null && $bodySize > $maxBodyLength)) { + return null; + } + + $mediaType = strtolower(trim(explode(';', $contentType, 2)[0])); + + $isJson = $mediaType === 'application/json' + // RFC 6839 structured syntax suffix, e.g. application/problem+json. + || substr($mediaType, -5) === '+json'; + $isForm = $mediaType === 'application/x-www-form-urlencoded'; + + if (!$isJson && !$isForm) { + return KeyValueDataFilter::FILTERED_VALUE; + } + + // The size can be unknown (a null body size), so readBody() enforces the limit again after reading. + $bodyContents = self::readBody($body, $maxBodyLength); + if ($bodyContents === null) { + return null; + } + + try { + if ($isJson) { + /** @mago-ignore analysis:mixed-assignment */ + $decodedBody = JSON::decode($bodyContents); + } else { + /** @var array $decodedBody */ + $decodedBody = Query::parse($bodyContents); + } + } catch (\Throwable $exception) { + return KeyValueDataFilter::FILTERED_VALUE; + } + + if (!\is_array($decodedBody)) { + return KeyValueDataFilter::FILTERED_VALUE; + } + + return KeyValueDataFilter::filterHttpBodyData($decodedBody); + } + + private static function readBody(StreamInterface $body, int $maxBodyLength): ?string + { + if (!$body->isReadable() || !$body->isSeekable()) { + return null; + } + + $position = null; + + try { + $position = $body->tell(); + $body->rewind(); + + // Read one byte past the limit to detect bodies of unknown size that exceed it. + $contents = Utils::copyToString($body, $maxBodyLength + 1); + + if ($contents === '' || \strlen($contents) > $maxBodyLength) { + return null; + } + + return $contents; + } catch (\Throwable $exception) { + return null; + } finally { + if ($position !== null) { + self::restoreBodyPosition($body, $position); + } + } + } + + private static function restoreBodyPosition(StreamInterface $body, int $position): void + { + try { + $body->seek($position); + } catch (\Throwable $exception) { + // Ignore streams that report themselves as seekable but cannot be restored. + } + } + + private static function shouldAttachTracingHeaders(?Options $options, RequestInterface $request): bool + { + if ($options === null) { + return false; + } // Check if the request destination is allow listed in the trace_propagation_targets option. - return $sdkOptions->getTracePropagationTargets() === null - || \in_array($request->getUri()->getHost(), $sdkOptions->getTracePropagationTargets()); + return $options->getTracePropagationTargets() === null + || \in_array($request->getUri()->getHost(), $options->getTracePropagationTargets()); } } diff --git a/src/Util/Arr.php b/src/Util/Arr.php index 14f9594e5..2c0fde8de 100644 --- a/src/Util/Arr.php +++ b/src/Util/Arr.php @@ -45,7 +45,7 @@ public static function simpleDot(array $array): array /** * Checks whether a given array is a list. * - * `array_is_list` is introduced in PHP 8.1, so we have a polyfill for it. + * Uses `array_is_list` when available and falls back to a PHP 7.2-compatible implementation. * * @see https://www.php.net/manual/en/function.array-is-list.php#126794 * @@ -53,6 +53,10 @@ public static function simpleDot(array $array): array */ public static function isList(array $array): bool { + if (\function_exists('array_is_list')) { + return array_is_list($array); + } + $i = 0; foreach ($array as $k => $v) { diff --git a/tests/DataCollection/KeyValueDataFilterTest.php b/tests/DataCollection/KeyValueDataFilterTest.php index 4352e28fa..ea56ad62a 100644 --- a/tests/DataCollection/KeyValueDataFilterTest.php +++ b/tests/DataCollection/KeyValueDataFilterTest.php @@ -96,6 +96,23 @@ public function testFilterKeyValueDataFiltersNestedData(): void ], $filtered); } + public function testFilterHttpBodyDataFiltersSensitiveAndUnkeyedValues(): void + { + $this->assertSame([ + [ + 'password' => '[Filtered]', + 'name' => 'alice', + ], + '[Filtered]', + ], KeyValueDataFilter::filterHttpBodyData([ + [ + 'password' => 'secret', + 'name' => 'alice', + ], + 'unkeyed secret', + ])); + } + public function testFilterHeadersReturnsNullWhenCollectionIsOff(): void { $behavior = ['mode' => 'off', 'terms' => ['x-request-id']]; diff --git a/tests/Tracing/GuzzleTracingMiddlewareTest.php b/tests/Tracing/GuzzleTracingMiddlewareTest.php index becfa84a8..f8c2106c2 100644 --- a/tests/Tracing/GuzzleTracingMiddlewareTest.php +++ b/tests/Tracing/GuzzleTracingMiddlewareTest.php @@ -7,9 +7,12 @@ use GuzzleHttp\Promise\FulfilledPromise; use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Promise\RejectedPromise; +use GuzzleHttp\Psr7\FnStream; +use GuzzleHttp\Psr7\NoSeekStream; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\Uri; +use GuzzleHttp\Psr7\Utils; use PHPUnit\Framework\TestCase; use Sentry\ClientInterface; use Sentry\Event; @@ -19,7 +22,9 @@ use Sentry\State\Hub; use Sentry\State\Scope; use Sentry\Tracing\GuzzleTracingMiddleware; +use Sentry\Tracing\Span; use Sentry\Tracing\SpanStatus; +use Sentry\Tracing\Transaction; use Sentry\Tracing\TransactionContext; final class GuzzleTracingMiddlewareTest extends TestCase @@ -402,6 +407,424 @@ public function testTrace(Request $request, $expectedPromiseResult, array $expec $transaction->finish(); } + /** + * @dataProvider traceQueryStringDataProvider + * + * @param array $options + */ + public function testTraceFiltersQueryString(array $options, ?string $expectedQueryString): void + { + $rawQueryString = 'search=hello%20world&password=s%2Becret&custom=value'; + $sdkOptions = new Options(array_merge([ + 'traces_sample_rate' => 1, + ], $options)); + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn($sdkOptions); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $transaction = $hub->startTransaction(new TransactionContext()); + $hub->setSpan($transaction); + + $middleware = GuzzleTracingMiddleware::trace($hub); + $function = $middleware(function (Request $request) use ($rawQueryString): PromiseInterface { + $this->assertSame($rawQueryString, $request->getUri()->getQuery()); + + return new FulfilledPromise(new Response()); + }); + + /** @var PromiseInterface $promise */ + $promise = $function(new Request('GET', 'https://www.example.com?' . $rawQueryString), []); + $promise->wait(); + + $spanData = $this->getHttpSpan($transaction)->getData(); + $breadcrumbData = $this->getBreadcrumbData($hub); + + if ($expectedQueryString === null) { + $this->assertArrayNotHasKey('http.query', $spanData); + $this->assertArrayNotHasKey('http.query', $breadcrumbData); + } else { + $this->assertSame($expectedQueryString, $spanData['http.query']); + $this->assertSame($expectedQueryString, $breadcrumbData['http.query']); + } + } + + public function testTraceCollectsConfiguredOutgoingHttpData(): void + { + $sdkOptions = new Options([ + 'traces_sample_rate' => 1, + 'data_collection' => [], + ]); + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn($sdkOptions); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $transaction = $hub->startTransaction(new TransactionContext()); + $hub->setSpan($transaction); + + $response = new Response(200, [ + 'Content-Type' => 'application/x-www-form-urlencoded', + 'X-Response-Id' => 'response-123', + 'Set-Cookie' => [ + 'session_id=response-secret; Path=/; HttpOnly', + 'theme=light; Path=/', + ], + ], 'token=response-secret&status=ok'); + $middleware = GuzzleTracingMiddleware::trace($hub); + $function = $middleware(function (Request $request) use ($response): PromiseInterface { + $this->assertSame(0, $request->getBody()->tell()); + + return new FulfilledPromise($response); + }); + $request = new Request( + 'POST', + 'https://www.example.com/path?search=hello%20world&password=request-secret#fragment', + [ + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer request-secret', + 'Cookie' => 'session_id=request-secret; theme=dark', + ], + '[{"password":"request-secret","name":"Alice"},"unkeyed-request-secret"]' + ); + + /** @var PromiseInterface $promise */ + $promise = $function($request, []); + $promise->wait(); + + $this->assertSame(0, $request->getBody()->tell()); + $this->assertSame(0, $response->getBody()->tell()); + + $expectedSharedData = [ + 'url.full' => 'https://www.example.com/path?search=hello%20world&password=%5BFiltered%5D#fragment', + 'http.query' => 'search=hello%20world&password=[Filtered]', + ]; + $expectedSpanData = [ + 'http.request.header.content-type' => ['application/json'], + 'http.request.header.authorization' => ['[Filtered]'], + 'http.request.header.cookie' => ['[Filtered]'], + 'http.request.body.data' => [ + [ + 'password' => '[Filtered]', + 'name' => 'Alice', + ], + '[Filtered]', + ], + 'http.response.header.content-type' => ['application/x-www-form-urlencoded'], + 'http.response.header.x-response-id' => ['response-123'], + 'http.response.header.set-cookie' => ['[Filtered]', '[Filtered]'], + 'http.response.body.data' => [ + 'token' => '[Filtered]', + 'status' => 'ok', + ], + ]; + $spanData = $this->getHttpSpan($transaction)->getData(); + $breadcrumbData = $this->getBreadcrumbData($hub); + + foreach ($expectedSharedData as $key => $value) { + $this->assertSame($value, $spanData[$key]); + $this->assertSame($value, $breadcrumbData[$key]); + } + foreach ($expectedSpanData as $key => $value) { + $this->assertSame($value, $spanData[$key]); + $this->assertArrayNotHasKey($key, $breadcrumbData); + } + $this->assertSame($expectedSharedData['url.full'], $breadcrumbData['url']); + $this->assertStringNotContainsString('request-secret', json_encode($spanData)); + $this->assertStringNotContainsString('response-secret', json_encode($spanData)); + } + + public function testTraceDoesNotConsumeNonSeekableBodies(): void + { + $sdkOptions = new Options([ + 'traces_sample_rate' => 1, + 'data_collection' => [], + ]); + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn($sdkOptions); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $transaction = $hub->startTransaction(new TransactionContext()); + $hub->setSpan($transaction); + + $requestBody = new NoSeekStream(Utils::streamFor('{"request":"body"}')); + $responseBody = new NoSeekStream(Utils::streamFor('{"response":"body"}')); + $response = new Response(200, ['Content-Type' => 'application/json'], $responseBody); + $middleware = GuzzleTracingMiddleware::trace($hub); + $function = $middleware(function (Request $request) use ($response): PromiseInterface { + $this->assertSame('{"request":"body"}', $request->getBody()->getContents()); + + return new FulfilledPromise($response); + }); + + /** @var PromiseInterface $promise */ + $promise = $function(new Request( + 'POST', + 'https://www.example.com', + ['Content-Type' => 'application/json'], + $requestBody + ), []); + $promiseResult = $promise->wait(); + + $this->assertSame($response, $promiseResult); + $this->assertSame('{"response":"body"}', $promiseResult->getBody()->getContents()); + + $spanData = $this->getHttpSpan($transaction)->getData(); + $this->assertArrayNotHasKey('http.request.body.data', $spanData); + $this->assertArrayNotHasKey('http.response.body.data', $spanData); + } + + public function testTraceSkipsBodiesLargerThanTheirLimits(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn(new Options([ + 'traces_sample_rate' => 1, + 'data_collection' => [], + ])); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $transaction = $hub->startTransaction(new TransactionContext()); + $hub->setSpan($transaction); + + $oversizedRequestBody = str_repeat('a', 10001); + $oversizedResponseBody = str_repeat('a', 100001); + $response = new Response(200, ['Content-Type' => 'application/json'], $oversizedResponseBody); + $middleware = GuzzleTracingMiddleware::trace($hub); + $function = $middleware(static function () use ($response): PromiseInterface { + return new FulfilledPromise($response); + }); + + /** @var PromiseInterface $promise */ + $promise = $function(new Request( + 'POST', + 'https://www.example.com', + ['Content-Type' => 'application/json'], + $oversizedRequestBody + ), []); + $promise->wait(); + + $spanData = $this->getHttpSpan($transaction)->getData(); + $this->assertArrayNotHasKey('http.request.body.data', $spanData); + $this->assertArrayNotHasKey('http.response.body.data', $spanData); + } + + /** + * @dataProvider httpBodySafetyLimitDataProvider + */ + public function testTraceAppliesHttpBodySafetyLimit(int $bodySize, bool $shouldCollect): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn(new Options([ + 'traces_sample_rate' => 1, + 'max_request_body_size' => 'always', + 'data_collection' => [], + ])); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $transaction = $hub->startTransaction(new TransactionContext()); + $hub->setSpan($transaction); + + $rawBody = str_repeat('a', $bodySize); + $requestBody = FnStream::decorate(Utils::streamFor($rawBody), [ + 'getSize' => static function (): ?int { + return null; + }, + ]); + $responseBody = FnStream::decorate(Utils::streamFor($rawBody), [ + 'getSize' => static function (): ?int { + return null; + }, + ]); + $response = new Response(200, ['Content-Type' => 'application/json'], $responseBody); + $middleware = GuzzleTracingMiddleware::trace($hub); + $function = $middleware(static function () use ($response): PromiseInterface { + return new FulfilledPromise($response); + }); + + /** @var PromiseInterface $promise */ + $promise = $function(new Request( + 'POST', + 'https://www.example.com', + ['Content-Type' => 'application/json'], + $requestBody + ), []); + $promise->wait(); + + $this->assertSame(0, $requestBody->tell()); + $this->assertSame(0, $responseBody->tell()); + + $spanData = $this->getHttpSpan($transaction)->getData(); + if ($shouldCollect) { + $this->assertSame('[Filtered]', $spanData['http.request.body.data']); + $this->assertSame('[Filtered]', $spanData['http.response.body.data']); + } else { + $this->assertArrayNotHasKey('http.request.body.data', $spanData); + $this->assertArrayNotHasKey('http.response.body.data', $spanData); + } + } + + public static function httpBodySafetyLimitDataProvider(): iterable + { + yield 'at 100 KB safety limit' => [100000, true]; + yield 'over 100 KB safety limit' => [100001, false]; + } + + public function testTraceRespectsDisabledOutgoingHttpDataCollection(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn(new Options([ + 'traces_sample_rate' => 1, + 'data_collection' => [ + 'cookies' => ['mode' => 'off'], + 'http_headers' => [ + 'request' => ['mode' => 'off'], + 'response' => ['mode' => 'off'], + ], + 'http_bodies' => [], + 'url_query_params' => ['mode' => 'off'], + ], + ])); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $transaction = $hub->startTransaction(new TransactionContext()); + $hub->setSpan($transaction); + + $response = new Response(200, [ + 'Content-Type' => 'application/json', + 'Set-Cookie' => 'session_id=response-secret', + ], '{"token":"response-secret"}'); + $middleware = GuzzleTracingMiddleware::trace($hub); + $function = $middleware(static function () use ($response): PromiseInterface { + return new FulfilledPromise($response); + }); + + /** @var PromiseInterface $promise */ + $promise = $function(new Request( + 'POST', + 'https://www.example.com?password=request-secret', + [ + 'Content-Type' => 'application/json', + 'Cookie' => 'session_id=request-secret', + ], + '{"password":"request-secret"}' + ), []); + $promise->wait(); + + $spanData = $this->getHttpSpan($transaction)->getData(); + $breadcrumbData = $this->getBreadcrumbData($hub); + + foreach ([ + 'http.query', + 'http.request.header.content-type', + 'http.request.header.cookie', + 'http.request.body.data', + 'http.response.header.content-type', + 'http.response.header.set-cookie', + 'http.response.body.data', + ] as $key) { + $this->assertArrayNotHasKey($key, $spanData); + $this->assertArrayNotHasKey($key, $breadcrumbData); + } + } + + /** + * @return array + */ + private function getBreadcrumbData(Hub $hub): array + { + $event = Event::createEvent(); + $hub->configureScope(static function (Scope $scope) use ($event): void { + $scope->applyToEvent($event); + }); + $this->assertCount(1, $event->getBreadcrumbs()); + + return $event->getBreadcrumbs()[0]->getMetadata(); + } + + private function getHttpSpan(Transaction $transaction): Span + { + $this->assertNotNull($transaction->getSpanRecorder()); + $httpSpans = array_values(array_filter( + $transaction->getSpanRecorder()->getSpans(), + static function (Span $span): bool { + return $span->getOp() === 'http.client'; + } + )); + $this->assertCount(1, $httpSpans); + + return $httpSpans[0]; + } + + public static function traceQueryStringDataProvider(): iterable + { + yield 'legacy behavior is unchanged' => [ + [], + 'search=hello%20world&password=s%2Becret&custom=value', + ]; + + yield 'default data collection filters mandatory sensitive values' => [ + ['data_collection' => []], + 'search=hello%20world&password=[Filtered]&custom=value', + ]; + + yield 'collection can be disabled' => [ + [ + 'data_collection' => [ + 'url_query_params' => [ + 'mode' => 'off', + ], + ], + ], + null, + ]; + + yield 'allow list filters values not matching configured terms' => [ + [ + 'data_collection' => [ + 'url_query_params' => [ + 'mode' => 'allowList', + 'terms' => ['custom'], + ], + ], + ], + 'search=[Filtered]&password=[Filtered]&custom=value', + ]; + + yield 'deny list combines mandatory and custom terms' => [ + [ + 'data_collection' => [ + 'url_query_params' => [ + 'mode' => 'denyList', + 'terms' => ['custom'], + ], + ], + ], + 'search=hello%20world&password=[Filtered]&custom=[Filtered]', + ]; + } + public static function traceDataProvider(): iterable { yield [ From 2cadf4f97f6b5ee831f9cc181f1d5eada12270aa Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Fri, 4 Sep 2026 12:55:04 +0200 Subject: [PATCH 10/10] feat(pii): add data collection for guzzle --- phpstan-baseline.neon | 2 +- src/DataCollection/KeyValueDataFilter.php | 40 +- src/DataCollection/RequestDataCollector.php | 9 +- src/Options.php | 2 + src/Tracing/GuzzleTracingMiddleware.php | 297 ++++++++++-- src/Util/Arr.php | 6 +- .../DataCollection/KeyValueDataFilterTest.php | 17 + tests/Tracing/GuzzleTracingMiddlewareTest.php | 423 ++++++++++++++++++ 8 files changed, 758 insertions(+), 38 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 121eab327..27f87db38 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -181,7 +181,7 @@ parameters: path: src/Options.php - - message: "#^Method Sentry\\\\Options\\:\\:getMaxRequestBodySize\\(\\) should return string but returns mixed\\.$#" + message: "#^Method Sentry\\\\Options\\:\\:getMaxRequestBodySize\\(\\) should return 'always'\\|'medium'\\|'never'\\|'none'\\|'small' but returns mixed\\.$#" count: 1 path: src/Options.php diff --git a/src/DataCollection/KeyValueDataFilter.php b/src/DataCollection/KeyValueDataFilter.php index 0af97c5af..e5d221837 100644 --- a/src/DataCollection/KeyValueDataFilter.php +++ b/src/DataCollection/KeyValueDataFilter.php @@ -4,6 +4,8 @@ namespace Sentry\DataCollection; +use Sentry\Util\Arr; + /** * @internal * @@ -11,6 +13,13 @@ */ final class KeyValueDataFilter { + public const FILTERED_VALUE = '[Filtered]'; + + private const DEFAULT_BODY_FILTER_BEHAVIOR = [ + 'mode' => 'denyList', + 'terms' => [], + ]; + private const SENSITIVE_DATA_DENYLIST = [ 'auth', 'token', @@ -68,7 +77,7 @@ public static function filterHeaders(array $headers, array $behavior): ?array if (\in_array(strtolower($name), self::SENSITIVE_HEADERS, true) || self::shouldFilterValue($name, $behavior)) { foreach ($values as $headerLine => $headerValue) { - $values[$headerLine] = '[Filtered]'; + $values[$headerLine] = self::FILTERED_VALUE; } } @@ -98,7 +107,7 @@ public static function filterKeyValueData(array $data, array $behavior): ?array $key = (string) $key; if (self::shouldFilterValue($key, $behavior)) { - $filtered[$key] = '[Filtered]'; + $filtered[$key] = self::FILTERED_VALUE; } elseif (\is_array($value)) { $filtered[$key] = self::filterKeyValueData($value, $behavior); } else { @@ -109,6 +118,31 @@ public static function filterKeyValueData(array $data, array $behavior): ?array return $filtered; } + /** + * Filters structured HTTP body data while replacing unkeyed top-level values. + * + * @param array $data + * + * @return array + */ + public static function filterHttpBodyData(array $data): array + { + if (!Arr::isList($data)) { + return self::filterKeyValueData($data, self::DEFAULT_BODY_FILTER_BEHAVIOR) ?? []; + } + + $filtered = []; + + /** @mago-ignore analysis:mixed-assignment */ + foreach ($data as $value) { + $filtered[] = \is_array($value) + ? self::filterHttpBodyData($value) + : self::FILTERED_VALUE; + } + + return $filtered; + } + /** * @phpstan-param KeyValueCollectionBehavior $behavior */ @@ -126,7 +160,7 @@ public static function filterQueryString(string $queryString, array $behavior): $key = urldecode($encodedKey); if ($separatorPosition !== false && self::shouldFilterValue($key, $behavior)) { - $parts[$index] = $encodedKey . '=[Filtered]'; + $parts[$index] = $encodedKey . '=' . self::FILTERED_VALUE; } } diff --git a/src/DataCollection/RequestDataCollector.php b/src/DataCollection/RequestDataCollector.php index 1a49e24a4..45cd82d67 100644 --- a/src/DataCollection/RequestDataCollector.php +++ b/src/DataCollection/RequestDataCollector.php @@ -142,13 +142,10 @@ public function collectRequestBody($body) } if (!\is_array($body)) { - return '[Filtered]'; + return KeyValueDataFilter::FILTERED_VALUE; } - return KeyValueDataFilter::filterKeyValueData($body, [ - 'mode' => 'denyList', - 'terms' => [], - ]); + return KeyValueDataFilter::filterHttpBodyData($body); } /** @@ -165,7 +162,7 @@ private function sanitizeLegacyHeaders(array $headers): array if (\in_array(strtolower($name), $this->piiSanitizeHeaders, true)) { foreach ($values as $headerLine => $headerValue) { - $values[$headerLine] = '[Filtered]'; + $values[$headerLine] = KeyValueDataFilter::FILTERED_VALUE; } } diff --git a/src/Options.php b/src/Options.php index d420894a4..07f0025eb 100644 --- a/src/Options.php +++ b/src/Options.php @@ -1151,6 +1151,8 @@ public function setCaptureSilencedErrors(bool $shouldCapture): self /** * Gets the limit up to which integrations should capture the HTTP request * body. + * + * @return 'none'|'never'|'small'|'medium'|'always' */ public function getMaxRequestBodySize(): string { diff --git a/src/Tracing/GuzzleTracingMiddleware.php b/src/Tracing/GuzzleTracingMiddleware.php index 480502c9c..a883dee22 100644 --- a/src/Tracing/GuzzleTracingMiddleware.php +++ b/src/Tracing/GuzzleTracingMiddleware.php @@ -5,13 +5,19 @@ namespace Sentry\Tracing; use GuzzleHttp\Exception\RequestException as GuzzleRequestException; +use GuzzleHttp\Psr7\Query; use GuzzleHttp\Psr7\Uri; +use GuzzleHttp\Psr7\Utils; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamInterface; use Sentry\Breadcrumb; -use Sentry\ClientInterface; +use Sentry\DataCollection\DataCollectionOptions; +use Sentry\DataCollection\KeyValueDataFilter; +use Sentry\Options; use Sentry\SentrySdk; use Sentry\State\HubInterface; +use Sentry\Util\JSON; use function Sentry\getBaggage; use function Sentry\getTraceparent; @@ -21,6 +27,17 @@ */ final class GuzzleTracingMiddleware { + // Avoid reading arbitrarily large or unknown-sized streams into memory. + private const HTTP_BODY_MAX_CONTENT_LENGTH = 10 ** 5; + + private const MAX_REQUEST_BODY_SIZE_TO_LENGTH = [ + 'none' => 0, + 'never' => 0, + 'small' => 10 ** 3, + 'medium' => 10 ** 4, + 'always' => self::HTTP_BODY_MAX_CONTENT_LENGTH, + ]; + public static function trace(?HubInterface $hub = null): \Closure { return static function (callable $handler) use ($hub): \Closure { @@ -28,32 +45,59 @@ public static function trace(?HubInterface $hub = null): \Closure $hub = $hub ?? SentrySdk::getCurrentHub(); $client = $hub->getClient(); $parentSpan = $hub->getSpan(); + $requestUri = $request->getUri(); + $requestBody = $request->getBody(); $partialUri = Uri::fromParts([ - 'scheme' => $request->getUri()->getScheme(), - 'host' => $request->getUri()->getHost(), - 'port' => $request->getUri()->getPort(), - 'path' => $request->getUri()->getPath(), + 'scheme' => $requestUri->getScheme(), + 'host' => $requestUri->getHost(), + 'port' => $requestUri->getPort(), + 'path' => $requestUri->getPath(), ]); + $sdkOptions = $client !== null ? $client->getOptions() : null; + $dataCollection = $sdkOptions !== null ? $sdkOptions->getDataCollection() : null; $spanAndBreadcrumbData = [ 'http.request.method' => $request->getMethod(), - 'http.request.body.size' => $request->getBody()->getSize(), + 'http.request.body.size' => $requestBody->getSize(), ]; - if ($request->getUri()->getQuery() !== '') { - $spanAndBreadcrumbData['http.query'] = $request->getUri()->getQuery(); + $queryString = self::collectQueryString($dataCollection, $requestUri->getQuery()); + if ($queryString !== null) { + $spanAndBreadcrumbData['http.query'] = $queryString; } - if ($request->getUri()->getFragment() !== '') { - $spanAndBreadcrumbData['http.fragment'] = $request->getUri()->getFragment(); + if ($requestUri->getFragment() !== '') { + $spanAndBreadcrumbData['http.fragment'] = $requestUri->getFragment(); + } + + $collectedUri = $partialUri; + if ($dataCollection !== null) { + $collectedUri = $collectedUri + ->withQuery($queryString ?? '') + ->withFragment($requestUri->getFragment()); + $spanAndBreadcrumbData['url.full'] = (string) $collectedUri; } $childSpan = null; + $spanData = $spanAndBreadcrumbData; if ($parentSpan !== null && $parentSpan->getSampled()) { + if ($dataCollection !== null && $sdkOptions !== null) { + // Headers and bodies can be sizeable, so keep them on the recorded span instead of duplicating them on its breadcrumb. + $spanData = array_merge( + $spanData, + self::collectRequestSpanData( + $dataCollection, + $sdkOptions->getMaxRequestBodySize(), + $request, + $requestBody + ) + ); + } + $spanContext = new SpanContext(); $spanContext->setOp('http.client'); - $spanContext->setData($spanAndBreadcrumbData); + $spanContext->setData($spanData); $spanContext->setOrigin('auto.http.guzzle'); $spanContext->setDescription($request->getMethod() . ' ' . $partialUri); @@ -62,7 +106,7 @@ public static function trace(?HubInterface $hub = null): \Closure $hub->setSpan($childSpan); } - if (self::shouldAttachTracingHeaders($client, $request)) { + if (self::shouldAttachTracingHeaders($sdkOptions, $request)) { $traceParent = getTraceparent(); if ($traceParent !== '') { $request = $request->withHeader('sentry-trace', $traceParent); @@ -74,7 +118,7 @@ public static function trace(?HubInterface $hub = null): \Closure } } - $handlerPromiseCallback = static function ($responseOrException) use ($hub, $spanAndBreadcrumbData, $childSpan, $parentSpan, $partialUri) { + $handlerPromiseCallback = static function ($responseOrException) use ($hub, $spanAndBreadcrumbData, $spanData, $childSpan, $parentSpan, $collectedUri, $dataCollection) { if ($childSpan !== null) { // We finish the span (which means setting the span end timestamp) first to ensure the measured time // the span spans is as close to only the HTTP request time and do the data collection afterwards @@ -83,6 +127,7 @@ public static function trace(?HubInterface $hub = null): \Closure $hub->setSpan($parentSpan); } + /** @var ResponseInterface|null $response */ $response = null; if ($responseOrException instanceof ResponseInterface) { @@ -93,21 +138,28 @@ public static function trace(?HubInterface $hub = null): \Closure $breadcrumbLevel = Breadcrumb::LEVEL_INFO; - if ($response !== null) { - $spanAndBreadcrumbData['http.response.body.size'] = $response->getBody()->getSize(); - $spanAndBreadcrumbData['http.response.status_code'] = $response->getStatusCode(); + if ($response instanceof ResponseInterface) { + $responseBody = $response->getBody(); + $statusCode = $response->getStatusCode(); + $spanAndBreadcrumbData['http.response.body.size'] = $responseBody->getSize(); + $spanAndBreadcrumbData['http.response.status_code'] = $statusCode; - if ($response->getStatusCode() >= 400 && $response->getStatusCode() < 500) { + if ($statusCode >= 400 && $statusCode < 500) { $breadcrumbLevel = Breadcrumb::LEVEL_WARNING; - } elseif ($response->getStatusCode() >= 500) { + } elseif ($statusCode >= 500) { $breadcrumbLevel = Breadcrumb::LEVEL_ERROR; } } if ($childSpan !== null) { - if ($response !== null) { + if ($response instanceof ResponseInterface) { + $spanData = array_merge( + $spanData, + $spanAndBreadcrumbData, + self::collectResponseSpanData($dataCollection, $response) + ); $childSpan->setStatus(SpanStatus::createFromHttpStatusCode($response->getStatusCode())); - $childSpan->setData($spanAndBreadcrumbData); + $childSpan->setData($spanData); } else { $childSpan->setStatus(SpanStatus::internalError()); } @@ -119,7 +171,7 @@ public static function trace(?HubInterface $hub = null): \Closure 'http', null, array_merge([ - 'url' => (string) $partialUri, + 'url' => (string) $collectedUri, ], $spanAndBreadcrumbData) )); @@ -135,16 +187,207 @@ public static function trace(?HubInterface $hub = null): \Closure }; } - private static function shouldAttachTracingHeaders(?ClientInterface $client, RequestInterface $request): bool + private static function collectQueryString(?DataCollectionOptions $dataCollection, string $queryString): ?string { - if ($client === null) { - return false; + if ($queryString === '') { + return null; + } + + if ($dataCollection === null) { + return $queryString; + } + + return KeyValueDataFilter::filterQueryString($queryString, $dataCollection->getUrlQueryParams()); + } + + /** + * @param 'none'|'never'|'small'|'medium'|'always' $maxRequestBodySize + * + * @return array + */ + private static function collectRequestSpanData( + DataCollectionOptions $dataCollection, + string $maxRequestBodySize, + RequestInterface $request, + StreamInterface $body + ): array { + $data = self::collectHeaders($dataCollection, $request->getHeaders(), 'request'); + + if (!\in_array('outgoingRequest', $dataCollection->getHttpBodies(), true)) { + return $data; + } + + $maxBodyLength = self::MAX_REQUEST_BODY_SIZE_TO_LENGTH[$maxRequestBodySize]; + $collectedBody = self::collectBody($body, $request->getHeaderLine('Content-Type'), $maxBodyLength); + + if ($collectedBody !== null) { + $data['http.request.body.data'] = $collectedBody; + } + + return $data; + } + + /** + * @return array + */ + private static function collectResponseSpanData(?DataCollectionOptions $dataCollection, ResponseInterface $response): array + { + if ($dataCollection === null) { + return []; + } + + $data = self::collectHeaders($dataCollection, $response->getHeaders(), 'response'); + + if (!\in_array('incomingResponse', $dataCollection->getHttpBodies(), true)) { + return $data; + } + + $collectedBody = self::collectBody( + $response->getBody(), + $response->getHeaderLine('Content-Type'), + self::HTTP_BODY_MAX_CONTENT_LENGTH + ); + + if ($collectedBody !== null) { + $data['http.response.body.data'] = $collectedBody; + } + + return $data; + } + + /** + * @param array $headers + * @param 'request'|'response' $direction + * + * @return array + */ + private static function collectHeaders(DataCollectionOptions $dataCollection, array $headers, string $direction): array + { + $headerBehavior = $dataCollection->getHttpHeaders()[$direction]; + $cookieBehavior = $dataCollection->getCookies(); + $prefix = 'http.' . $direction . '.header.'; + $regularHeaders = []; + $attributes = []; + + foreach ($headers as $name => $values) { + $name = strtolower((string) $name); + + if ($name === 'cookie' || $name === 'set-cookie') { + if ($cookieBehavior['mode'] !== 'off' && $values !== []) { + // PSR-7 exposes cookies as raw header strings, so use the safe fallback required by the data collection spec. + $attributes[$prefix . $name] = array_fill(0, \count($values), KeyValueDataFilter::FILTERED_VALUE); + } + + continue; + } + + $regularHeaders[$name] = $values; } - $sdkOptions = $client->getOptions(); + $filteredHeaders = KeyValueDataFilter::filterHeaders($regularHeaders, $headerBehavior); + foreach ($filteredHeaders ?? [] as $name => $values) { + $attributes[$prefix . $name] = $values; + } + + return $attributes; + } + + /** + * @return array|string|null + */ + private static function collectBody(StreamInterface $body, string $contentType, int $maxBodyLength) + { + if ($maxBodyLength === 0) { + return null; + } + + $bodySize = $body->getSize(); + if ($bodySize === 0 || ($bodySize !== null && $bodySize > $maxBodyLength)) { + return null; + } + + $mediaType = strtolower(trim(explode(';', $contentType, 2)[0])); + + $isJson = $mediaType === 'application/json' + // RFC 6839 structured syntax suffix, e.g. application/problem+json. + || substr($mediaType, -5) === '+json'; + $isForm = $mediaType === 'application/x-www-form-urlencoded'; + + if (!$isJson && !$isForm) { + return KeyValueDataFilter::FILTERED_VALUE; + } + + // The size can be unknown (a null body size), so readBody() enforces the limit again after reading. + $bodyContents = self::readBody($body, $maxBodyLength); + if ($bodyContents === null) { + return null; + } + + try { + if ($isJson) { + /** @mago-ignore analysis:mixed-assignment */ + $decodedBody = JSON::decode($bodyContents); + } else { + /** @var array $decodedBody */ + $decodedBody = Query::parse($bodyContents); + } + } catch (\Throwable $exception) { + return KeyValueDataFilter::FILTERED_VALUE; + } + + if (!\is_array($decodedBody)) { + return KeyValueDataFilter::FILTERED_VALUE; + } + + return KeyValueDataFilter::filterHttpBodyData($decodedBody); + } + + private static function readBody(StreamInterface $body, int $maxBodyLength): ?string + { + if (!$body->isReadable() || !$body->isSeekable()) { + return null; + } + + $position = null; + + try { + $position = $body->tell(); + $body->rewind(); + + // Read one byte past the limit to detect bodies of unknown size that exceed it. + $contents = Utils::copyToString($body, $maxBodyLength + 1); + + if ($contents === '' || \strlen($contents) > $maxBodyLength) { + return null; + } + + return $contents; + } catch (\Throwable $exception) { + return null; + } finally { + if ($position !== null) { + self::restoreBodyPosition($body, $position); + } + } + } + + private static function restoreBodyPosition(StreamInterface $body, int $position): void + { + try { + $body->seek($position); + } catch (\Throwable $exception) { + // Ignore streams that report themselves as seekable but cannot be restored. + } + } + + private static function shouldAttachTracingHeaders(?Options $options, RequestInterface $request): bool + { + if ($options === null) { + return false; + } // Check if the request destination is allow listed in the trace_propagation_targets option. - return $sdkOptions->getTracePropagationTargets() === null - || \in_array($request->getUri()->getHost(), $sdkOptions->getTracePropagationTargets()); + return $options->getTracePropagationTargets() === null + || \in_array($request->getUri()->getHost(), $options->getTracePropagationTargets()); } } diff --git a/src/Util/Arr.php b/src/Util/Arr.php index 14f9594e5..2c0fde8de 100644 --- a/src/Util/Arr.php +++ b/src/Util/Arr.php @@ -45,7 +45,7 @@ public static function simpleDot(array $array): array /** * Checks whether a given array is a list. * - * `array_is_list` is introduced in PHP 8.1, so we have a polyfill for it. + * Uses `array_is_list` when available and falls back to a PHP 7.2-compatible implementation. * * @see https://www.php.net/manual/en/function.array-is-list.php#126794 * @@ -53,6 +53,10 @@ public static function simpleDot(array $array): array */ public static function isList(array $array): bool { + if (\function_exists('array_is_list')) { + return array_is_list($array); + } + $i = 0; foreach ($array as $k => $v) { diff --git a/tests/DataCollection/KeyValueDataFilterTest.php b/tests/DataCollection/KeyValueDataFilterTest.php index 4352e28fa..ea56ad62a 100644 --- a/tests/DataCollection/KeyValueDataFilterTest.php +++ b/tests/DataCollection/KeyValueDataFilterTest.php @@ -96,6 +96,23 @@ public function testFilterKeyValueDataFiltersNestedData(): void ], $filtered); } + public function testFilterHttpBodyDataFiltersSensitiveAndUnkeyedValues(): void + { + $this->assertSame([ + [ + 'password' => '[Filtered]', + 'name' => 'alice', + ], + '[Filtered]', + ], KeyValueDataFilter::filterHttpBodyData([ + [ + 'password' => 'secret', + 'name' => 'alice', + ], + 'unkeyed secret', + ])); + } + public function testFilterHeadersReturnsNullWhenCollectionIsOff(): void { $behavior = ['mode' => 'off', 'terms' => ['x-request-id']]; diff --git a/tests/Tracing/GuzzleTracingMiddlewareTest.php b/tests/Tracing/GuzzleTracingMiddlewareTest.php index becfa84a8..f8c2106c2 100644 --- a/tests/Tracing/GuzzleTracingMiddlewareTest.php +++ b/tests/Tracing/GuzzleTracingMiddlewareTest.php @@ -7,9 +7,12 @@ use GuzzleHttp\Promise\FulfilledPromise; use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Promise\RejectedPromise; +use GuzzleHttp\Psr7\FnStream; +use GuzzleHttp\Psr7\NoSeekStream; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\Uri; +use GuzzleHttp\Psr7\Utils; use PHPUnit\Framework\TestCase; use Sentry\ClientInterface; use Sentry\Event; @@ -19,7 +22,9 @@ use Sentry\State\Hub; use Sentry\State\Scope; use Sentry\Tracing\GuzzleTracingMiddleware; +use Sentry\Tracing\Span; use Sentry\Tracing\SpanStatus; +use Sentry\Tracing\Transaction; use Sentry\Tracing\TransactionContext; final class GuzzleTracingMiddlewareTest extends TestCase @@ -402,6 +407,424 @@ public function testTrace(Request $request, $expectedPromiseResult, array $expec $transaction->finish(); } + /** + * @dataProvider traceQueryStringDataProvider + * + * @param array $options + */ + public function testTraceFiltersQueryString(array $options, ?string $expectedQueryString): void + { + $rawQueryString = 'search=hello%20world&password=s%2Becret&custom=value'; + $sdkOptions = new Options(array_merge([ + 'traces_sample_rate' => 1, + ], $options)); + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn($sdkOptions); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $transaction = $hub->startTransaction(new TransactionContext()); + $hub->setSpan($transaction); + + $middleware = GuzzleTracingMiddleware::trace($hub); + $function = $middleware(function (Request $request) use ($rawQueryString): PromiseInterface { + $this->assertSame($rawQueryString, $request->getUri()->getQuery()); + + return new FulfilledPromise(new Response()); + }); + + /** @var PromiseInterface $promise */ + $promise = $function(new Request('GET', 'https://www.example.com?' . $rawQueryString), []); + $promise->wait(); + + $spanData = $this->getHttpSpan($transaction)->getData(); + $breadcrumbData = $this->getBreadcrumbData($hub); + + if ($expectedQueryString === null) { + $this->assertArrayNotHasKey('http.query', $spanData); + $this->assertArrayNotHasKey('http.query', $breadcrumbData); + } else { + $this->assertSame($expectedQueryString, $spanData['http.query']); + $this->assertSame($expectedQueryString, $breadcrumbData['http.query']); + } + } + + public function testTraceCollectsConfiguredOutgoingHttpData(): void + { + $sdkOptions = new Options([ + 'traces_sample_rate' => 1, + 'data_collection' => [], + ]); + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn($sdkOptions); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $transaction = $hub->startTransaction(new TransactionContext()); + $hub->setSpan($transaction); + + $response = new Response(200, [ + 'Content-Type' => 'application/x-www-form-urlencoded', + 'X-Response-Id' => 'response-123', + 'Set-Cookie' => [ + 'session_id=response-secret; Path=/; HttpOnly', + 'theme=light; Path=/', + ], + ], 'token=response-secret&status=ok'); + $middleware = GuzzleTracingMiddleware::trace($hub); + $function = $middleware(function (Request $request) use ($response): PromiseInterface { + $this->assertSame(0, $request->getBody()->tell()); + + return new FulfilledPromise($response); + }); + $request = new Request( + 'POST', + 'https://www.example.com/path?search=hello%20world&password=request-secret#fragment', + [ + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer request-secret', + 'Cookie' => 'session_id=request-secret; theme=dark', + ], + '[{"password":"request-secret","name":"Alice"},"unkeyed-request-secret"]' + ); + + /** @var PromiseInterface $promise */ + $promise = $function($request, []); + $promise->wait(); + + $this->assertSame(0, $request->getBody()->tell()); + $this->assertSame(0, $response->getBody()->tell()); + + $expectedSharedData = [ + 'url.full' => 'https://www.example.com/path?search=hello%20world&password=%5BFiltered%5D#fragment', + 'http.query' => 'search=hello%20world&password=[Filtered]', + ]; + $expectedSpanData = [ + 'http.request.header.content-type' => ['application/json'], + 'http.request.header.authorization' => ['[Filtered]'], + 'http.request.header.cookie' => ['[Filtered]'], + 'http.request.body.data' => [ + [ + 'password' => '[Filtered]', + 'name' => 'Alice', + ], + '[Filtered]', + ], + 'http.response.header.content-type' => ['application/x-www-form-urlencoded'], + 'http.response.header.x-response-id' => ['response-123'], + 'http.response.header.set-cookie' => ['[Filtered]', '[Filtered]'], + 'http.response.body.data' => [ + 'token' => '[Filtered]', + 'status' => 'ok', + ], + ]; + $spanData = $this->getHttpSpan($transaction)->getData(); + $breadcrumbData = $this->getBreadcrumbData($hub); + + foreach ($expectedSharedData as $key => $value) { + $this->assertSame($value, $spanData[$key]); + $this->assertSame($value, $breadcrumbData[$key]); + } + foreach ($expectedSpanData as $key => $value) { + $this->assertSame($value, $spanData[$key]); + $this->assertArrayNotHasKey($key, $breadcrumbData); + } + $this->assertSame($expectedSharedData['url.full'], $breadcrumbData['url']); + $this->assertStringNotContainsString('request-secret', json_encode($spanData)); + $this->assertStringNotContainsString('response-secret', json_encode($spanData)); + } + + public function testTraceDoesNotConsumeNonSeekableBodies(): void + { + $sdkOptions = new Options([ + 'traces_sample_rate' => 1, + 'data_collection' => [], + ]); + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn($sdkOptions); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $transaction = $hub->startTransaction(new TransactionContext()); + $hub->setSpan($transaction); + + $requestBody = new NoSeekStream(Utils::streamFor('{"request":"body"}')); + $responseBody = new NoSeekStream(Utils::streamFor('{"response":"body"}')); + $response = new Response(200, ['Content-Type' => 'application/json'], $responseBody); + $middleware = GuzzleTracingMiddleware::trace($hub); + $function = $middleware(function (Request $request) use ($response): PromiseInterface { + $this->assertSame('{"request":"body"}', $request->getBody()->getContents()); + + return new FulfilledPromise($response); + }); + + /** @var PromiseInterface $promise */ + $promise = $function(new Request( + 'POST', + 'https://www.example.com', + ['Content-Type' => 'application/json'], + $requestBody + ), []); + $promiseResult = $promise->wait(); + + $this->assertSame($response, $promiseResult); + $this->assertSame('{"response":"body"}', $promiseResult->getBody()->getContents()); + + $spanData = $this->getHttpSpan($transaction)->getData(); + $this->assertArrayNotHasKey('http.request.body.data', $spanData); + $this->assertArrayNotHasKey('http.response.body.data', $spanData); + } + + public function testTraceSkipsBodiesLargerThanTheirLimits(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn(new Options([ + 'traces_sample_rate' => 1, + 'data_collection' => [], + ])); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $transaction = $hub->startTransaction(new TransactionContext()); + $hub->setSpan($transaction); + + $oversizedRequestBody = str_repeat('a', 10001); + $oversizedResponseBody = str_repeat('a', 100001); + $response = new Response(200, ['Content-Type' => 'application/json'], $oversizedResponseBody); + $middleware = GuzzleTracingMiddleware::trace($hub); + $function = $middleware(static function () use ($response): PromiseInterface { + return new FulfilledPromise($response); + }); + + /** @var PromiseInterface $promise */ + $promise = $function(new Request( + 'POST', + 'https://www.example.com', + ['Content-Type' => 'application/json'], + $oversizedRequestBody + ), []); + $promise->wait(); + + $spanData = $this->getHttpSpan($transaction)->getData(); + $this->assertArrayNotHasKey('http.request.body.data', $spanData); + $this->assertArrayNotHasKey('http.response.body.data', $spanData); + } + + /** + * @dataProvider httpBodySafetyLimitDataProvider + */ + public function testTraceAppliesHttpBodySafetyLimit(int $bodySize, bool $shouldCollect): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn(new Options([ + 'traces_sample_rate' => 1, + 'max_request_body_size' => 'always', + 'data_collection' => [], + ])); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $transaction = $hub->startTransaction(new TransactionContext()); + $hub->setSpan($transaction); + + $rawBody = str_repeat('a', $bodySize); + $requestBody = FnStream::decorate(Utils::streamFor($rawBody), [ + 'getSize' => static function (): ?int { + return null; + }, + ]); + $responseBody = FnStream::decorate(Utils::streamFor($rawBody), [ + 'getSize' => static function (): ?int { + return null; + }, + ]); + $response = new Response(200, ['Content-Type' => 'application/json'], $responseBody); + $middleware = GuzzleTracingMiddleware::trace($hub); + $function = $middleware(static function () use ($response): PromiseInterface { + return new FulfilledPromise($response); + }); + + /** @var PromiseInterface $promise */ + $promise = $function(new Request( + 'POST', + 'https://www.example.com', + ['Content-Type' => 'application/json'], + $requestBody + ), []); + $promise->wait(); + + $this->assertSame(0, $requestBody->tell()); + $this->assertSame(0, $responseBody->tell()); + + $spanData = $this->getHttpSpan($transaction)->getData(); + if ($shouldCollect) { + $this->assertSame('[Filtered]', $spanData['http.request.body.data']); + $this->assertSame('[Filtered]', $spanData['http.response.body.data']); + } else { + $this->assertArrayNotHasKey('http.request.body.data', $spanData); + $this->assertArrayNotHasKey('http.response.body.data', $spanData); + } + } + + public static function httpBodySafetyLimitDataProvider(): iterable + { + yield 'at 100 KB safety limit' => [100000, true]; + yield 'over 100 KB safety limit' => [100001, false]; + } + + public function testTraceRespectsDisabledOutgoingHttpDataCollection(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn(new Options([ + 'traces_sample_rate' => 1, + 'data_collection' => [ + 'cookies' => ['mode' => 'off'], + 'http_headers' => [ + 'request' => ['mode' => 'off'], + 'response' => ['mode' => 'off'], + ], + 'http_bodies' => [], + 'url_query_params' => ['mode' => 'off'], + ], + ])); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $transaction = $hub->startTransaction(new TransactionContext()); + $hub->setSpan($transaction); + + $response = new Response(200, [ + 'Content-Type' => 'application/json', + 'Set-Cookie' => 'session_id=response-secret', + ], '{"token":"response-secret"}'); + $middleware = GuzzleTracingMiddleware::trace($hub); + $function = $middleware(static function () use ($response): PromiseInterface { + return new FulfilledPromise($response); + }); + + /** @var PromiseInterface $promise */ + $promise = $function(new Request( + 'POST', + 'https://www.example.com?password=request-secret', + [ + 'Content-Type' => 'application/json', + 'Cookie' => 'session_id=request-secret', + ], + '{"password":"request-secret"}' + ), []); + $promise->wait(); + + $spanData = $this->getHttpSpan($transaction)->getData(); + $breadcrumbData = $this->getBreadcrumbData($hub); + + foreach ([ + 'http.query', + 'http.request.header.content-type', + 'http.request.header.cookie', + 'http.request.body.data', + 'http.response.header.content-type', + 'http.response.header.set-cookie', + 'http.response.body.data', + ] as $key) { + $this->assertArrayNotHasKey($key, $spanData); + $this->assertArrayNotHasKey($key, $breadcrumbData); + } + } + + /** + * @return array + */ + private function getBreadcrumbData(Hub $hub): array + { + $event = Event::createEvent(); + $hub->configureScope(static function (Scope $scope) use ($event): void { + $scope->applyToEvent($event); + }); + $this->assertCount(1, $event->getBreadcrumbs()); + + return $event->getBreadcrumbs()[0]->getMetadata(); + } + + private function getHttpSpan(Transaction $transaction): Span + { + $this->assertNotNull($transaction->getSpanRecorder()); + $httpSpans = array_values(array_filter( + $transaction->getSpanRecorder()->getSpans(), + static function (Span $span): bool { + return $span->getOp() === 'http.client'; + } + )); + $this->assertCount(1, $httpSpans); + + return $httpSpans[0]; + } + + public static function traceQueryStringDataProvider(): iterable + { + yield 'legacy behavior is unchanged' => [ + [], + 'search=hello%20world&password=s%2Becret&custom=value', + ]; + + yield 'default data collection filters mandatory sensitive values' => [ + ['data_collection' => []], + 'search=hello%20world&password=[Filtered]&custom=value', + ]; + + yield 'collection can be disabled' => [ + [ + 'data_collection' => [ + 'url_query_params' => [ + 'mode' => 'off', + ], + ], + ], + null, + ]; + + yield 'allow list filters values not matching configured terms' => [ + [ + 'data_collection' => [ + 'url_query_params' => [ + 'mode' => 'allowList', + 'terms' => ['custom'], + ], + ], + ], + 'search=[Filtered]&password=[Filtered]&custom=value', + ]; + + yield 'deny list combines mandatory and custom terms' => [ + [ + 'data_collection' => [ + 'url_query_params' => [ + 'mode' => 'denyList', + 'terms' => ['custom'], + ], + ], + ], + 'search=hello%20world&password=[Filtered]&custom=[Filtered]', + ]; + } + public static function traceDataProvider(): iterable { yield [