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:
238
api/tests/Feature/Observability/PiiScrubbingTest.php
Normal file
238
api/tests/Feature/Observability/PiiScrubbingTest.php
Normal file
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Observability;
|
||||
|
||||
use App\Services\Observability\SentryEventScrubber;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use RuntimeException;
|
||||
use Sentry\Event;
|
||||
use Sentry\EventHint;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class PiiScrubbingTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $request
|
||||
*/
|
||||
private function scrubEventWithRequest(array $request, ?EventHint $hint = null): ?Event
|
||||
{
|
||||
$event = Event::createEvent();
|
||||
$event->setRequest($request);
|
||||
|
||||
return (new SentryEventScrubber)->scrub($event, $hint);
|
||||
}
|
||||
|
||||
public function test_password_in_request_body_is_scrubbed(): void
|
||||
{
|
||||
$event = $this->scrubEventWithRequest([
|
||||
'data' => ['email' => 'a@b.test', 'password' => 'sup3rsecret!'],
|
||||
]);
|
||||
|
||||
$this->assertSame('[scrubbed]', $event->getRequest()['data']['password']);
|
||||
$this->assertSame('a@b.test', $event->getRequest()['data']['email']);
|
||||
}
|
||||
|
||||
public function test_password_confirmation_is_scrubbed(): void
|
||||
{
|
||||
$event = $this->scrubEventWithRequest([
|
||||
'data' => ['password_confirmation' => 'p@ss', 'current_password' => 'oldpass'],
|
||||
]);
|
||||
|
||||
$this->assertSame('[scrubbed]', $event->getRequest()['data']['password_confirmation']);
|
||||
$this->assertSame('[scrubbed]', $event->getRequest()['data']['current_password']);
|
||||
}
|
||||
|
||||
public function test_authorization_header_is_scrubbed(): void
|
||||
{
|
||||
$event = $this->scrubEventWithRequest([
|
||||
'headers' => ['Authorization' => 'Bearer abc.def.ghi'],
|
||||
]);
|
||||
|
||||
$this->assertSame('[scrubbed]', $event->getRequest()['headers']['Authorization']);
|
||||
}
|
||||
|
||||
public function test_cookie_header_is_scrubbed(): void
|
||||
{
|
||||
$event = $this->scrubEventWithRequest([
|
||||
'headers' => ['Cookie' => 'crewli_session=abcd'],
|
||||
]);
|
||||
|
||||
$this->assertSame('[scrubbed]', $event->getRequest()['headers']['Cookie']);
|
||||
}
|
||||
|
||||
public function test_x_impersonation_token_header_is_scrubbed(): void
|
||||
{
|
||||
$event = $this->scrubEventWithRequest([
|
||||
'headers' => ['X-Impersonation-Token' => 'imp_token_xyz'],
|
||||
]);
|
||||
|
||||
$this->assertSame('[scrubbed]', $event->getRequest()['headers']['X-Impersonation-Token']);
|
||||
}
|
||||
|
||||
public function test_form_values_payload_is_replaced_wholesale(): void
|
||||
{
|
||||
$event = $this->scrubEventWithRequest([
|
||||
'data' => [
|
||||
'form_values' => [
|
||||
'email' => 'sensitive@example.com',
|
||||
'dietary' => 'vegan',
|
||||
'phone' => '+31612345678',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$data = $event->getRequest()['data'];
|
||||
$this->assertSame('[scrubbed_form_values]', $data['form_values']);
|
||||
$serialised = json_encode($data, JSON_THROW_ON_ERROR);
|
||||
$this->assertStringNotContainsString('sensitive@example.com', $serialised);
|
||||
$this->assertStringNotContainsString('vegan', $serialised);
|
||||
$this->assertStringNotContainsString('+31612345678', $serialised);
|
||||
}
|
||||
|
||||
public function test_token_query_string_is_scrubbed(): void
|
||||
{
|
||||
$event = $this->scrubEventWithRequest([
|
||||
'query_string' => 'token=abc123&keep=me',
|
||||
]);
|
||||
|
||||
$qs = $event->getRequest()['query_string'];
|
||||
$this->assertStringContainsString('token=%5Bscrubbed%5D', $qs);
|
||||
$this->assertStringContainsString('keep=me', $qs);
|
||||
}
|
||||
|
||||
public function test_api_key_query_string_is_scrubbed(): void
|
||||
{
|
||||
$event = $this->scrubEventWithRequest([
|
||||
'query_string' => 'api_key=xyz&page=2',
|
||||
]);
|
||||
|
||||
$qs = $event->getRequest()['query_string'];
|
||||
$this->assertStringContainsString('api_key=%5Bscrubbed%5D', $qs);
|
||||
$this->assertStringContainsString('page=2', $qs);
|
||||
}
|
||||
|
||||
public function test_iban_in_nested_body_is_scrubbed(): void
|
||||
{
|
||||
$event = $this->scrubEventWithRequest([
|
||||
'data' => [
|
||||
'profile' => [
|
||||
'address' => [
|
||||
'iban' => 'NL91ABNA0417164300',
|
||||
'street' => 'Damrak 1',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$address = $event->getRequest()['data']['profile']['address'];
|
||||
$this->assertSame('[scrubbed]', $address['iban']);
|
||||
$this->assertSame('Damrak 1', $address['street']);
|
||||
}
|
||||
|
||||
public function test_bsn_in_nested_body_is_scrubbed(): void
|
||||
{
|
||||
$event = $this->scrubEventWithRequest([
|
||||
'data' => ['kyc' => ['passport_number' => 'NX1234567', 'bsn' => '123456789']],
|
||||
]);
|
||||
|
||||
$kyc = $event->getRequest()['data']['kyc'];
|
||||
$this->assertSame('[scrubbed]', $kyc['passport_number']);
|
||||
$this->assertSame('[scrubbed]', $kyc['bsn']);
|
||||
}
|
||||
|
||||
public function test_send_default_pii_is_false(): void
|
||||
{
|
||||
$this->assertFalse(config('sentry.send_default_pii'));
|
||||
}
|
||||
|
||||
public function test_validation_exception_is_in_ignore_list(): void
|
||||
{
|
||||
$this->assertContains(ValidationException::class, config('sentry.ignore_exceptions'));
|
||||
}
|
||||
|
||||
public function test_authentication_exception_is_in_ignore_list(): void
|
||||
{
|
||||
$this->assertContains(AuthenticationException::class, config('sentry.ignore_exceptions'));
|
||||
}
|
||||
|
||||
public function test_authorization_exception_is_in_ignore_list(): void
|
||||
{
|
||||
$this->assertContains(AuthorizationException::class, config('sentry.ignore_exceptions'));
|
||||
}
|
||||
|
||||
public function test_http_exception_404_is_dropped_by_scrubber(): void
|
||||
{
|
||||
$event = Event::createEvent();
|
||||
$hint = EventHint::fromArray(['exception' => new NotFoundHttpException]);
|
||||
|
||||
$this->assertNull((new SentryEventScrubber)->scrub($event, $hint));
|
||||
}
|
||||
|
||||
public function test_http_exception_500_is_captured(): void
|
||||
{
|
||||
$event = Event::createEvent();
|
||||
$hint = EventHint::fromArray(['exception' => new HttpException(500, 'boom')]);
|
||||
|
||||
$this->assertNotNull((new SentryEventScrubber)->scrub($event, $hint));
|
||||
}
|
||||
|
||||
public function test_throwable_from_controller_is_captured(): void
|
||||
{
|
||||
$event = Event::createEvent();
|
||||
$hint = EventHint::fromArray(['exception' => new RuntimeException('programmer error')]);
|
||||
|
||||
$this->assertNotNull((new SentryEventScrubber)->scrub($event, $hint));
|
||||
}
|
||||
|
||||
public function test_form_values_replacement_blocks_attempts_to_smuggle_pii(): void
|
||||
{
|
||||
// form_values is a wholesale replace — even if the payload is deeply
|
||||
// nested, the entire branch is wiped so individual keys cannot leak.
|
||||
$event = $this->scrubEventWithRequest([
|
||||
'data' => [
|
||||
'submission' => [
|
||||
'form_values' => [
|
||||
'medical' => 'celiac',
|
||||
'children' => [
|
||||
['name' => 'Bobby', 'allergy' => 'peanuts'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$serialised = json_encode($event->getRequest()['data'], JSON_THROW_ON_ERROR);
|
||||
$this->assertStringContainsString('[scrubbed_form_values]', $serialised);
|
||||
$this->assertStringNotContainsString('celiac', $serialised);
|
||||
$this->assertStringNotContainsString('Bobby', $serialised);
|
||||
$this->assertStringNotContainsString('peanuts', $serialised);
|
||||
}
|
||||
|
||||
public function test_cookies_request_field_is_replaced(): void
|
||||
{
|
||||
$event = $this->scrubEventWithRequest([
|
||||
'cookies' => ['SESSION' => 'abcd', 'tracking' => 'xyz'],
|
||||
]);
|
||||
|
||||
$this->assertSame('[scrubbed]', $event->getRequest()['cookies']);
|
||||
}
|
||||
|
||||
public function test_max_depth_guard_prevents_unbounded_recursion(): void
|
||||
{
|
||||
$deep = ['v' => 'leaf'];
|
||||
for ($i = 0; $i < 15; $i++) {
|
||||
$deep = ['nest' => $deep];
|
||||
}
|
||||
|
||||
$event = $this->scrubEventWithRequest(['data' => $deep]);
|
||||
|
||||
$serialised = json_encode($event->getRequest()['data'], JSON_THROW_ON_ERROR);
|
||||
$this->assertStringContainsString('[max_depth]', $serialised);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user