The scrubber is fully stateless. Container-resolution per event was overhead without value, the closure indirection polluted the config layer with executable logic, and stack traces showed an anonymous closure frame instead of the class name. - SentryEventScrubber::scrub() and its private helpers all become static methods. No instance fields, so the change is mechanical. - config/sentry.php before_send switches from a closure that calls app() to PHP array-callable notation [Class, method]. Symfony OptionsResolver accepts array-callables for static methods. - PiiScrubbingTest swaps (new SentryEventScrubber)->scrub(...) for SentryEventScrubber::scrub(...). Semantics unchanged. Tests 1537 unchanged. Larastan and Pint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
138 lines
3.8 KiB
PHP
138 lines
3.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Observability;
|
|
|
|
use Sentry\Event;
|
|
use Sentry\EventHint;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
|
|
/**
|
|
* PII scrubber registered as Sentry's `before_send` hook (RFC-WS-7 §3.7).
|
|
*
|
|
* Two responsibilities:
|
|
* - Drop sub-500 HttpExceptions: ignore_exceptions in config/sentry.php is
|
|
* class-only; status-based filtering must happen here.
|
|
* - Strip sensitive request data from outgoing events: body keys, headers,
|
|
* query string parameters, and form_values payloads (definitionally PII
|
|
* in Crewli — entire payload is replaced wholesale).
|
|
*/
|
|
final class SentryEventScrubber
|
|
{
|
|
private const SENSITIVE_BODY_KEYS = [
|
|
'password', 'password_confirmation', 'current_password',
|
|
'token', 'api_key', 'secret', 'webhook_secret', 'dsn',
|
|
'signature', 'authorization', 'cookie', 'bearer',
|
|
'iban', 'bic', 'passport_number', 'bsn',
|
|
];
|
|
|
|
private const SENSITIVE_HEADERS = [
|
|
'authorization', 'cookie', 'set-cookie',
|
|
'x-api-key', 'x-impersonation-token',
|
|
];
|
|
|
|
private const SENSITIVE_QUERY_KEYS = [
|
|
'token', 'api_key',
|
|
];
|
|
|
|
private const SCRUBBED = '[scrubbed]';
|
|
|
|
private const FORM_VALUES_KEY = 'form_values';
|
|
|
|
private const FORM_VALUES_REPLACEMENT = '[scrubbed_form_values]';
|
|
|
|
private const MAX_DEPTH = 10;
|
|
|
|
public static function scrub(Event $event, ?EventHint $hint = null): ?Event
|
|
{
|
|
if ($hint?->exception instanceof HttpException && $hint->exception->getStatusCode() < 500) {
|
|
return null;
|
|
}
|
|
|
|
$request = $event->getRequest();
|
|
|
|
if ($request !== []) {
|
|
$event->setRequest(array_merge($request, [
|
|
'data' => self::scrubBody($request['data'] ?? []),
|
|
'headers' => self::scrubHeaders($request['headers'] ?? []),
|
|
'query_string' => self::scrubQueryString($request['query_string'] ?? ''),
|
|
'cookies' => self::SCRUBBED,
|
|
]));
|
|
}
|
|
|
|
return $event;
|
|
}
|
|
|
|
/**
|
|
* @param mixed $data
|
|
* @return mixed
|
|
*/
|
|
private static function scrubBody($data, int $depth = 0)
|
|
{
|
|
if (! is_array($data)) {
|
|
return $data;
|
|
}
|
|
|
|
if ($depth > self::MAX_DEPTH) {
|
|
return ['[max_depth]'];
|
|
}
|
|
|
|
foreach ($data as $key => $value) {
|
|
if (is_string($key) && strtolower($key) === self::FORM_VALUES_KEY) {
|
|
$data[$key] = self::FORM_VALUES_REPLACEMENT;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (is_string($key) && in_array(strtolower($key), self::SENSITIVE_BODY_KEYS, true)) {
|
|
$data[$key] = self::SCRUBBED;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (is_array($value)) {
|
|
$data[$key] = self::scrubBody($value, $depth + 1);
|
|
}
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|string $headers
|
|
* @return array<string, mixed>|string
|
|
*/
|
|
private static function scrubHeaders($headers)
|
|
{
|
|
if (! is_array($headers)) {
|
|
return $headers;
|
|
}
|
|
|
|
foreach (array_keys($headers) as $name) {
|
|
if (in_array(strtolower((string) $name), self::SENSITIVE_HEADERS, true)) {
|
|
$headers[$name] = self::SCRUBBED;
|
|
}
|
|
}
|
|
|
|
return $headers;
|
|
}
|
|
|
|
private static function scrubQueryString(string $queryString): string
|
|
{
|
|
if ($queryString === '') {
|
|
return '';
|
|
}
|
|
|
|
parse_str($queryString, $parsed);
|
|
|
|
foreach ($parsed as $key => $value) {
|
|
if (is_string($key) && in_array(strtolower($key), self::SENSITIVE_QUERY_KEYS, true)) {
|
|
$parsed[$key] = self::SCRUBBED;
|
|
}
|
|
}
|
|
|
|
return http_build_query($parsed);
|
|
}
|
|
}
|