Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions src/DataCollection/RequestDataCollector.php
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;
}

Comment thread
Litarnus marked this conversation as resolved.
return \is_array($body) ? $body : '[Filtered]';
Comment thread
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;
}
}
168 changes: 168 additions & 0 deletions src/DataCollection/SensitiveDataScrubber.php
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',
];
Comment thread
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scrubber skips nested sensitive keys

Medium Severity

scrubKeyValueData only inspects top-level keys and copies nested array values unchanged. Sensitive nested keys (for example PHP array cookies like user[password]) are therefore left intact, despite this class being introduced for recursive scrubbing that framework SDKs are expected to reuse.

Fix in Cursor Fix in Web

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;
}
}
Loading