feat: sentry-laravel install + scrubber + ignored exceptions
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>
This commit is contained in:
137
api/app/Services/Observability/SentryEventScrubber.php
Normal file
137
api/app/Services/Observability/SentryEventScrubber.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user