-
-
Notifications
You must be signed in to change notification settings - Fork 473
feat(pii): add data collection for HTTP requests #2167
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,189 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Sentry\DataCollection; | ||
|
|
||
| /** | ||
| * This collector exposes methods to collect data based on pii/data_collection settings. | ||
| * | ||
| * It supports both the legacy sendDefaultPii option and the new data_collection spec | ||
| * so that the caller side doesn't have to deal with it. | ||
| * | ||
| * The collector can also be used in the framework SDKs without having to re-implement the | ||
| * logic and test everything again. Ideally they only implement thin adapters that extract | ||
| * plain header/cookie/query/body data from their request objects and feed it through this | ||
| * class. | ||
| * | ||
| * @internal | ||
| */ | ||
| final class RequestDataCollector | ||
| { | ||
| /** | ||
| * List of headers in lowercase that will be sanitized when using the legacy pii flag. | ||
| */ | ||
| public const DEFAULT_PII_SANITIZE_HEADERS = [ | ||
| 'authorization', | ||
| 'proxy-authorization', | ||
| 'cookie', | ||
| 'set-cookie', | ||
| 'x-forwarded-for', | ||
| 'x-real-ip', | ||
| ]; | ||
|
|
||
| /** | ||
| * @var DataCollectionOptions|null | ||
| */ | ||
| private $dataCollection; | ||
|
|
||
| /** | ||
| * @var bool | ||
| */ | ||
| private $sendDefaultPii; | ||
|
|
||
| /** | ||
| * @var string[] | ||
| */ | ||
| private $piiSanitizeHeaders; | ||
|
|
||
| /** | ||
| * @param DataCollectionOptions|null $dataCollection The data collection configuration, or null to use the legacy `send_default_pii` behavior | ||
| * @param bool $sendDefaultPii The value of the legacy `send_default_pii` option | ||
| * @param string[] $piiSanitizeHeaders The lowercase names of the headers to sanitize in legacy mode | ||
| */ | ||
| public function __construct( | ||
| ?DataCollectionOptions $dataCollection, | ||
| bool $sendDefaultPii, | ||
| array $piiSanitizeHeaders = self::DEFAULT_PII_SANITIZE_HEADERS | ||
| ) { | ||
| $this->dataCollection = $dataCollection; | ||
| $this->sendDefaultPii = $sendDefaultPii; | ||
| $this->piiSanitizeHeaders = $piiSanitizeHeaders; | ||
| } | ||
|
|
||
| public function usesDataCollection(): bool | ||
| { | ||
| return $this->dataCollection !== null; | ||
| } | ||
|
|
||
| public function shouldCollectUserInfo(): bool | ||
| { | ||
| if ($this->dataCollection === null) { | ||
| return $this->sendDefaultPii; | ||
| } | ||
|
|
||
| return $this->dataCollection->shouldCollectUserInfo(); | ||
| } | ||
|
|
||
| public function collectQueryString(string $queryString): ?string | ||
| { | ||
| if ($this->dataCollection === null) { | ||
| // The legacy behavior collects the query string untouched and | ||
| // skips it when it evaluates to a falsy value | ||
| return $queryString ?: null; | ||
| } | ||
|
|
||
| $behavior = $this->dataCollection->getQueryParams(); | ||
|
|
||
| if ($behavior['mode'] === 'off' || $queryString === '') { | ||
| return null; | ||
| } | ||
|
|
||
| return SensitiveDataScrubber::scrubQueryString($queryString, $behavior); | ||
| } | ||
|
|
||
| /** | ||
| * @param array<array-key, mixed> $cookies | ||
| * | ||
| * @return array<array-key, mixed>|null | ||
| */ | ||
| public function collectCookies(array $cookies): ?array | ||
| { | ||
| if ($this->dataCollection === null) { | ||
| return $this->sendDefaultPii ? $cookies : null; | ||
| } | ||
|
|
||
| $behavior = $this->dataCollection->getCookies(); | ||
|
|
||
| if ($behavior['mode'] === 'off') { | ||
| return null; | ||
| } | ||
|
|
||
| return SensitiveDataScrubber::scrubKeyValueData($cookies, $behavior); | ||
| } | ||
|
|
||
| /** | ||
| * @param array<array-key, string[]> $headers | ||
| * | ||
| * @return array<array-key, string[]>|null | ||
| */ | ||
| public function collectHeaders(array $headers): ?array | ||
| { | ||
| if ($this->dataCollection === null) { | ||
| return $this->sendDefaultPii ? $headers : $this->sanitizeLegacyHeaders($headers); | ||
| } | ||
|
|
||
| $behavior = $this->dataCollection->getHttpHeaders()['request']; | ||
|
|
||
| if ($behavior['mode'] === 'off') { | ||
| return null; | ||
| } | ||
|
|
||
| return SensitiveDataScrubber::scrubHeaders($headers, $behavior); | ||
| } | ||
|
|
||
| public function shouldCollectRequestBody(): bool | ||
| { | ||
| if ($this->dataCollection === null) { | ||
| // The legacy behavior always captures the body, subject only to | ||
| // the `max_request_body_size` option enforced by the caller | ||
| return true; | ||
| } | ||
|
|
||
| return \in_array('incomingRequest', $this->dataCollection->getHttpBodies(), true); | ||
| } | ||
|
|
||
| /** | ||
| * @param mixed $body | ||
| * | ||
| * @return mixed | ||
| */ | ||
| public function collectRequestBody($body) | ||
| { | ||
| if (empty($body)) { | ||
| return null; | ||
| } | ||
|
|
||
| if ($this->dataCollection === null) { | ||
| return $body; | ||
| } | ||
|
|
||
| if (!$this->shouldCollectRequestBody()) { | ||
| return null; | ||
| } | ||
|
|
||
| return \is_array($body) ? $body : '[Filtered]'; | ||
|
Litarnus marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /** | ||
| * @param array<array-key, string[]> $headers | ||
| * | ||
| * @return array<array-key, string[]> | ||
| */ | ||
| private function sanitizeLegacyHeaders(array $headers): array | ||
| { | ||
| foreach ($headers as $name => $values) { | ||
| $name = (string) $name; | ||
|
|
||
| if (!\in_array(strtolower($name), $this->piiSanitizeHeaders, true)) { | ||
| continue; | ||
| } | ||
|
|
||
| foreach ($values as $headerLine => $headerValue) { | ||
| $headers[$name][$headerLine] = '[Filtered]'; | ||
| } | ||
| } | ||
|
|
||
| return $headers; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,168 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Sentry\DataCollection; | ||
|
|
||
| /** | ||
| * @internal | ||
| * | ||
| * @phpstan-type KeyValueCollectionBehavior array{mode: 'off'|'denyList'|'allowList', terms: string[]} | ||
| */ | ||
| final class SensitiveDataScrubber | ||
| { | ||
| private const SENSITIVE_DATA_DENYLIST = [ | ||
| 'auth', | ||
| 'token', | ||
| 'secret', | ||
| 'password', | ||
| 'passwd', | ||
| 'pwd', | ||
| 'key', | ||
| 'jwt', | ||
| 'bearer', | ||
| 'sso', | ||
| 'saml', | ||
| 'csrf', | ||
| 'xsrf', | ||
| 'credentials', | ||
| 'session', | ||
| 'sid', | ||
| 'identity', | ||
| ]; | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * Headers in lowercase that are always scrubbed. The spec forbids sending | ||
| * raw Cookie/Set-Cookie header values even in allow-list mode; individual | ||
| * cookies are collected separately, subject to the cookies behavior. | ||
| * IP-carrying headers (x-forwarded-for, ...) are deliberately absent: the | ||
| * spec handles them through user-supplied extended deny terms instead. | ||
| */ | ||
| private const SENSITIVE_HEADERS = [ | ||
| 'cookie', | ||
| 'set-cookie', | ||
| ]; | ||
|
|
||
| /** | ||
| * @var string|null | ||
| */ | ||
| private static $sensitiveDataDenyListRegex; | ||
|
|
||
| /** | ||
| * This class contains only static methods and should not be instantiated. | ||
| */ | ||
| private function __construct() | ||
| { | ||
| } | ||
|
|
||
| /** | ||
| * @param array<array-key, string[]> $headers | ||
| * | ||
| * @phpstan-param KeyValueCollectionBehavior $behavior | ||
| * | ||
| * @return array<string, string[]> | ||
| */ | ||
| 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<array-key, mixed> $data | ||
| * | ||
| * @phpstan-param KeyValueCollectionBehavior $behavior | ||
| * | ||
| * @return array<string, mixed> | ||
| */ | ||
| 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; | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Scrubber skips nested sensitive keysMedium Severity
Reviewed by Cursor Bugbot for commit 65482dd. Configure here. |
||
|
|
||
| /** | ||
| * @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; | ||
| } | ||
| } | ||


Uh oh!
There was an error while loading. Please reload this page.