Skip to content
Open
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
171 changes: 171 additions & 0 deletions src/DataCollection/KeyValueDataFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<?php

declare(strict_types=1);

namespace Sentry\DataCollection;

/**
* @internal
*
* @phpstan-type KeyValueCollectionBehavior array{mode: 'off'|'denyList'|'allowList', terms: string[]}
*/
final class KeyValueDataFilter
{
private const SENSITIVE_DATA_DENYLIST = [
'auth',
'token',
'secret',
'password',
'passwd',
'pwd',
'key',
'jwt',
'bearer',
'sso',
'saml',
'csrf',
'xsrf',
'credentials',
'session',
'sid',
'identity',
];

/**
* Cookie headers that must always be filtered when headers are collected.
*/
private const SENSITIVE_HEADERS = [
'cookie',
'set-cookie',
];

/**
* @var string|null
*/
private static $sensitiveDataDenyListRegex;

private function __construct()
{
}

/**
* @param array<array-key, string[]> $headers
*
* @phpstan-param KeyValueCollectionBehavior $behavior
*
* @return array<string, string[]>|null Returns null when collection is off
*/
public static function filterHeaders(array $headers, array $behavior): ?array
{
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::shouldFilterValue($name, $behavior)) {
foreach ($values as $headerLine => $headerValue) {
$values[$headerLine] = '[Filtered]';
}
}

$filtered[$name] = $values;
}

return $filtered;
}

/**
* @param array<array-key, mixed> $data
*
* @phpstan-param KeyValueCollectionBehavior $behavior
*
* @return array<string, mixed>|null Returns null when collection is off
*/
public static function filterKeyValueData(array $data, array $behavior): ?array
{
if ($behavior['mode'] === 'off') {
return null;
}

$filtered = [];

/** @mago-ignore analysis:mixed-assignment */
foreach ($data as $key => $value) {
$key = (string) $key;
$filtered[$key] = self::shouldFilterValue($key, $behavior) ? '[Filtered]' : $value;
}

return $filtered;
}

/**
* @phpstan-param KeyValueCollectionBehavior $behavior
*/
public static function filterQueryString(string $queryString, array $behavior): ?string
{
if ($behavior['mode'] === 'off') {
return null;
}

$parts = explode('&', $queryString);

foreach ($parts as $index => $part) {
$separatorPosition = strpos($part, '=');
$encodedKey = $separatorPosition === false ? $part : substr($part, 0, $separatorPosition);
$key = urldecode($encodedKey);

if (self::shouldFilterValue($key, $behavior)) {
$parts[$index] = $encodedKey . '=[Filtered]';
Comment thread
sentry[bot] marked this conversation as resolved.
}
}

return implode('&', $parts);
}

/**
* @phpstan-param KeyValueCollectionBehavior $behavior
*/
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']);
}

return self::matchesAnyTerm($key, $behavior['terms']);
}
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

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
{
$key = strtolower($key);

foreach ($terms as $term) {
if (strpos($key, strtolower($term)) !== false) {
return true;
}
}

return false;
}
}
186 changes: 186 additions & 0 deletions tests/DataCollection/KeyValueDataFilterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<?php

declare(strict_types=1);

namespace Sentry\Tests\DataCollection;

use PHPUnit\Framework\TestCase;
use Sentry\DataCollection\KeyValueDataFilter;

final class KeyValueDataFilterTest extends TestCase
{
public function testFilterKeyValueDataReturnsNullWhenCollectionIsOff(): void
{
$behavior = ['mode' => '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);
}
}
Loading