WS-7 PR-2 commit 1. Wires sentry-laravel into the app behind a config-only no-op when SENTRY_DSN_BACKEND is empty (RFC §3.3). - composer require sentry/sentry-laravel ^4.15 (resolved 4.25.1) - config/sentry.php: DSN env mapped to SENTRY_DSN_BACKEND, environment falls back to APP_ENV, traces/profiles forced to 0.0 (RFC §2 amendment B), send_default_pii hard-pinned false, before_send to SentryEventScrubber, ignore_exceptions covers ValidationException / AuthenticationException / AuthorizationException. - app/Services/Observability/SentryEventScrubber.php: recursive body / header / query-string scrubber + form_values wholesale replacement + HttpException sub-500 drop (status filter that ignore_exceptions cannot do class-only). Max-depth guard against malicious payloads. - app/Enums/Observability/ActorType.php: enum + resolver for §3.6 actor_type tag (consumed by BindSentryContext in commit 2). - tests/Feature/Observability/PiiScrubbingTest.php: 20 cases. - api/.env.example: SENTRY_DSN_BACKEND + SENTRY_RELEASE entries. Larastan: clean. Test count: 1487 to 1507. 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 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' => $this->scrubBody($request['data'] ?? []),
|
|
'headers' => $this->scrubHeaders($request['headers'] ?? []),
|
|
'query_string' => $this->scrubQueryString($request['query_string'] ?? ''),
|
|
'cookies' => self::SCRUBBED,
|
|
]));
|
|
}
|
|
|
|
return $event;
|
|
}
|
|
|
|
/**
|
|
* @param mixed $data
|
|
* @return mixed
|
|
*/
|
|
private 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] = $this->scrubBody($value, $depth + 1);
|
|
}
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|string $headers
|
|
* @return array<string, mixed>|string
|
|
*/
|
|
private 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 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);
|
|
}
|
|
}
|