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>
239 lines
8.2 KiB
PHP
239 lines
8.2 KiB
PHP
<?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 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(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(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(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);
|
|
}
|
|
}
|