diff --git a/api/.env.example b/api/.env.example index c578a3ff..b5a67430 100644 --- a/api/.env.example +++ b/api/.env.example @@ -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@. 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= diff --git a/api/app/Enums/Observability/ActorType.php b/api/app/Enums/Observability/ActorType.php new file mode 100644 index 00000000..5a36fe8b --- /dev/null +++ b/api/app/Enums/Observability/ActorType.php @@ -0,0 +1,55 @@ +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; + } +} diff --git a/api/app/Services/Observability/SentryEventScrubber.php b/api/app/Services/Observability/SentryEventScrubber.php new file mode 100644 index 00000000..1671f5b2 --- /dev/null +++ b/api/app/Services/Observability/SentryEventScrubber.php @@ -0,0 +1,137 @@ +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 $headers + * @return array|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); + } +} diff --git a/api/composer.json b/api/composer.json index 87602794..2670c257 100644 --- a/api/composer.json +++ b/api/composer.json @@ -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" diff --git a/api/composer.lock b/api/composer.lock index 34b7a1d2..d1d0a205 100644 --- a/api/composer.lock +++ b/api/composer.lock @@ -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", diff --git a/api/config/sentry.php b/api/config/sentry.php new file mode 100644 index 00000000..4dbb31be --- /dev/null +++ b/api/config/sentry.php @@ -0,0 +1,155 @@ + 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), + ], + +]; diff --git a/api/tests/Feature/Observability/PiiScrubbingTest.php b/api/tests/Feature/Observability/PiiScrubbingTest.php new file mode 100644 index 00000000..8cdc2896 --- /dev/null +++ b/api/tests/Feature/Observability/PiiScrubbingTest.php @@ -0,0 +1,238 @@ + $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); + } +}