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:
@@ -78,3 +78,14 @@ SANCTUM_STATEFUL_DOMAINS=localhost:5174,localhost:5175
|
||||
# env-gate + this flag) keeps Telescope out even if one layer is
|
||||
# breached. See /dev-docs/TELESCOPE.md.
|
||||
TELESCOPE_ENABLED=false
|
||||
|
||||
# Sentry / GlitchTip (RFC-WS-7 §3.3, §3.4).
|
||||
# DSN routes events to the self-hosted GlitchTip project crewli-api.
|
||||
# Empty = SDK no-op — leave blank in local development. Source the real
|
||||
# value from the 1Password vault entry "Crewli / GlitchTip / DSNs"
|
||||
# (key SENTRY_DSN_BACKEND) for staging / production.
|
||||
SENTRY_DSN_BACKEND=
|
||||
# Release identifier in the form crewli-api@<short-sha>. The deploy
|
||||
# pipeline injects this per build; leave blank locally. Empty release
|
||||
# means events are still captured but won't carry release context.
|
||||
SENTRY_RELEASE=
|
||||
|
||||
55
api/app/Enums/Observability/ActorType.php
Normal file
55
api/app/Enums/Observability/ActorType.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\Observability;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Actor classification used as the `actor_type` Sentry tag (RFC-WS-7 §3.6).
|
||||
*
|
||||
* Resolution precedence (most specific first):
|
||||
* 1. Portal-token request → PORTAL_TOKEN
|
||||
* 2. Authenticated super_admin → SUPER_ADMIN
|
||||
* 3. Authenticated org_admin → ORGANIZER_ADMIN
|
||||
* 4. Authenticated volunteer (role match) → VOLUNTEER
|
||||
* 5. Other authenticated user → ORG_MEMBER
|
||||
* 6. None of the above → UNAUTHENTICATED
|
||||
*/
|
||||
enum ActorType: string
|
||||
{
|
||||
case ORGANIZER_ADMIN = 'organizer_admin';
|
||||
case SUPER_ADMIN = 'super_admin';
|
||||
case PORTAL_TOKEN = 'portal_token';
|
||||
case VOLUNTEER = 'volunteer';
|
||||
case ORG_MEMBER = 'org_member';
|
||||
case UNAUTHENTICATED = 'unauthenticated';
|
||||
|
||||
public static function resolve(?Authenticatable $user, ?Request $request): self
|
||||
{
|
||||
if ($request !== null && $request->attributes->get('portal_context') !== null) {
|
||||
return self::PORTAL_TOKEN;
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return self::UNAUTHENTICATED;
|
||||
}
|
||||
|
||||
if ($user->hasRole('super_admin')) {
|
||||
return self::SUPER_ADMIN;
|
||||
}
|
||||
|
||||
if ($user->hasRole('org_admin')) {
|
||||
return self::ORGANIZER_ADMIN;
|
||||
}
|
||||
|
||||
if ($user->hasRole('volunteer')) {
|
||||
return self::VOLUNTEER;
|
||||
}
|
||||
|
||||
return self::ORG_MEMBER;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"pragmarx/google2fa": "^9.0",
|
||||
"sentry/sentry-laravel": "^4.15",
|
||||
"spatie/laravel-activitylog": "^5.0",
|
||||
"spatie/laravel-medialibrary": "^11.21",
|
||||
"spatie/laravel-permission": "^7.2"
|
||||
|
||||
483
api/composer.lock
generated
483
api/composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "78c21fb00a5a2db68ad60afeb62382b9",
|
||||
"content-hash": "48bb02e9c223eedc61e86fdf91a72552",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
@@ -1539,6 +1539,66 @@
|
||||
],
|
||||
"time": "2025-08-22T14:27:06+00:00"
|
||||
},
|
||||
{
|
||||
"name": "jean85/pretty-package-versions",
|
||||
"version": "2.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Jean85/pretty-package-versions.git",
|
||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer-runtime-api": "^2.1.0",
|
||||
"php": "^7.4|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.2",
|
||||
"jean85/composer-provided-replaced-stub-package": "^1.0",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpunit/phpunit": "^7.5|^8.5|^9.6",
|
||||
"rector/rector": "^2.0",
|
||||
"vimeo/psalm": "^4.3 || ^5.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Jean85\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Alessandro Lai",
|
||||
"email": "alessandro.lai85@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A library to get pretty versions strings of installed dependencies",
|
||||
"keywords": [
|
||||
"composer",
|
||||
"package",
|
||||
"release",
|
||||
"versions"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
|
||||
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
|
||||
},
|
||||
"time": "2025-03-19T14:43:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "v12.56.0",
|
||||
@@ -3225,6 +3285,84 @@
|
||||
],
|
||||
"time": "2026-02-16T23:10:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nyholm/psr7",
|
||||
"version": "1.8.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Nyholm/psr7.git",
|
||||
"reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
|
||||
"reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2",
|
||||
"psr/http-factory": "^1.0",
|
||||
"psr/http-message": "^1.1 || ^2.0"
|
||||
},
|
||||
"provide": {
|
||||
"php-http/message-factory-implementation": "1.0",
|
||||
"psr/http-factory-implementation": "1.0",
|
||||
"psr/http-message-implementation": "1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"http-interop/http-factory-tests": "^0.9",
|
||||
"php-http/message-factory": "^1.0",
|
||||
"php-http/psr7-integration-tests": "^1.0",
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.4",
|
||||
"symfony/error-handler": "^4.4"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.8-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Nyholm\\Psr7\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Tobias Nyholm",
|
||||
"email": "tobias.nyholm@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Martijn van der Ven",
|
||||
"email": "martijn@vanderven.se"
|
||||
}
|
||||
],
|
||||
"description": "A fast PHP7 implementation of PSR-7",
|
||||
"homepage": "https://tnyholm.se",
|
||||
"keywords": [
|
||||
"psr-17",
|
||||
"psr-7"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Nyholm/psr7/issues",
|
||||
"source": "https://github.com/Nyholm/psr7/tree/1.8.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Zegnat",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nyholm",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-09-09T07:06:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "paragonie/constant_time_encoding",
|
||||
"version": "v3.1.3",
|
||||
@@ -4190,6 +4328,191 @@
|
||||
},
|
||||
"time": "2026-03-03T17:31:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sentry/sentry",
|
||||
"version": "4.26.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/getsentry/sentry-php.git",
|
||||
"reference": "7597fd10c443929c62489d7cf38d1cb8341d6608"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/7597fd10c443929c62489d7cf38d1cb8341d6608",
|
||||
"reference": "7597fd10c443929c62489d7cf38d1cb8341d6608",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"guzzlehttp/psr7": "^1.8.4|^2.1.1",
|
||||
"jean85/pretty-package-versions": "^1.5|^2.0.4",
|
||||
"php": "^7.2|^8.0",
|
||||
"psr/log": "^1.0|^2.0|^3.0",
|
||||
"symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"raven/raven": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.4",
|
||||
"guzzlehttp/promises": "^2.0.3",
|
||||
"guzzlehttp/psr7": "^1.8.4|^2.1.1",
|
||||
"monolog/monolog": "^1.6|^2.0|^3.0",
|
||||
"nyholm/psr7": "^1.8",
|
||||
"open-telemetry/api": "^1.0",
|
||||
"open-telemetry/exporter-otlp": "^1.0",
|
||||
"open-telemetry/sdk": "^1.0",
|
||||
"phpbench/phpbench": "^1.0",
|
||||
"phpstan/phpstan": "^1.3",
|
||||
"phpunit/phpunit": "^8.5.52|^9.6.34",
|
||||
"spiral/roadrunner-http": "^3.6",
|
||||
"spiral/roadrunner-worker": "^3.6"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.",
|
||||
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sentry\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sentry",
|
||||
"email": "accounts@sentry.io"
|
||||
}
|
||||
],
|
||||
"description": "PHP SDK for Sentry (http://sentry.io)",
|
||||
"homepage": "http://sentry.io",
|
||||
"keywords": [
|
||||
"crash-reporting",
|
||||
"crash-reports",
|
||||
"error-handler",
|
||||
"error-monitoring",
|
||||
"log",
|
||||
"logging",
|
||||
"profiling",
|
||||
"sentry",
|
||||
"tracing"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/getsentry/sentry-php/issues",
|
||||
"source": "https://github.com/getsentry/sentry-php/tree/4.26.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://sentry.io/",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://sentry.io/pricing/",
|
||||
"type": "custom"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-30T12:50:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sentry/sentry-laravel",
|
||||
"version": "4.25.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/getsentry/sentry-laravel.git",
|
||||
"reference": "67efbdd74a752fcc1038676986b055a4df7d5084"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/67efbdd74a752fcc1038676986b055a4df7d5084",
|
||||
"reference": "67efbdd74a752fcc1038676986b055a4df7d5084",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0",
|
||||
"nyholm/psr7": "^1.0",
|
||||
"php": "^7.2 | ^8.0",
|
||||
"sentry/sentry": "^4.23.0",
|
||||
"symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0 | ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.11",
|
||||
"guzzlehttp/guzzle": "^7.2",
|
||||
"laravel/folio": "^1.1",
|
||||
"laravel/framework": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0",
|
||||
"laravel/octane": "^2.15",
|
||||
"laravel/pennant": "^1.0",
|
||||
"livewire/livewire": "^2.0 | ^3.0 | ^4.0",
|
||||
"mockery/mockery": "^1.3",
|
||||
"orchestra/testbench": "^4.7 | ^5.1 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0",
|
||||
"phpstan/phpstan": "^1.10",
|
||||
"phpunit/phpunit": "^8.5 | ^9.6 | ^10.4 | ^11.5"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"Sentry": "Sentry\\Laravel\\Facade"
|
||||
},
|
||||
"providers": [
|
||||
"Sentry\\Laravel\\ServiceProvider",
|
||||
"Sentry\\Laravel\\Tracing\\ServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-0": {
|
||||
"Sentry\\Laravel\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sentry",
|
||||
"email": "accounts@sentry.io"
|
||||
}
|
||||
],
|
||||
"description": "Laravel SDK for Sentry (https://sentry.io)",
|
||||
"homepage": "https://sentry.io",
|
||||
"keywords": [
|
||||
"crash-reporting",
|
||||
"crash-reports",
|
||||
"error-handler",
|
||||
"error-monitoring",
|
||||
"laravel",
|
||||
"log",
|
||||
"logging",
|
||||
"profiling",
|
||||
"sentry",
|
||||
"tracing"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/getsentry/sentry-laravel/issues",
|
||||
"source": "https://github.com/getsentry/sentry-laravel/tree/4.25.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://sentry.io/",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://sentry.io/pricing/",
|
||||
"type": "custom"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-05T09:22:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/image",
|
||||
"version": "3.9.4",
|
||||
@@ -5726,6 +6049,77 @@
|
||||
],
|
||||
"time": "2026-03-30T14:11:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/options-resolver",
|
||||
"version": "v8.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/options-resolver.git",
|
||||
"reference": "b48bce0a70b914f6953dafbd10474df232ed4de8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/b48bce0a70b914f6953dafbd10474df232ed4de8",
|
||||
"reference": "b48bce0a70b914f6953dafbd10474df232ed4de8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"symfony/deprecation-contracts": "^2.5|^3"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\OptionsResolver\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides an improved replacement for the array_replace PHP function",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"config",
|
||||
"configuration",
|
||||
"options"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/options-resolver/tree/v8.0.8"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-30T15:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-ctype",
|
||||
"version": "v1.34.0",
|
||||
@@ -6620,6 +7014,93 @@
|
||||
],
|
||||
"time": "2026-03-24T13:12:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/psr-http-message-bridge",
|
||||
"version": "v8.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/psr-http-message-bridge.git",
|
||||
"reference": "94facc221260c1d5f20e31ee43cd6c6a824b4a19"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/94facc221260c1d5f20e31ee43cd6c6a824b4a19",
|
||||
"reference": "94facc221260c1d5f20e31ee43cd6c6a824b4a19",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"psr/http-message": "^1.0|^2.0",
|
||||
"symfony/http-foundation": "^7.4|^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"php-http/discovery": "<1.15"
|
||||
},
|
||||
"require-dev": {
|
||||
"nyholm/psr7": "^1.1",
|
||||
"php-http/discovery": "^1.15",
|
||||
"psr/log": "^1.1.4|^2|^3",
|
||||
"symfony/browser-kit": "^7.4|^8.0",
|
||||
"symfony/config": "^7.4|^8.0",
|
||||
"symfony/event-dispatcher": "^7.4|^8.0",
|
||||
"symfony/framework-bundle": "^7.4|^8.0",
|
||||
"symfony/http-kernel": "^7.4|^8.0",
|
||||
"symfony/runtime": "^7.4|^8.0"
|
||||
},
|
||||
"type": "symfony-bridge",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Bridge\\PsrHttpMessage\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "PSR HTTP message bridge",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"http",
|
||||
"http-message",
|
||||
"psr-17",
|
||||
"psr-7"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.0.8"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-30T15:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/routing",
|
||||
"version": "v7.4.8",
|
||||
|
||||
155
api/config/sentry.php
Normal file
155
api/config/sentry.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Sentry Laravel SDK configuration file.
|
||||
*
|
||||
* @see https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/
|
||||
*/
|
||||
return [
|
||||
|
||||
// @see https://docs.sentry.io/concepts/key-terms/dsn-explainer/
|
||||
// Crewli convention: SENTRY_DSN_BACKEND maps to the crewli-api project
|
||||
// DSN in 1Password vault. Empty = SDK no-op (RFC-WS-7 §3.3).
|
||||
'dsn' => env('SENTRY_DSN_BACKEND'),
|
||||
|
||||
// @see https://spotlightjs.com/
|
||||
// 'spotlight' => env('SENTRY_SPOTLIGHT', false),
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#logger
|
||||
// 'logger' => Sentry\Logger\DebugFileLogger::class, // By default this will log to `storage_path('logs/sentry.log')`
|
||||
|
||||
// The release version of your application
|
||||
// Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD'))
|
||||
'release' => env('SENTRY_RELEASE'),
|
||||
|
||||
// When left empty or `null` the Laravel environment will be used (usually discovered from `APP_ENV` in your `.env`)
|
||||
'environment' => env('SENTRY_ENVIRONMENT', env('APP_ENV')),
|
||||
|
||||
// Crewli observability scrubber (RFC-WS-7 §3.7).
|
||||
'before_send' => static fn (\Sentry\Event $event, ?\Sentry\EventHint $hint = null): ?\Sentry\Event => app(\App\Services\Observability\SentryEventScrubber::class)->scrub($event, $hint),
|
||||
|
||||
// Errors-only — RFC §2 amendment B explicitly excludes performance tracing.
|
||||
// Force traces/profiles off regardless of env.
|
||||
'traces_sample_rate' => 0.0,
|
||||
'profiles_sample_rate' => 0.0,
|
||||
|
||||
// Boundary with existing systems (RFC §3.10): exclude expected business
|
||||
// outcomes. HTTPException is filtered further by status code in the
|
||||
// scrubber (sub-500s are dropped there).
|
||||
'ignore_exceptions' => [
|
||||
\Illuminate\Validation\ValidationException::class,
|
||||
\Illuminate\Auth\AuthenticationException::class,
|
||||
\Illuminate\Auth\Access\AuthorizationException::class,
|
||||
],
|
||||
|
||||
// Override the organization ID used for trace continuation checks.
|
||||
'org_id' => env('SENTRY_ORG_ID') === null ? null : (int) env('SENTRY_ORG_ID'),
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#sample_rate
|
||||
'sample_rate' => env('SENTRY_SAMPLE_RATE') === null ? 1.0 : (float) env('SENTRY_SAMPLE_RATE'),
|
||||
|
||||
// Only continue incoming traces when the organization IDs are compatible with this SDK instance.
|
||||
'strict_trace_continuation' => env('SENTRY_STRICT_TRACE_CONTINUATION', false),
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_logs
|
||||
'enable_logs' => env('SENTRY_ENABLE_LOGS', false),
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#log_flush_threshold
|
||||
'log_flush_threshold' => env('SENTRY_LOG_FLUSH_THRESHOLD') === null ? null : (int) env('SENTRY_LOG_FLUSH_THRESHOLD'),
|
||||
|
||||
// The minimum log level that will be sent to Sentry as logs using the `sentry_logs` logging channel
|
||||
'logs_channel_level' => env('SENTRY_LOG_LEVEL', env('SENTRY_LOGS_LEVEL', env('LOG_LEVEL', 'debug'))),
|
||||
|
||||
// RFC-WS-7 §3.7 point 5 / §3.8 — strip locals from stack traces and IP
|
||||
// from user context. Hard-pinned, no env override.
|
||||
'send_default_pii' => false,
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_transactions
|
||||
'ignore_transactions' => [
|
||||
// Ignore Laravel's default health URL
|
||||
'/up',
|
||||
],
|
||||
|
||||
// Breadcrumb specific configuration
|
||||
'breadcrumbs' => [
|
||||
// Capture Laravel logs as breadcrumbs
|
||||
'logs' => env('SENTRY_BREADCRUMBS_LOGS_ENABLED', true),
|
||||
|
||||
// Capture Laravel cache events (hits, writes etc.) as breadcrumbs
|
||||
'cache' => env('SENTRY_BREADCRUMBS_CACHE_ENABLED', true),
|
||||
|
||||
// Capture Livewire components like routes as breadcrumbs
|
||||
'livewire' => env('SENTRY_BREADCRUMBS_LIVEWIRE_ENABLED', true),
|
||||
|
||||
// Capture SQL queries as breadcrumbs
|
||||
'sql_queries' => env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true),
|
||||
|
||||
// Capture SQL query bindings (parameters) in SQL query breadcrumbs
|
||||
'sql_bindings' => env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false),
|
||||
|
||||
// Capture queue job information as breadcrumbs
|
||||
'queue_info' => env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true),
|
||||
|
||||
// Capture command information as breadcrumbs
|
||||
'command_info' => env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true),
|
||||
|
||||
// Capture HTTP client request information as breadcrumbs
|
||||
'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true),
|
||||
|
||||
// Capture send notifications as breadcrumbs
|
||||
'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true),
|
||||
],
|
||||
|
||||
// Performance monitoring specific configuration
|
||||
'tracing' => [
|
||||
// Trace queue jobs as their own transactions (this enables tracing for queue jobs)
|
||||
'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', false),
|
||||
|
||||
// Capture queue jobs as spans when executed on the sync driver
|
||||
'queue_jobs' => env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', false),
|
||||
|
||||
// Capture SQL queries as spans
|
||||
'sql_queries' => env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true),
|
||||
|
||||
// Capture SQL query bindings (parameters) in SQL query spans
|
||||
'sql_bindings' => env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false),
|
||||
|
||||
// Capture where the SQL query originated from on the SQL query spans
|
||||
'sql_origin' => env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true),
|
||||
|
||||
// Define a threshold in milliseconds for SQL queries to resolve their origin
|
||||
'sql_origin_threshold_ms' => env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100),
|
||||
|
||||
// Capture views rendered as spans
|
||||
'views' => env('SENTRY_TRACE_VIEWS_ENABLED', true),
|
||||
|
||||
// Capture Livewire components as spans
|
||||
'livewire' => env('SENTRY_TRACE_LIVEWIRE_ENABLED', true),
|
||||
|
||||
// Capture HTTP client requests as spans
|
||||
'http_client_requests' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true),
|
||||
|
||||
// Capture Laravel cache events (hits, writes etc.) as spans
|
||||
'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true),
|
||||
|
||||
// Capture Redis operations as spans (this enables Redis events in Laravel)
|
||||
'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false),
|
||||
|
||||
// Capture where the Redis command originated from on the Redis command spans
|
||||
'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true),
|
||||
|
||||
// Capture send notifications as spans
|
||||
'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true),
|
||||
|
||||
// Enable tracing for requests without a matching route (404's)
|
||||
'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false),
|
||||
|
||||
// Configures if the performance trace should continue after the response has been sent to the user until the application terminates
|
||||
// This is required to capture any spans that are created after the response has been sent like queue jobs dispatched using `dispatch(...)->afterResponse()` for example
|
||||
'continue_after_response' => env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true),
|
||||
|
||||
// Enable the tracing integrations supplied by Sentry (recommended)
|
||||
'default_integrations' => env('SENTRY_TRACE_DEFAULT_INTEGRATIONS_ENABLED', true),
|
||||
],
|
||||
|
||||
];
|
||||
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