Files
crewli/api/app/Services/Observability/SentryEventScrubber.php
bert.hausmans 5980c36ae4 refactor: SentryEventScrubber static + config array notation
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>
2026-05-06 12:42:25 +02:00

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