From d6ff9413a34f17a2c22d05ce8ef551cedb003b1c Mon Sep 17 00:00:00 2001 From: Felix Gradinaru Date: Sat, 15 Aug 2026 18:14:04 +0200 Subject: [PATCH 1/4] fix: bind UserContextServiceInterface instead of UserContextInterface in Objects.yaml DefaultUserContextService implements UserContextServiceInterface, which is also the interface SentryClient injects. The previous binding targeted the UserContext value-object interface and only worked through Flow's implicit single-implementation resolution. --- Configuration/Objects.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Configuration/Objects.yaml b/Configuration/Objects.yaml index 8ec4355..48afd56 100644 --- a/Configuration/Objects.yaml +++ b/Configuration/Objects.yaml @@ -1,3 +1,3 @@ -Flownative\Sentry\Context\UserContextInterface: +Flownative\Sentry\Context\UserContextServiceInterface: scope: singleton className: Flownative\Sentry\Context\DefaultUserContextService From 402cbc32f8a7b4547ffadb3cdc2bd15259f8aec7 Mon Sep 17 00:00:00 2001 From: Felix Gradinaru Date: Sat, 15 Aug 2026 18:15:11 +0200 Subject: [PATCH 2/4] feat: add sdkOptions passthrough for Sentry SDK client options Flownative.Sentry.sdkOptions lets projects configure any YAML-representable Sentry SDK option (e.g. max_request_body_size, send_default_pii) without a package change. Options set explicitly by this package always win; the before_send* callbacks, ignore_exceptions and integrations are reserved and rejected at boot with the offending settings path; in_app_exclude entries are merged with the package defaults. --- .../InvalidConfigurationException.php | 18 +++++ Classes/SentryClient.php | 68 ++++++++++++++++--- Configuration/Settings.yaml | 8 +++ 3 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 Classes/Exception/InvalidConfigurationException.php diff --git a/Classes/Exception/InvalidConfigurationException.php b/Classes/Exception/InvalidConfigurationException.php new file mode 100644 index 0000000..c4b8ae3 --- /dev/null +++ b/Classes/Exception/InvalidConfigurationException.php @@ -0,0 +1,18 @@ +excludeExceptionMessagePatterns = $settings['capture']['excludeExceptionMessagePatterns'] ?? []; $this->excludeExceptionCodes = $settings['capture']['excludeExceptionCodes'] ?? []; $this->errorLevel = $settings['errorLevel'] ?? error_reporting(); + $this->sdkOptions = $this->validateSdkOptions($settings['sdkOptions'] ?? []); + } + + /** + * @throws InvalidConfigurationException + */ + private function validateSdkOptions(mixed $sdkOptions): array + { + if (!is_array($sdkOptions)) { + throw new InvalidConfigurationException( + 'Flownative.Sentry.sdkOptions must be an array of Sentry SDK options', + 1755264001 + ); + } + foreach (self::RESERVED_SDK_OPTIONS as $reservedOption) { + if (array_key_exists($reservedOption, $sdkOptions)) { + throw new InvalidConfigurationException( + sprintf( + 'The Sentry SDK option "%s" cannot be set via Flownative.Sentry.sdkOptions.%s because it is managed by this package', + $reservedOption, + $reservedOption + ), + 1755264002 + ); + } + } + return $sdkOptions; } public function initializeObject(): void @@ -126,20 +170,26 @@ public function initializeObject(): void $representationSerializer ); - \Sentry\init([ + $inAppExclude = [ + FLOW_PATH_ROOT . '/Packages/Application/Flownative.Sentry/Classes/', + FLOW_PATH_ROOT . '/Packages/Framework/Neos.Flow/Classes/Aop/', + FLOW_PATH_ROOT . '/Packages/Framework/Neos.Flow/Classes/Error/', + FLOW_PATH_ROOT . '/Packages/Framework/Neos.Flow/Classes/Log/', + FLOW_PATH_ROOT . '/Packages/Libraries/neos/flow-log/' + ]; + if (isset($this->sdkOptions['in_app_exclude'])) { + $inAppExclude = array_values(array_unique(array_merge($inAppExclude, (array)$this->sdkOptions['in_app_exclude']))); + } + + // Options set explicitly by this package always win over sdkOptions + \Sentry\init(array_replace($this->sdkOptions, [ 'dsn' => $this->dsn, 'environment' => $this->environment, 'release' => $this->release, 'sample_rate' => $this->sampleRate, 'traces_sample_rate' => $this->tracesSampleRate, 'ignore_exceptions' => array_keys(array_filter($this->excludeExceptionTypes)), - 'in_app_exclude' => [ - FLOW_PATH_ROOT . '/Packages/Application/Flownative.Sentry/Classes/', - FLOW_PATH_ROOT . '/Packages/Framework/Neos.Flow/Classes/Aop/', - FLOW_PATH_ROOT . '/Packages/Framework/Neos.Flow/Classes/Error/', - FLOW_PATH_ROOT . '/Packages/Framework/Neos.Flow/Classes/Log/', - FLOW_PATH_ROOT . '/Packages/Libraries/neos/flow-log/' - ], + 'in_app_exclude' => $inAppExclude, 'attach_stacktrace' => true, 'error_types' => $this->errorLevel, 'before_send' => function (Event $event, ?EventHint $hint): ?Event { @@ -150,7 +200,7 @@ public function initializeObject(): void return $event; } - ]); + ])); $client = SentrySdk::getCurrentHub()->getClient(); if (!$client) { diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index b8ba3d8..8718a7f 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -6,6 +6,14 @@ Flownative: sampleRate: 1.0 tracesSampleRate: 0 errorLevel: null + + # Additional Sentry SDK client options passed to \Sentry\init(). Options set + # explicitly by this package (dsn, environment, release, sample rates, error + # level, exception excludes) always take precedence. The before_send* + # callbacks, ignore_exceptions and integrations cannot be set here. + # in_app_exclude entries are merged with the package defaults. + sdkOptions: {} + capture: excludeExceptionTypes: 'Neos\Flow\Mvc\Controller\Exception\InvalidControllerException': true From 7e7c1ba52a961ff07b292c86650f9ef7985607aa Mon Sep 17 00:00:00 2001 From: Felix Gradinaru Date: Sat, 15 Aug 2026 18:16:13 +0200 Subject: [PATCH 3/4] fix: apply per-event data via capture-local scope instead of mutating the global scope captureThrowable() and captureMessage() previously wrote extras, tags, user and the session tag into the hub's scope permanently. In long-running CLI processes (queue workers) data from one capture leaked into all subsequent events: stale Reference Codes, exception_code tags and WithExtraDataInterface payloads. Per-event data is now applied inside withScope(), so it evaporates after each capture; the process-stable flow_version/flow_context tags remain on the global scope, set once at initialization. --- Classes/SentryClient.php | 55 +++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/Classes/SentryClient.php b/Classes/SentryClient.php index bbac2fa..723e993 100644 --- a/Classes/SentryClient.php +++ b/Classes/SentryClient.php @@ -223,15 +223,9 @@ private function setTags(): void $flowVersion = FLOW_VERSION_BRANCH; } - $currentSession = $this->sessionManager?->getCurrentSession(); - - SentrySdk::getCurrentHub()->configureScope(static function (Scope $scope) use ($flowVersion, $currentSession): void { + SentrySdk::getCurrentHub()->configureScope(static function (Scope $scope) use ($flowVersion): void { $scope->setTag('flow_version', $flowVersion); $scope->setTag('flow_context', (string)Bootstrap::$staticObjectManager->get(Environment::class)->getContext()); - - if ($currentSession instanceof Session && $currentSession->isStarted()) { - $scope->setTag('flow_session_sha1', sha1($currentSession->getId())); - } }); } @@ -276,11 +270,14 @@ public function captureThrowable(Throwable $throwable, array $extraData = [], ar $tags['exception_code'] = (string)$throwable->getCode(); - $this->setTags(); - $this->configureScope($extraData, $tags); $event = Event::createEvent(); $this->addThrowableToEvent($throwable, $event); - $sentryEventId = SentrySdk::getCurrentHub()->captureEvent($event); + + $sentryEventId = null; + SentrySdk::getCurrentHub()->withScope(function (Scope $scope) use ($event, $extraData, $tags, &$sentryEventId): void { + $this->applyCaptureScope($scope, $extraData, $tags); + $sentryEventId = SentrySdk::getCurrentHub()->captureEvent($event); + }); return new CaptureResult( true, @@ -299,12 +296,15 @@ public function captureMessage(string $message, Severity $severity, array $extra ); } - $this->setTags(); - $this->configureScope($extraData, $tags); $eventHint = EventHint::fromArray([ 'stacktrace' => $this->prepareStacktrace(null) ]); - $sentryEventId = SentrySdk::getCurrentHub()->captureMessage($message, $severity, $eventHint); + + $sentryEventId = null; + SentrySdk::getCurrentHub()->withScope(function (Scope $scope) use ($message, $severity, $eventHint, $extraData, $tags, &$sentryEventId): void { + $this->applyCaptureScope($scope, $extraData, $tags); + $sentryEventId = SentrySdk::getCurrentHub()->captureMessage($message, $severity, $eventHint); + }); return new CaptureResult( true, @@ -338,7 +338,12 @@ static function(bool $carry, string $pattern) use ($message) { return false; } - private function configureScope(array $extraData, array $tags): void + /** + * Applies per-event data to a capture-local scope. Called inside withScope() + * so that nothing of this leaks into subsequent events of the same process + * (long-running CLI workers would otherwise accumulate stale data). + */ + private function applyCaptureScope(Scope $scope, array $extraData, array $tags): void { $securityContext = Bootstrap::$staticObjectManager->get(SecurityContext::class); if ($securityContext instanceof SecurityContext && $securityContext->isInitialized()) { @@ -347,15 +352,19 @@ private function configureScope(array $extraData, array $tags): void $userContext = new UserContext(); } - SentrySdk::getCurrentHub()->configureScope(static function (Scope $scope) use ($userContext, $extraData, $tags): void { - foreach ($extraData as $extraDataKey => $extraDataValue) { - $scope->setExtra($extraDataKey, $extraDataValue); - } - foreach ($tags as $tagKey => $tagValue) { - $scope->setTag($tagKey, $tagValue); - } - $scope->setUser($userContext->toArray()); - }); + foreach ($extraData as $extraDataKey => $extraDataValue) { + $scope->setExtra($extraDataKey, $extraDataValue); + } + foreach ($tags as $tagKey => $tagValue) { + $scope->setTag($tagKey, $tagValue); + } + + $currentSession = $this->sessionManager?->getCurrentSession(); + if ($currentSession instanceof Session && $currentSession->isStarted()) { + $scope->setTag('flow_session_sha1', sha1($currentSession->getId())); + } + + $scope->setUser($userContext->toArray()); } private function renderCleanPathAndFilename(string $rawPathAndFilename): string From 343bd5dcb79e4fab4dd4876bbdb0ca6d1f70de48 Mon Sep 17 00:00:00 2001 From: Felix Gradinaru Date: Sat, 15 Aug 2026 18:23:55 +0200 Subject: [PATCH 4/4] feat: add configurable event scrubber chain and data minimization options - EventScrubberInterface with explicit registration via Flownative.Sentry.scrubbers (stable IDs, PositionalArraySorter positions, per-entry options, disable via ~); the chain is validated, sorted AND instantiated at boot so misconfiguration fails the deployment loudly - chain composed into before_send, before_send_transaction and before_send_check_in, after the configured exception excludes - fail-closed + fail-loud: a throwing scrubber discards the event and sends a synthetic, built-from-scratch replacement event carrying only the scrubber ID, normalized class names, integer code and discarded event ID - bundled scrubbers: RequestScrubber (keep-only request interface, delimiter-preserving query parameter allowlist, header allowlist), ValuePatternScrubber (url-credentials/email/ipv4/ipv6/iban/phone patterns applied literally via callback, canonicalized sensitive-key redaction, object replacement without serialization, coverage incl. transaction names, spans, breadcrumbs, tags, fingerprints, check-in slugs), FrameVarsScrubber (also relativizes absolute file paths and strips anonymous-class path segments), SpanDataScrubber - data minimization toggles, defaulting to previous behavior: attachSessionTag, attachProcessInfo, captureStacktraceVariables, userContext (service|username|sha1|none; none also removes a foreign-code scope user via Scope::removeUser()) - flow_version/flow_context tags re-applied per capture as before, so they survive foreign scope replacement - settings schema, PHPUnit suite incl. client-level integration tests with an in-memory transport --- Classes/CaptureLedger.php | 71 +++++ Classes/IntegrationFilter.php | 39 +++ Classes/Scrubbing/EventScrubberInterface.php | 38 +++ Classes/Scrubbing/FrameVarsScrubber.php | 89 ++++++ Classes/Scrubbing/RequestScrubber.php | 162 +++++++++++ Classes/Scrubbing/ScrubberChain.php | 111 +++++++ Classes/Scrubbing/ScrubberChainFactory.php | 112 +++++++ Classes/Scrubbing/SpanDataScrubber.php | 83 ++++++ Classes/Scrubbing/ValuePatternScrubber.php | 273 ++++++++++++++++++ Classes/SentryClient.php | 171 +++++++++-- Configuration/Settings.yaml | 43 +++ README.md | 108 +++++++ .../Settings/Flownative.Sentry.schema.yaml | 45 +++ Tests/Unit/CaptureLedgerTest.php | 51 ++++ Tests/Unit/IntegrationFilterTest.php | 39 +++ .../Unit/Scrubbing/FrameVarsScrubberTest.php | 83 ++++++ Tests/Unit/Scrubbing/RequestScrubberTest.php | 114 ++++++++ .../ScrubberChainClientIntegrationTest.php | 130 +++++++++ .../Scrubbing/ScrubberChainFactoryTest.php | 94 ++++++ Tests/Unit/Scrubbing/ScrubberChainTest.php | 108 +++++++ Tests/Unit/Scrubbing/SpanDataScrubberTest.php | 76 +++++ .../Scrubbing/ValuePatternScrubberTest.php | 258 +++++++++++++++++ composer.json | 12 + phpunit.xml | 13 + 24 files changed, 2292 insertions(+), 31 deletions(-) create mode 100644 Classes/CaptureLedger.php create mode 100644 Classes/IntegrationFilter.php create mode 100644 Classes/Scrubbing/EventScrubberInterface.php create mode 100644 Classes/Scrubbing/FrameVarsScrubber.php create mode 100644 Classes/Scrubbing/RequestScrubber.php create mode 100644 Classes/Scrubbing/ScrubberChain.php create mode 100644 Classes/Scrubbing/ScrubberChainFactory.php create mode 100644 Classes/Scrubbing/SpanDataScrubber.php create mode 100644 Classes/Scrubbing/ValuePatternScrubber.php create mode 100644 Resources/Private/Schema/Settings/Flownative.Sentry.schema.yaml create mode 100644 Tests/Unit/CaptureLedgerTest.php create mode 100644 Tests/Unit/IntegrationFilterTest.php create mode 100644 Tests/Unit/Scrubbing/FrameVarsScrubberTest.php create mode 100644 Tests/Unit/Scrubbing/RequestScrubberTest.php create mode 100644 Tests/Unit/Scrubbing/ScrubberChainClientIntegrationTest.php create mode 100644 Tests/Unit/Scrubbing/ScrubberChainFactoryTest.php create mode 100644 Tests/Unit/Scrubbing/ScrubberChainTest.php create mode 100644 Tests/Unit/Scrubbing/SpanDataScrubberTest.php create mode 100644 Tests/Unit/Scrubbing/ValuePatternScrubberTest.php create mode 100644 phpunit.xml diff --git a/Classes/CaptureLedger.php b/Classes/CaptureLedger.php new file mode 100644 index 0000000..49b7d89 --- /dev/null +++ b/Classes/CaptureLedger.php @@ -0,0 +1,71 @@ +capturedThrowables = new WeakMap(); + } + + public function hasBeenCaptured(Throwable $throwable): bool + { + $seenObjectIds = []; + for ($current = $throwable; $current !== null; $current = $current->getPrevious()) { + $objectId = spl_object_id($current); + if (isset($seenObjectIds[$objectId])) { + break; + } + $seenObjectIds[$objectId] = true; + if (isset($this->capturedThrowables[$current])) { + return true; + } + } + return false; + } + + /** + * Only call this after a capture was ACCEPTED (not excluded, not dropped + * by a scrubber without replacement) — a rejected capture must not + * suppress a later legitimate one. + */ + public function remember(Throwable $throwable): void + { + $seenObjectIds = []; + for ($current = $throwable; $current !== null; $current = $current->getPrevious()) { + $objectId = spl_object_id($current); + if (isset($seenObjectIds[$objectId])) { + break; + } + $seenObjectIds[$objectId] = true; + $this->capturedThrowables[$current] = true; + } + } +} diff --git a/Classes/IntegrationFilter.php b/Classes/IntegrationFilter.php new file mode 100644 index 0000000..e052f3e --- /dev/null +++ b/Classes/IntegrationFilter.php @@ -0,0 +1,39 @@ +getExceptions() as $exceptionDataBag) { + $stacktrace = $exceptionDataBag->getStacktrace(); + if ($stacktrace !== null) { + $exceptionDataBag->setStacktrace($this->rebuildStacktrace($stacktrace)); + } + } + + $eventStacktrace = $event->getStacktrace(); + if ($eventStacktrace !== null) { + $event->setStacktrace($this->rebuildStacktrace($eventStacktrace)); + } + + return $event; + } + + private function rebuildStacktrace(Stacktrace $stacktrace): Stacktrace + { + $strippedFrames = []; + foreach ($stacktrace->getFrames() as $frame) { + $strippedFrames[] = new Frame( + $this->stripAnonymousClassPaths($frame->getFunctionName()), + $this->relativizeFilePath($frame->getFile()), + $frame->getLine(), + $this->stripAnonymousClassPaths($frame->getRawFunctionName()), + null, + [], + $frame->isInApp() + ); + } + return new Stacktrace($strippedFrames); + } + + /** + * Frame::getFile() regularly carries the absolute path for files outside + * the Flow proxy cache; local usernames and server layout must not leave + * the system. + */ + private function relativizeFilePath(string $file): string + { + if (!str_starts_with($file, '/')) { + return $file; + } + if (defined('FLOW_PATH_ROOT') && str_starts_with($file, FLOW_PATH_ROOT)) { + return substr($file, strlen(FLOW_PATH_ROOT)); + } + return '…/' . basename($file); + } + + /** + * Anonymous class names embed file paths (class@anonymous/path/file.php:12). + */ + private function stripAnonymousClassPaths(?string $functionName): ?string + { + if ($functionName === null || !str_contains($functionName, '@anonymous')) { + return $functionName; + } + return preg_replace('/@anonymous[^:]*(?::\d+)?(\$\w+)?/', '@anonymous', $functionName) ?? '[anonymous]'; + } +} diff --git a/Classes/Scrubbing/RequestScrubber.php b/Classes/Scrubbing/RequestScrubber.php new file mode 100644 index 0000000..e2d7860 --- /dev/null +++ b/Classes/Scrubbing/RequestScrubber.php @@ -0,0 +1,162 @@ +queryParamAllowlist = array_map('strval', $options['queryParamAllowlist'] ?? []); + $this->headerAllowlist = array_map( + static fn($headerName) => strtolower((string)$headerName), + $options['headerAllowlist'] ?? [] + ); + } + + public function scrub(Event $event, ?EventHint $hint): ?Event + { + $request = $event->getRequest(); + if ($request !== []) { + $cleanRequest = []; + if (isset($request['method'])) { + $cleanRequest['method'] = $request['method']; + } + if (isset($request['url'])) { + $cleanRequest['url'] = $this->filterUrl((string)$request['url']); + } + if (isset($request['query_string'])) { + $cleanRequest['query_string'] = $this->filterQueryString((string)$request['query_string']); + } + if (isset($request['headers']) && is_array($request['headers'])) { + $cleanRequest['headers'] = $this->filterHeaders($request['headers']); + } + // data, cookies and env are dropped on purpose + $event->setRequest($cleanRequest); + } + + $breadcrumbs = []; + $breadcrumbsChanged = false; + foreach ($event->getBreadcrumbs() as $breadcrumb) { + $metadata = $breadcrumb->getMetadata(); + if (isset($metadata['url']) && is_string($metadata['url'])) { + $filteredUrl = $this->filterUrl($metadata['url']); + if ($filteredUrl !== $metadata['url']) { + $metadata['url'] = $filteredUrl; + $breadcrumb = new Breadcrumb( + $breadcrumb->getLevel(), + $breadcrumb->getType(), + $breadcrumb->getCategory(), + $breadcrumb->getMessage(), + $metadata, + $breadcrumb->getTimestamp() + ); + $breadcrumbsChanged = true; + } + } + $breadcrumbs[] = $breadcrumb; + } + if ($breadcrumbsChanged) { + $event->setBreadcrumb($breadcrumbs); + } + + return $event; + } + + private function filterUrl(string $url): string + { + $fragmentPosition = strpos($url, '#'); + if ($fragmentPosition !== false) { + $url = substr($url, 0, $fragmentPosition); + } + $queryPosition = strpos($url, '?'); + if ($queryPosition === false) { + return $url; + } + $filteredQuery = $this->filterQueryString(substr($url, $queryPosition + 1)); + return $filteredQuery === '' + ? substr($url, 0, $queryPosition) + : substr($url, 0, $queryPosition) . '?' . $filteredQuery; + } + + private function filterQueryString(string $queryString): string + { + if ($queryString === '') { + return ''; + } + // A fragment has no business in a query string field either + $fragmentPosition = strpos($queryString, '#'); + if ($fragmentPosition !== false) { + $queryString = substr($queryString, 0, $fragmentPosition); + } + + // No parse_str(): it would collapse repeated parameters and mangle + // names. Delimiters are captured so surviving pairs keep their own. + $tokens = preg_split('/([&;])/', $queryString, -1, PREG_SPLIT_DELIM_CAPTURE) ?: []; + $kept = ''; + for ($i = 0; $i < count($tokens); $i += 2) { + $rawPair = $tokens[$i]; + if ($rawPair === '') { + continue; + } + $rawName = strstr($rawPair, '=', true); + if ($rawName === false) { + $rawName = $rawPair; + } + $decodedName = rawurldecode(str_replace('+', ' ', $rawName)); + if (in_array($decodedName, $this->queryParamAllowlist, true)) { + $precedingDelimiter = $i > 0 ? ($tokens[$i - 1] ?: '&') : ''; + $kept .= ($kept === '' ? '' : $precedingDelimiter) . $rawPair; + } + } + return $kept; + } + + private function filterHeaders(array $headers): array + { + $keptHeaders = []; + foreach ($headers as $headerName => $headerValue) { + $normalizedName = strtolower((string)$headerName); + if (!in_array($normalizedName, $this->headerAllowlist, true)) { + continue; + } + if ($normalizedName === 'referer' || $normalizedName === 'referrer') { + $headerValue = is_array($headerValue) + ? array_map(fn($value) => $this->filterUrl((string)$value), $headerValue) + : $this->filterUrl((string)$headerValue); + } + $keptHeaders[$headerName] = $headerValue; + } + return $keptHeaders; + } +} diff --git a/Classes/Scrubbing/ScrubberChain.php b/Classes/Scrubbing/ScrubberChain.php new file mode 100644 index 0000000..86010fc --- /dev/null +++ b/Classes/Scrubbing/ScrubberChain.php @@ -0,0 +1,111 @@ + $scrubbers indexed by registry identifier, in execution order + */ + public function __construct( + private readonly array $scrubbers + ) { + } + + public function process(Event $event, ?EventHint $hint): ?Event + { + $originalEventId = (string)$event->getId(); + $originalExceptionClass = null; + $originalExceptionCode = null; + if ($hint?->exception instanceof Throwable) { + $originalExceptionClass = get_class($hint->exception); + $code = $hint->exception->getCode(); + $originalExceptionCode = is_int($code) ? $code : null; + } elseif ($event->getExceptions() !== []) { + $originalExceptionClass = $event->getExceptions()[0]->getType(); + } + + foreach ($this->scrubbers as $identifier => $scrubber) { + try { + $event = $scrubber->scrub($event, $hint); + } catch (Throwable $throwable) { + return $this->createFailureEvent((string)$identifier, $throwable, $originalEventId, $originalExceptionClass, $originalExceptionCode); + } + if ($event === null) { + return null; + } + } + return $event; + } + + /** + * The failure event is allowlist-by-construction: every field is set + * deliberately and nothing is derived from the discarded event apart from + * the explicitly chosen facts (identifiers, class names, integer code). + */ + private function createFailureEvent(string $identifier, Throwable $throwable, string $originalEventId, ?string $originalExceptionClass, ?int $originalExceptionCode): ?Event + { + try { + $scrubberExceptionClass = self::normalizeClassName(get_class($throwable)); + + $event = Event::createEvent(); + $event->setMessage('Flownative Sentry event scrubber failed – the original event was discarded'); + $event->setLevel(Severity::error()); + $event->setLogger('flownative.sentry.scrubber'); + $event->setFingerprint(['flownative-sentry-scrubber-failure', $identifier, $scrubberExceptionClass]); + + $extra = [ + 'scrubber' => $identifier, + 'scrubber_exception_class' => $scrubberExceptionClass, + 'discarded_event_id' => $originalEventId, + ]; + if ($originalExceptionClass !== null) { + $extra['original_exception_class'] = self::normalizeClassName($originalExceptionClass); + } + if ($originalExceptionCode !== null) { + $extra['original_exception_code'] = $originalExceptionCode; + } + $event->setExtra($extra); + + return $event; + } catch (Throwable) { + // Deliberately without any detail: nothing from the failed path may leak + error_log('Flownative.Sentry: event scrubber failure event could not be created; event discarded'); + return null; + } + } + + /** + * Anonymous class names contain file paths and must not be transmitted. + */ + public static function normalizeClassName(string $className): string + { + if (str_contains($className, '@anonymous')) { + return '[anonymous]'; + } + return preg_match('/^[A-Za-z_\\\\][A-Za-z0-9_\\\\]*$/', $className) === 1 ? $className : '[invalid-class]'; + } +} diff --git a/Classes/Scrubbing/ScrubberChainFactory.php b/Classes/Scrubbing/ScrubberChainFactory.php new file mode 100644 index 0000000..5af1b98 --- /dev/null +++ b/Classes/Scrubbing/ScrubberChainFactory.php @@ -0,0 +1,112 @@ + $entry) { + if ($entry === null) { + continue; + } + if (!is_array($entry) || !isset($entry['className']) || !is_string($entry['className'])) { + throw new InvalidConfigurationException( + sprintf('Flownative.Sentry.scrubbers.%s must be null or an array with a "className" string', $identifier), + 1755264010 + ); + } + if (!class_exists($entry['className'])) { + throw new InvalidConfigurationException( + sprintf('Flownative.Sentry.scrubbers.%s.className: class %s does not exist', $identifier, $entry['className']), + 1755264011 + ); + } + if (!is_subclass_of($entry['className'], EventScrubberInterface::class)) { + throw new InvalidConfigurationException( + sprintf('Flownative.Sentry.scrubbers.%s.className: %s does not implement %s', $identifier, $entry['className'], EventScrubberInterface::class), + 1755264012 + ); + } + if (!(new \ReflectionClass($entry['className']))->isInstantiable()) { + throw new InvalidConfigurationException( + sprintf('Flownative.Sentry.scrubbers.%s.className: %s is not instantiable', $identifier, $entry['className']), + 1755264016 + ); + } + if (isset($entry['options']) && !is_array($entry['options'])) { + throw new InvalidConfigurationException( + sprintf('Flownative.Sentry.scrubbers.%s.options must be an array', $identifier), + 1755264013 + ); + } + } + + $enabledConfiguration = array_filter($scrubberConfiguration, static fn($entry) => $entry !== null); + try { + $sortedConfiguration = (new PositionalArraySorter($enabledConfiguration))->toArray(); + } catch (Throwable $throwable) { + throw new InvalidConfigurationException( + 'Flownative.Sentry.scrubbers positions could not be resolved: ' . $throwable->getMessage(), + 1755264014 + ); + } + // The sorter does not detect circular before/after references itself + if (array_diff(array_keys($enabledConfiguration), array_keys($sortedConfiguration)) !== []) { + throw new InvalidConfigurationException( + 'Flownative.Sentry.scrubbers positions are incomplete after sorting — check for circular before/after references', + 1755264015 + ); + } + + $scrubbers = []; + foreach ($sortedConfiguration as $identifier => $entry) { + $className = $entry['className']; + try { + $scrubbers[$identifier] = new $className($entry['options'] ?? []); + } catch (Throwable $throwable) { + throw new InvalidConfigurationException( + sprintf('Flownative.Sentry.scrubbers.%s could not be constructed: %s', $identifier, $throwable->getMessage()), + 1755264017 + ); + } + } + $this->chain = new ScrubberChain($scrubbers); + } + + public function getChain(): ScrubberChain + { + return $this->chain; + } +} diff --git a/Classes/Scrubbing/SpanDataScrubber.php b/Classes/Scrubbing/SpanDataScrubber.php new file mode 100644 index 0000000..45df1f5 --- /dev/null +++ b/Classes/Scrubbing/SpanDataScrubber.php @@ -0,0 +1,83 @@ +getTransaction(); + if ($transaction !== null) { + $event->setTransaction($this->stripUrlQueries($transaction)); + } + + foreach ($event->getSpans() as $span) { + // Span::setData() merges, so existing keys are overwritten with a marker + $spanData = $span->getData(); + if (is_array($spanData) && $spanData !== []) { + $span->setData(array_fill_keys(array_keys($spanData), '[Filtered]')); + } + $description = $span->getDescription(); + if ($description !== null) { + $span->setDescription($this->stripUrlQueries($description)); + } + $scrubbedSpanTags = []; + foreach ($span->getTags() as $tagKey => $tagValue) { + $scrubbedSpanTags[$tagKey] = $this->stripUrlQueries((string)$tagValue); + } + if ($scrubbedSpanTags !== []) { + $span->setTags($scrubbedSpanTags); + } + } + + $contexts = $event->getContexts(); + if (isset($contexts['trace']) && is_array($contexts['trace']) && isset($contexts['trace']['data'])) { + $traceContext = $contexts['trace']; + $traceContext['data'] = []; + $event->setContext('trace', $traceContext); + } + + return $event; + } + + private function stripUrlQueries(string $value): string + { + foreach (self::URL_QUERY_PATTERNS as $pattern) { + $value = preg_replace($pattern, '$1', $value) ?? $value; + } + return $value; + } +} diff --git a/Classes/Scrubbing/ValuePatternScrubber.php b/Classes/Scrubbing/ValuePatternScrubber.php new file mode 100644 index 0000000..cf0134f --- /dev/null +++ b/Classes/Scrubbing/ValuePatternScrubber.php @@ -0,0 +1,273 @@ +]" + * entirely — their content is never serialized by this scrubber. + * + * Options: + * - patterns: string[] — names of built-in patterns to apply. Default []. + * Available: url-credentials, email, ipv4, ipv6, iban, phone. + * The phone pattern is prone to false positives (invoice or serial + * numbers); enable it deliberately. + * - urlCredentialSchemes: string[] — URI schemes whose userinfo is + * redacted by the url-credentials pattern. + * - sensitiveKeys: string[] — array keys whose values are redacted + * entirely, matched case-insensitively as substring. + * - replacement: string — the replacement marker. Default "[Filtered]". + * - maxDepth: int — recursion depth limit for nested data. Default 10. + */ +final class ValuePatternScrubber implements EventScrubberInterface +{ + private const BUILTIN_PATTERNS = [ + // "/" is legal in RFC local parts but omitted so path-embedded + // addresses ("/unsubscribe/user@host") redact only the address + 'email' => '/[A-Za-z0-9.!#$%&\'*+=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+/', + 'ipv4' => '/\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\b/', + 'ipv6' => '/(? '/\b[A-Za-z]{2}\d{2}(?: ?[A-Za-z0-9]{1,4}){3,8}\b/', + // A number only qualifies with an international prefix or interior + // separators — bare digit runs (exception codes, timestamps, IDs, + // reference codes) never match. Guards keep ISO and dotted dates. + 'phone' => '/(?activePatterns = []; + $this->applyUrlCredentials = false; + foreach ($options['patterns'] ?? [] as $patternName) { + if ($patternName === 'url-credentials') { + $this->applyUrlCredentials = true; + continue; + } + if (!isset(self::BUILTIN_PATTERNS[$patternName])) { + throw new RuntimeException(sprintf('ValuePatternScrubber: unknown pattern "%s"', $patternName), 1755264020); + } + $this->activePatterns[$patternName] = self::BUILTIN_PATTERNS[$patternName]; + } + $this->urlCredentialSchemes = array_map('strtolower', $options['urlCredentialSchemes'] ?? self::DEFAULT_URL_CREDENTIAL_SCHEMES); + $this->sensitiveKeys = array_map( + static fn($key) => (string)preg_replace('/[^a-z0-9]/', '', strtolower((string)$key)), + $options['sensitiveKeys'] ?? self::DEFAULT_SENSITIVE_KEYS + ); + $this->replacement = (string)($options['replacement'] ?? '[Filtered]'); + $this->maxDepth = (int)($options['maxDepth'] ?? 10); + } + + public function scrub(Event $event, ?EventHint $hint): ?Event + { + if ($event->getMessage() !== null) { + $scrubbedParams = $this->scrubValue($event->getMessageParams()); + $event->setMessage( + $this->scrubString($event->getMessage()), + is_array($scrubbedParams) ? $scrubbedParams : [], + $event->getMessageFormatted() !== null ? $this->scrubString($event->getMessageFormatted()) : null + ); + } + + foreach ($event->getExceptions() as $exceptionDataBag) { + $exceptionDataBag->setValue($this->scrubString($exceptionDataBag->getValue())); + } + + $breadcrumbs = []; + $breadcrumbsChanged = false; + foreach ($event->getBreadcrumbs() as $breadcrumb) { + $scrubbedMessage = $breadcrumb->getMessage() !== null ? $this->scrubString($breadcrumb->getMessage()) : null; + $scrubbedCategory = $this->scrubString($breadcrumb->getCategory()); + $scrubbedMetadata = $this->scrubValue($breadcrumb->getMetadata()); + if ($scrubbedMessage !== $breadcrumb->getMessage() + || $scrubbedCategory !== $breadcrumb->getCategory() + || $scrubbedMetadata !== $breadcrumb->getMetadata()) { + $breadcrumb = new Breadcrumb( + $breadcrumb->getLevel(), + $breadcrumb->getType(), + $scrubbedCategory, + $scrubbedMessage, + is_array($scrubbedMetadata) ? $scrubbedMetadata : [], + $breadcrumb->getTimestamp() + ); + $breadcrumbsChanged = true; + } + $breadcrumbs[] = $breadcrumb; + } + if ($breadcrumbsChanged) { + $event->setBreadcrumb($breadcrumbs); + } + + $extra = $this->scrubValue($event->getExtra()); + $event->setExtra(is_array($extra) ? $extra : []); + + foreach ($event->getContexts() as $contextName => $contextData) { + if (is_array($contextData)) { + $scrubbedContext = $this->scrubValue($contextData); + $event->setContext($contextName, is_array($scrubbedContext) ? $scrubbedContext : []); + } + } + + $tags = []; + foreach ($event->getTags() as $tagKey => $tagValue) { + $tags[$this->scrubString((string)$tagKey)] = $this->scrubString((string)$tagValue); + } + $event->setTags($tags); + + $fingerprint = $event->getFingerprint(); + if ($fingerprint !== []) { + $event->setFingerprint(array_map(fn($part) => $this->scrubString((string)$part), $fingerprint)); + } + + $request = $event->getRequest(); + if ($request !== []) { + $scrubbedRequest = $this->scrubValue($request); + $event->setRequest(is_array($scrubbedRequest) ? $scrubbedRequest : []); + } + + $checkIn = $event->getCheckIn(); + if ($checkIn !== null) { + if (!method_exists($checkIn, 'setMonitorSlug')) { + // fail-closed: an unscrubbable check-in must not pass through + throw new RuntimeException('ValuePatternScrubber: CheckIn::setMonitorSlug() is unavailable in this SDK version', 1755264023); + } + $checkIn->setMonitorSlug($this->scrubString($checkIn->getMonitorSlug())); + } + + $transaction = $event->getTransaction(); + if ($transaction !== null) { + $event->setTransaction($this->scrubString($transaction)); + } + foreach ($event->getSpans() as $span) { + $description = $span->getDescription(); + if ($description !== null) { + $span->setDescription($this->scrubString($description)); + } + $spanTags = []; + foreach ($span->getTags() as $spanTagKey => $spanTagValue) { + $spanTags[$this->scrubString((string)$spanTagKey)] = $this->scrubString((string)$spanTagValue); + } + if ($spanTags !== []) { + $span->setTags($spanTags); + } + $spanData = $span->getData(); + if (is_array($spanData) && $spanData !== []) { + $scrubbedSpanData = $this->scrubValue($spanData); + $span->setData(is_array($scrubbedSpanData) ? $scrubbedSpanData : []); + } + } + + // The user interface is deliberately not touched (allowed usernames survive). + return $event; + } + + private function scrubValue(mixed $value, int $depth = 0): mixed + { + if ($depth > $this->maxDepth) { + return $this->replacement . ' (depth limit)'; + } + if (is_string($value)) { + return $this->scrubString($value); + } + if (is_array($value)) { + $result = []; + foreach ($value as $key => $item) { + if (is_string($key) && $this->isSensitiveKey($key)) { + $result[$key] = $this->replacement; + continue; + } + $resultKey = is_string($key) ? $this->scrubString($key) : $key; + $result[$resultKey] = $this->scrubValue($item, $depth + 1); + } + return $result; + } + if (is_object($value)) { + if ($value instanceof \DateTimeInterface) { + return $value; + } + // Never serialize object content here — replace it entirely + return '[object ' . ScrubberChain::normalizeClassName(get_class($value)) . ']'; + } + return $value; + } + + private function isSensitiveKey(string $key): bool + { + $canonicalKey = (string)preg_replace('/[^a-z0-9]/', '', strtolower($key)); + foreach ($this->sensitiveKeys as $sensitiveKey) { + if ($sensitiveKey !== '' && str_contains($canonicalKey, $sensitiveKey)) { + return true; + } + } + return false; + } + + private function scrubString(string $value): string + { + if ($this->applyUrlCredentials) { + $value = preg_replace_callback( + '~\b([a-z][a-z0-9+.-]*)(://)([^/?#@\s]++)@~i', + fn(array $matches) => in_array(strtolower($matches[1]), $this->urlCredentialSchemes, true) + ? $matches[1] . $matches[2] . $this->replacement . '@' + : $matches[0], + $value + ); + if ($value === null) { + throw new RuntimeException('ValuePatternScrubber: url-credentials replacement failed', 1755264021); + } + } + foreach ($this->activePatterns as $patternName => $pattern) { + // preg_replace_callback keeps the replacement literal — a + // configured replacement like "$0" must not re-insert the match + $value = preg_replace_callback($pattern, fn(): string => $this->replacement, $value); + if ($value === null) { + // fail-closed: a failing regex must not let the value through + throw new RuntimeException(sprintf('ValuePatternScrubber: pattern "%s" failed', $patternName), 1755264022); + } + } + return $value; + } +} diff --git a/Classes/SentryClient.php b/Classes/SentryClient.php index 723e993..7d30be5 100644 --- a/Classes/SentryClient.php +++ b/Classes/SentryClient.php @@ -18,6 +18,7 @@ use Flownative\Sentry\Context\WithExtraDataInterface; use Flownative\Sentry\Exception\InvalidConfigurationException; use Flownative\Sentry\Log\CaptureResult; +use Flownative\Sentry\Scrubbing\ScrubberChainFactory; use Neos\Flow\Annotations as Flow; use Neos\Flow\Core\Bootstrap; use Neos\Flow\Error\WithReferenceCodeInterface; @@ -60,6 +61,7 @@ class SentryClient 'before_send_metrics', 'ignore_exceptions', 'integrations', + 'default_integrations', ]; protected string $dsn; @@ -73,6 +75,13 @@ class SentryClient protected array $excludeExceptionMessagePatterns = []; protected array $excludeExceptionCodes = []; protected array $sdkOptions = []; + protected ?ScrubberChainFactory $scrubberChainFactory = null; + protected bool $attachSessionTag = true; + protected bool $attachProcessInfo = true; + protected bool $captureStacktraceVariables = true; + protected bool $captureUncaughtExceptions = true; + protected bool $capturePhpErrors = true; + protected string $userContextMode = 'service'; protected ?StacktraceBuilder $stacktraceBuilder = null; /** @@ -127,6 +136,19 @@ public function injectSettings(array $settings): void $this->excludeExceptionCodes = $settings['capture']['excludeExceptionCodes'] ?? []; $this->errorLevel = $settings['errorLevel'] ?? error_reporting(); $this->sdkOptions = $this->validateSdkOptions($settings['sdkOptions'] ?? []); + $this->scrubberChainFactory = new ScrubberChainFactory($settings['scrubbers'] ?? []); + $this->attachSessionTag = (bool)($settings['attachSessionTag'] ?? true); + $this->attachProcessInfo = (bool)($settings['attachProcessInfo'] ?? true); + $this->captureStacktraceVariables = (bool)($settings['captureStacktraceVariables'] ?? true); + $this->captureUncaughtExceptions = (bool)($settings['captureUncaughtExceptions'] ?? true); + $this->capturePhpErrors = (bool)($settings['capturePhpErrors'] ?? true); + $this->userContextMode = $settings['userContext'] ?? 'service'; + if (!in_array($this->userContextMode, ['service', 'username', 'sha1', 'none'], true)) { + throw new InvalidConfigurationException( + sprintf('Flownative.Sentry.userContext must be one of "service", "username", "sha1" or "none", got "%s"', $this->userContextMode), + 1755264003 + ); + } } /** @@ -164,12 +186,40 @@ public function initializeObject(): void $representationSerializer = new RepresentationSerializer( new Options([]) ); - $representationSerializer->setSerializeAllObjects(true); + if ($this->captureStacktraceVariables) { + $representationSerializer->setSerializeAllObjects(true); + } $this->stacktraceBuilder = new StacktraceBuilder( new Options([]), $representationSerializer ); + $scrubberChainFactory = $this->scrubberChainFactory; + $scrub = static function (Event $event, ?EventHint $hint) use ($scrubberChainFactory): ?Event { + if ($scrubberChainFactory === null) { + return $event; + } + return $scrubberChainFactory->getChain()->process($event, $hint); + }; + $captureLedger = new CaptureLedger(); + + $excludedIntegrationClassNames = []; + if (!$this->captureUncaughtExceptions) { + // Flow's global exception handler routes uncaught throwables into + // the ThrowableStorage; the SDK listener would capture them first + // and produce a second, context-poor event. + $excludedIntegrationClassNames[] = \Sentry\Integration\ExceptionListenerIntegration::class; + } + if (!$this->capturePhpErrors) { + // Errors Flow escalates (exceptionalErrors) arrive as exceptions; + // with the listener removed, errorLevel only selects which fatal + // error types the fatal listener captures. + $excludedIntegrationClassNames[] = \Sentry\Integration\ErrorListenerIntegration::class; + } + $integrationOptions = $excludedIntegrationClassNames !== [] + ? ['integrations' => IntegrationFilter::excluding($excludedIntegrationClassNames)] + : []; + $inAppExclude = [ FLOW_PATH_ROOT . '/Packages/Application/Flownative.Sentry/Classes/', FLOW_PATH_ROOT . '/Packages/Framework/Neos.Flow/Classes/Aop/', @@ -182,7 +232,7 @@ public function initializeObject(): void } // Options set explicitly by this package always win over sdkOptions - \Sentry\init(array_replace($this->sdkOptions, [ + \Sentry\init(array_replace($this->sdkOptions, $integrationOptions, [ 'dsn' => $this->dsn, 'environment' => $this->environment, 'release' => $this->release, @@ -192,14 +242,25 @@ public function initializeObject(): void 'in_app_exclude' => $inAppExclude, 'attach_stacktrace' => true, 'error_types' => $this->errorLevel, - 'before_send' => function (Event $event, ?EventHint $hint): ?Event { - $hasThrowableAndShouldSkip = $hint?->exception && $this->shouldExcludeException($hint->exception); - if ($hasThrowableAndShouldSkip) { - return null; + 'before_send' => function (Event $event, ?EventHint $hint) use ($scrub, $captureLedger): ?Event { + if ($hint?->exception) { + if ($this->shouldExcludeException($hint->exception)) { + return null; + } + if ($captureLedger->hasBeenCaptured($hint->exception)) { + // log-then-rethrow: the incident is already in Sentry + return null; + } } + $event = $scrub($event, $hint); + if ($event !== null && $hint?->exception) { + $captureLedger->remember($hint->exception); + } return $event; - } + }, + 'before_send_transaction' => $scrub, + 'before_send_check_in' => $scrub ])); $client = SentrySdk::getCurrentHub()->getClient(); @@ -210,6 +271,20 @@ public function initializeObject(): void } private function setTags(): void + { + $flowVersion = $this->determineFlowVersion(); + $sessionTag = $this->attachSessionTag ? $this->determineSessionTag() : null; + + SentrySdk::getCurrentHub()->configureScope(static function (Scope $scope) use ($flowVersion, $sessionTag): void { + $scope->setTag('flow_version', $flowVersion); + $scope->setTag('flow_context', (string)Bootstrap::$staticObjectManager->get(Environment::class)->getContext()); + if ($sessionTag !== null) { + $scope->setTag('flow_session_sha1', $sessionTag); + } + }); + } + + private function determineFlowVersion(): string { $flowVersion = ''; if ($this->packageManager) { @@ -222,11 +297,16 @@ private function setTags(): void if (empty($flowVersion)) { $flowVersion = FLOW_VERSION_BRANCH; } + return $flowVersion; + } - SentrySdk::getCurrentHub()->configureScope(static function (Scope $scope) use ($flowVersion): void { - $scope->setTag('flow_version', $flowVersion); - $scope->setTag('flow_context', (string)Bootstrap::$staticObjectManager->get(Environment::class)->getContext()); - }); + private function determineSessionTag(): ?string + { + $currentSession = $this->sessionManager?->getCurrentSession(); + if ($currentSession instanceof Session && $currentSession->isStarted()) { + return sha1($currentSession->getId()); + } + return null; } public function getOptions(): Options @@ -262,21 +342,26 @@ public function captureThrowable(Throwable $throwable, array $extraData = [], ar $extraData = Arrays::arrayMergeRecursiveOverrule($extraData, $throwable->getExtraData()); } - $extraData['PHP Process Inode'] = getmyinode(); - $extraData['PHP Process PID'] = getmypid(); - $extraData['PHP Process UID'] = getmyuid(); - $extraData['PHP Process GID'] = getmygid(); - $extraData['PHP Process User'] = get_current_user(); + if ($this->attachProcessInfo) { + $extraData['PHP Process Inode'] = getmyinode(); + $extraData['PHP Process PID'] = getmypid(); + $extraData['PHP Process UID'] = getmyuid(); + $extraData['PHP Process GID'] = getmygid(); + $extraData['PHP Process User'] = get_current_user(); + } $tags['exception_code'] = (string)$throwable->getCode(); $event = Event::createEvent(); $this->addThrowableToEvent($throwable, $event); + // The hint carries the throwable to before_send, where the capture + // ledger deduplicates log-then-rethrow patterns across all paths + $eventHint = EventHint::fromArray(['exception' => $throwable]); $sentryEventId = null; - SentrySdk::getCurrentHub()->withScope(function (Scope $scope) use ($event, $extraData, $tags, &$sentryEventId): void { + SentrySdk::getCurrentHub()->withScope(function (Scope $scope) use ($event, $eventHint, $extraData, $tags, &$sentryEventId): void { $this->applyCaptureScope($scope, $extraData, $tags); - $sentryEventId = SentrySdk::getCurrentHub()->captureEvent($event); + $sentryEventId = SentrySdk::getCurrentHub()->captureEvent($event, $eventHint); }); return new CaptureResult( @@ -345,13 +430,6 @@ static function(bool $carry, string $pattern) use ($message) { */ private function applyCaptureScope(Scope $scope, array $extraData, array $tags): void { - $securityContext = Bootstrap::$staticObjectManager->get(SecurityContext::class); - if ($securityContext instanceof SecurityContext && $securityContext->isInitialized()) { - $userContext = $this->userContextService->getUserContext($securityContext); - } else { - $userContext = new UserContext(); - } - foreach ($extraData as $extraDataKey => $extraDataValue) { $scope->setExtra($extraDataKey, $extraDataValue); } @@ -359,12 +437,43 @@ private function applyCaptureScope(Scope $scope, array $extraData, array $tags): $scope->setTag($tagKey, $tagValue); } - $currentSession = $this->sessionManager?->getCurrentSession(); - if ($currentSession instanceof Session && $currentSession->isStarted()) { - $scope->setTag('flow_session_sha1', sha1($currentSession->getId())); + // Re-applied per capture as v3.2.0 did, so the tags survive foreign + // code replacing or clearing the hub scope after initialization + $scope->setTag('flow_version', $this->determineFlowVersion()); + $scope->setTag('flow_context', (string)Bootstrap::$staticObjectManager->get(Environment::class)->getContext()); + + if ($this->attachSessionTag) { + $sessionTag = $this->determineSessionTag(); + if ($sessionTag !== null) { + $scope->setTag('flow_session_sha1', $sessionTag); + } + } + + if ($this->userContextMode === 'none') { + // Also removes a user that foreign code put on the inherited scope + $scope->removeUser(); + return; + } + + $securityContext = Bootstrap::$staticObjectManager->get(SecurityContext::class); + if ($securityContext instanceof SecurityContext && $securityContext->isInitialized()) { + $userContext = $this->userContextService->getUserContext($securityContext); + } else { + $userContext = new UserContext(); } - $scope->setUser($userContext->toArray()); + switch ($this->userContextMode) { + case 'username': + $scope->setUser(['username' => (string)($userContext->getUsername() ?? '')]); + break; + case 'sha1': + $username = (string)($userContext->getUsername() ?? ''); + $scope->setUser(['username' => $username !== '' ? sha1($username) : '']); + break; + case 'service': + default: + $scope->setUser($userContext->toArray()); + } } private function renderCleanPathAndFilename(string $rawPathAndFilename): string @@ -395,7 +504,7 @@ private function prepareStacktrace(?Throwable $throwable): ?Stacktrace $stacktrace = $this->stacktraceBuilder->buildFromException($throwable); } else { $stacktrace = $this->stacktraceBuilder->buildFromBacktrace( - debug_backtrace(0), + debug_backtrace($this->captureStacktraceVariables ? 0 : DEBUG_BACKTRACE_IGNORE_ARGS), __FILE__, __LINE__ - 3 ); @@ -410,7 +519,7 @@ private function prepareStacktrace(?Throwable $throwable): ?Stacktrace $frame->getLine(), $frame->getRawFunctionName(), $frame->getAbsoluteFilePath(), - $frame->getVars(), + $this->captureStacktraceVariables ? $frame->getVars() : [], !str_contains($classPathAndFilename, 'Packages/Framework/') ); } diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index 8718a7f..c6828d0 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -14,6 +14,49 @@ Flownative: # in_app_exclude entries are merged with the package defaults. sdkOptions: {} + # Event scrubbers, run for error events, transactions and check-ins after + # the capture excludes. Keys are stable registry identifiers; entries take + # className, an optional position ('start', 'end', numeric, 'before ', + # 'after ') and an optional options array passed to the scrubber's + # constructor. Disable an inherited entry by setting it to null (~). + # Bundled scrubbers: RequestScrubber, ValuePatternScrubber, + # FrameVarsScrubber, SpanDataScrubber (Flownative\Sentry\Scrubbing). + scrubbers: {} + + # Attach a sha1 hash of the session ID as flow_session_sha1 tag. The hash + # is a stable pseudonymous identifier — disable for data minimization. + attachSessionTag: true + + # Attach PHP process information (PID, UID, GID, inode, user) to events. + attachProcessInfo: true + + # Serialize function arguments into stack trace frames. Arguments can + # contain any application data (form contents, domain objects) — disable + # for data minimization. Also controls argument capture for message events. + captureStacktraceVariables: true + + # Register the SDK's own uncaught-exception listener. Flow's global + # exception handler already routes uncaught throwables into the + # ThrowableStorage (= Sentry), so the listener produces a second, + # context-poor event for the same incident. Disable to capture uncaught + # exceptions only through Flow. Note: exceptions whose renderingGroup + # sets logException: false (404/410 by default) are then not captured. + captureUncaughtExceptions: true + + # Register the SDK's own PHP error listener. Errors Flow escalates to + # exceptions (exceptionalErrors: E_USER_ERROR, E_RECOVERABLE_ERROR) + # otherwise produce a duplicate event. With the listener disabled, + # warnings/notices no longer create standalone events and errorLevel + # only selects which fatal error types are captured at shutdown. + capturePhpErrors: true + + # Which user information is attached to events: + # 'service' — whatever the UserContextService returns (default) + # 'username' — only the username field + # 'sha1' — only a sha1 hash of the username + # 'none' — no user information at all + userContext: 'service' + capture: excludeExceptionTypes: 'Neos\Flow\Mvc\Controller\Exception\InvalidControllerException': true diff --git a/README.md b/README.md index ea7e4f2..ea8b315 100644 --- a/README.md +++ b/README.md @@ -210,3 +210,111 @@ There are two more test modes for message capturing and error handling: - `./flow sentry:test --mode message` - `./flow sentry:test --mode error` + +## Additional SDK options + +Any YAML-representable Sentry SDK client option can be passed through to +`\Sentry\init()` via `sdkOptions`: + +```yaml +Flownative: + Sentry: + sdkOptions: + max_request_body_size: 'never' + send_default_pii: false +``` + +Options this package sets explicitly (`dsn`, `environment`, `release`, the +sample rates, `error_types` and the exception excludes) always take +precedence. The `before_send*` callbacks, `ignore_exceptions` and +`integrations` are managed by the package and rejected with an exception at +boot. `in_app_exclude` entries are merged with the package defaults. Which +options exist depends on the installed `sentry/sentry` version. + +## Event scrubbing + +Scrubbers remove or redact data from events before they are sent. They run +for error events, transactions and check-ins, after the configured exception +excludes. Registration is explicit, keyed by a stable identifier: + +```yaml +Flownative: + Sentry: + scrubbers: + 'Acme.Request': + className: 'Flownative\Sentry\Scrubbing\RequestScrubber' + position: 100 + options: + queryParamAllowlist: ['search'] + 'Acme.Patterns': + className: 'Flownative\Sentry\Scrubbing\ValuePatternScrubber' + position: 200 + options: + patterns: ['url-credentials', 'email', 'ipv4', 'ipv6'] +``` + +`position` supports numeric values, `'start'`, `'end'`, `'before '` and +`'after '` (Flow's `PositionalArraySorter`). Entries without a position +run between `start` and `end` — give privacy scrubbers explicit numeric +positions so their order is deterministic. An inherited entry can be +disabled with `'Acme.Request': ~`. + +A scrubber implements `Flownative\Sentry\Scrubbing\EventScrubberInterface` +and is constructed with its `options` array. Returning `null` discards the +event. If a scrubber throws, the event is discarded and a synthetic +replacement event (containing only the scrubber identifier, exception class +names, the integer exception code and the discarded event ID) is sent +instead, so scrubbing failures stay visible in Sentry. + +Bundled scrubbers: + +- `RequestScrubber` — reduces the request interface to method, URL, query + string and headers; the query string is filtered against + `queryParamAllowlist`, headers against `headerAllowlist` (keep-only), + body, cookies and env are always dropped. Also filters breadcrumb URLs. +- `ValuePatternScrubber` — redacts built-in patterns (`url-credentials`, + `email`, `ipv4`, `ipv6`, `iban`, `phone`) from messages, exception + values, breadcrumbs, extras, contexts, tags, fingerprints and the request + interface, redacts sensitive keys entirely and replaces objects without + serializing their content. The user interface is not touched. +- `FrameVarsScrubber` — removes captured function arguments, absolute file + paths and source context from stack trace frames. +- `SpanDataScrubber` — drops span data and removes URL query strings from + transaction names, span descriptions and span tags. + +## Data minimization options + +```yaml +Flownative: + Sentry: + attachSessionTag: false # no flow_session_sha1 tag (default: true) + attachProcessInfo: false # no PID/UID/GID/inode/user extras (default: true) + captureStacktraceVariables: false # no function arguments in stack traces (default: true) + userContext: 'username' # 'service' (default), 'username', 'sha1' or 'none' +``` + +All defaults preserve the previous behavior of this package. + +## Duplicate event prevention + +Two capture pipelines exist in a Flow application: the SDK's own listeners +(registered by `\Sentry\init()`) and Flow's exception handling, which routes +throwables into this package via the ThrowableStorage. Without precautions +one incident can produce two events. + +- `captureUncaughtExceptions: false` removes the SDK's uncaught-exception + listener; uncaught throwables are captured once, through Flow, with full + context. Exceptions whose renderingGroup sets `logException: false` + (404/410 by default) are then not captured at all. +- `capturePhpErrors: false` removes the SDK's PHP error listener; errors + Flow escalates (`exceptionalErrors`) are captured once, as exceptions. + Warnings/notices then produce no standalone events, and `errorLevel` only + selects which fatal error types the shutdown handler captures. +- A per-process capture ledger (WeakMap over the throwable and its whole + `getPrevious()` chain, marked only after an accepted capture) + deduplicates log-then-rethrow patterns — e.g. Flow's Doctrine `Query` + logs the driver exception and rethrows a wrapper; only one event is sent. + +Both toggles default to `true` (previous behavior). `default_integrations` +and `integrations` are managed by the package and rejected in `sdkOptions`; +`capture_silenced_errors` has no effect while the error listener is removed. diff --git a/Resources/Private/Schema/Settings/Flownative.Sentry.schema.yaml b/Resources/Private/Schema/Settings/Flownative.Sentry.schema.yaml new file mode 100644 index 0000000..ea3a725 --- /dev/null +++ b/Resources/Private/Schema/Settings/Flownative.Sentry.schema.yaml @@ -0,0 +1,45 @@ +type: dictionary +additionalProperties: true +properties: + dsn: { type: ['string', 'null'] } + environment: { type: ['string', 'null'] } + release: { type: ['string', 'null'] } + sampleRate: { type: ['number', 'integer'] } + tracesSampleRate: { type: ['number', 'integer'] } + errorLevel: { type: ['integer', 'null'] } + + sdkOptions: + type: dictionary + additionalProperties: true + + scrubbers: + type: dictionary + additionalProperties: + type: ['dictionary', 'null'] + properties: + className: { type: string, format: class-name } + position: { type: ['string', 'integer'] } + options: + type: dictionary + additionalProperties: true + + attachSessionTag: { type: boolean } + attachProcessInfo: { type: boolean } + captureStacktraceVariables: { type: boolean } + captureUncaughtExceptions: { type: boolean } + capturePhpErrors: { type: boolean } + userContext: + type: string + enum: ['service', 'username', 'sha1', 'none'] + + capture: + type: dictionary + properties: + excludeExceptionTypes: + type: dictionary + additionalProperties: { type: boolean } + excludeExceptionMessagePatterns: + type: array + items: { type: string } + excludeExceptionCodes: + type: array diff --git a/Tests/Unit/CaptureLedgerTest.php b/Tests/Unit/CaptureLedgerTest.php new file mode 100644 index 0000000..ab9348c --- /dev/null +++ b/Tests/Unit/CaptureLedgerTest.php @@ -0,0 +1,51 @@ +hasBeenCaptured(new \RuntimeException('fresh'))); + } + + public function testRememberedThrowableIsCaptured(): void + { + $ledger = new CaptureLedger(); + $throwable = new \RuntimeException('captured'); + $ledger->remember($throwable); + self::assertTrue($ledger->hasBeenCaptured($throwable)); + } + + public function testWrapperCarryingCapturedOriginalIsDetected(): void + { + $ledger = new CaptureLedger(); + $original = new \PDOException('driver error'); + $ledger->remember($original); + + $wrapper = new \RuntimeException('query failed', 0, $original); + self::assertTrue($ledger->hasBeenCaptured($wrapper)); + } + + public function testRememberingWrapperMarksWholeChain(): void + { + $ledger = new CaptureLedger(); + $original = new \PDOException('driver error'); + $wrapper = new \RuntimeException('query failed', 0, $original); + $ledger->remember($wrapper); + + self::assertTrue($ledger->hasBeenCaptured($original)); + } + + public function testDistinctThrowablesAreIndependent(): void + { + $ledger = new CaptureLedger(); + $ledger->remember(new \RuntimeException('first attempt')); + self::assertFalse($ledger->hasBeenCaptured(new \RuntimeException('second attempt'))); + } +} diff --git a/Tests/Unit/IntegrationFilterTest.php b/Tests/Unit/IntegrationFilterTest.php new file mode 100644 index 0000000..94fc733 --- /dev/null +++ b/Tests/Unit/IntegrationFilterTest.php @@ -0,0 +1,39 @@ + 'visitor@example.com', 'message' => 'secret'], + true + ), + ]); + } + + public function testExceptionStacktraceFramesLoseVarsAndAbsolutePath(): void + { + $event = Event::createEvent(); + $event->setExceptions([ + new ExceptionDataBag(new \RuntimeException('boom'), $this->stacktraceWithVars(), null), + ]); + + $event = (new FrameVarsScrubber())->scrub($event, null); + $frame = $event->getExceptions()[0]->getStacktrace()->getFrames()[0]; + + self::assertSame([], $frame->getVars()); + self::assertNull($frame->getAbsoluteFilePath()); + self::assertSame('App\Controller::submitAction', $frame->getFunctionName()); + self::assertSame('DistributionPackages/App/Classes/Controller.php', $frame->getFile()); + self::assertSame(42, $frame->getLine()); + self::assertTrue($frame->isInApp()); + } + + public function testEventLevelStacktraceIsAlsoStripped(): void + { + $event = Event::createEvent(); + $event->setStacktrace($this->stacktraceWithVars()); + + $event = (new FrameVarsScrubber())->scrub($event, null); + + self::assertSame([], $event->getStacktrace()->getFrames()[0]->getVars()); + } + + public function testAbsoluteFilePathsAreRelativized(): void + { + $event = Event::createEvent(); + $event->setStacktrace(new Stacktrace([ + new Frame('vendor_function', '/Users/alice/Sites/project/vendor/lib/File.php', 10, null, '/Users/alice/Sites/project/vendor/lib/File.php', [], false), + ])); + + $frame = (new FrameVarsScrubber())->scrub($event, null)->getStacktrace()->getFrames()[0]; + + self::assertStringNotContainsString('/Users/alice', $frame->getFile()); + self::assertSame('…/File.php', $frame->getFile()); + } + + public function testAnonymousClassPathsInFunctionNamesAreStripped(): void + { + $event = Event::createEvent(); + $event->setStacktrace(new Stacktrace([ + new Frame('class@anonymous/Users/alice/Sites/secret/File.php:12$3f::handle', 'File.php', 12, 'class@anonymous/Users/alice/Sites/secret/File.php:12$3f::handle', null, [], true), + ])); + + $frame = (new FrameVarsScrubber())->scrub($event, null)->getStacktrace()->getFrames()[0]; + + self::assertStringNotContainsString('/Users/alice', (string)$frame->getFunctionName()); + self::assertStringNotContainsString('/Users/alice', (string)$frame->getRawFunctionName()); + } +} diff --git a/Tests/Unit/Scrubbing/RequestScrubberTest.php b/Tests/Unit/Scrubbing/RequestScrubberTest.php new file mode 100644 index 0000000..82a4375 --- /dev/null +++ b/Tests/Unit/Scrubbing/RequestScrubberTest.php @@ -0,0 +1,114 @@ +setRequest([ + 'method' => 'POST', + 'url' => 'https://example.com/contact', + 'data' => ['email' => 'visitor@example.com', 'message' => 'secret'], + 'cookies' => ['session' => 'abc'], + 'env' => ['REMOTE_ADDR' => '203.0.113.7'], + ]); + + $request = $scrubber->scrub($event, null)->getRequest(); + + self::assertSame('POST', $request['method']); + self::assertArrayNotHasKey('data', $request); + self::assertArrayNotHasKey('cookies', $request); + self::assertArrayNotHasKey('env', $request); + } + + public function testQueryStringIsFilteredAgainstAllowlist(): void + { + $scrubber = new RequestScrubber(['queryParamAllowlist' => ['search']]); + $event = Event::createEvent(); + $event->setRequest([ + 'url' => 'https://example.com/find?search=a&search=b&token=SECRET&se%61rch=encoded#fragment', + 'query_string' => 'search=a&search=b&token=SECRET', + ]); + + $request = $scrubber->scrub($event, null)->getRequest(); + + // repeated params survive, token is dropped, encoded name is form-decoded for comparison, fragment is gone + self::assertSame('https://example.com/find?search=a&search=b&se%61rch=encoded', $request['url']); + self::assertSame('search=a&search=b', $request['query_string']); + } + + public function testUrlWithoutRemainingParamsLosesQuestionMark(): void + { + $scrubber = new RequestScrubber(); + $event = Event::createEvent(); + $event->setRequest(['url' => 'https://example.com/find?token=SECRET']); + + self::assertSame('https://example.com/find', $scrubber->scrub($event, null)->getRequest()['url']); + } + + public function testHeadersAreKeepOnlyAndRefererQueryIsFiltered(): void + { + $scrubber = new RequestScrubber(['headerAllowlist' => ['User-Agent', 'Referer'], 'queryParamAllowlist' => ['search']]); + $event = Event::createEvent(); + $event->setRequest([ + 'headers' => [ + 'user-agent' => 'TestBrowser/1.0', + 'X-Custom-Secret' => 'internal', + 'Referer' => 'https://example.com/from?search=term&token=SECRET', + ], + ]); + + $headers = $scrubber->scrub($event, null)->getRequest()['headers']; + + self::assertSame('TestBrowser/1.0', $headers['user-agent']); + self::assertArrayNotHasKey('X-Custom-Secret', $headers); + self::assertSame('https://example.com/from?search=term', $headers['Referer']); + } + + public function testSemicolonDelimitersArePreservedAndFragmentInQueryStringIsCut(): void + { + $scrubber = new RequestScrubber(['queryParamAllowlist' => ['a', 'c']]); + $event = Event::createEvent(); + $event->setRequest(['query_string' => 'a=1;b=2;c=3&d=4#frag']); + + self::assertSame('a=1;c=3', $scrubber->scrub($event, null)->getRequest()['query_string']); + } + + public function testParamNamesAreCaseSensitive(): void + { + $scrubber = new RequestScrubber(['queryParamAllowlist' => ['search']]); + $event = Event::createEvent(); + $event->setRequest(['query_string' => 'Search=term&search=kept']); + + self::assertSame('search=kept', $scrubber->scrub($event, null)->getRequest()['query_string']); + } + + public function testBreadcrumbUrlMetadataIsFiltered(): void + { + $scrubber = new RequestScrubber(['queryParamAllowlist' => ['page']]); + $event = Event::createEvent(); + $event->setBreadcrumb([ + new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_HTTP, + 'http', + null, + ['url' => 'https://example.com/api?page=2&token=SECRET', 'status_code' => 200] + ), + ]); + + $breadcrumbs = $scrubber->scrub($event, null)->getBreadcrumbs(); + + self::assertSame('https://example.com/api?page=2', $breadcrumbs[0]->getMetadata()['url']); + self::assertSame(200, $breadcrumbs[0]->getMetadata()['status_code']); + } +} diff --git a/Tests/Unit/Scrubbing/ScrubberChainClientIntegrationTest.php b/Tests/Unit/Scrubbing/ScrubberChainClientIntegrationTest.php new file mode 100644 index 0000000..a3907f7 --- /dev/null +++ b/Tests/Unit/Scrubbing/ScrubberChainClientIntegrationTest.php @@ -0,0 +1,130 @@ +sentEvents) implements TransportInterface { + public function __construct(private array &$sentEvents) + { + } + + public function send(Event $event): Result + { + $this->sentEvents[] = $event; + return new Result(ResultStatus::success(), $event); + } + + public function close(?int $timeout = null): Result + { + return new Result(ResultStatus::success()); + } + }; + + $options = [ + 'dsn' => 'https://public@example.invalid/1', + 'before_send' => $beforeSend, + ]; + $builder = ClientBuilder::create($options); + $builder->setTransport($transport); + return new Hub($builder->getClient()); + } + + public function testScopeEnrichedEventIsScrubbedThroughRealClient(): void + { + $factory = new ScrubberChainFactory([ + 'patterns' => ['className' => ValuePatternScrubber::class, 'options' => ['patterns' => ['email']]], + ]); + $scrub = static fn(Event $event, ?EventHint $hint): ?Event => $factory->getChain()->process($event, $hint); + + $hub = $this->buildHub($scrub); + $hub->configureScope(static function (Scope $scope): void { + $scope->setExtra('note', 'reply to visitor@example.com'); + }); + $hub->captureMessage('boom from visitor@example.com'); + + self::assertCount(1, $this->sentEvents); + $sentEvent = $this->sentEvents[0]; + self::assertSame('boom from [Filtered]', $sentEvent->getMessage()); + self::assertSame('reply to [Filtered]', $sentEvent->getExtra()['note']); + } + + public function testLedgerComposedBeforeSendDeduplicatesLogThenRethrow(): void + { + // Mirrors the package's before_send composition: ledger check first, + // then scrub, then mark on accept + $ledger = new \Flownative\Sentry\CaptureLedger(); + $beforeSend = static function (Event $event, ?EventHint $hint) use ($ledger): ?Event { + if ($hint?->exception !== null) { + if ($ledger->hasBeenCaptured($hint->exception)) { + return null; + } + } + if ($event !== null && $hint?->exception !== null) { + $ledger->remember($hint->exception); + } + return $event; + }; + + $hub = $this->buildHub($beforeSend); + $original = new \RuntimeException('driver failure'); + $hub->captureException($original); + $wrapper = new \LogicException('query failed', 0, $original); + $hub->captureException($wrapper); + $unrelated = new \RuntimeException('second incident'); + $hub->captureException($unrelated); + + self::assertCount(2, $this->sentEvents); + } + + public function testFailingScrubberYieldsSyntheticEventThroughRealClient(): void + { + $factory = new ScrubberChainFactory([ + 'broken' => ['className' => ThrowingScrubber::class], + ]); + $scrub = static fn(Event $event, ?EventHint $hint): ?Event => $factory->getChain()->process($event, $hint); + + $hub = $this->buildHub($scrub); + $hub->captureMessage('secret visitor@example.com payload'); + + self::assertCount(1, $this->sentEvents); + $sentEvent = $this->sentEvents[0]; + self::assertSame('Flownative Sentry event scrubber failed – the original event was discarded', $sentEvent->getMessage()); + self::assertStringNotContainsString('visitor@example.com', var_export($sentEvent->getExtra(), true)); + } +} + +final class ThrowingScrubber implements \Flownative\Sentry\Scrubbing\EventScrubberInterface +{ + public function __construct(array $options = []) + { + } + + public function scrub(Event $event, ?EventHint $hint): ?Event + { + throw new \RuntimeException('scrubber exploded'); + } +} diff --git a/Tests/Unit/Scrubbing/ScrubberChainFactoryTest.php b/Tests/Unit/Scrubbing/ScrubberChainFactoryTest.php new file mode 100644 index 0000000..2c1fb67 --- /dev/null +++ b/Tests/Unit/Scrubbing/ScrubberChainFactoryTest.php @@ -0,0 +1,94 @@ +name = $options['name'] ?? '?'; + } + + public function scrub(Event $event, ?EventHint $hint): ?Event + { + self::$order[] = $this->name; + return $event; + } +} + +final class ScrubberChainFactoryTest extends TestCase +{ + public function testEntryWithoutClassNameIsRejected(): void + { + $this->expectException(InvalidConfigurationException::class); + new ScrubberChainFactory(['broken' => ['position' => 'end']]); + } + + public function testUnknownClassIsRejected(): void + { + $this->expectException(InvalidConfigurationException::class); + new ScrubberChainFactory(['broken' => ['className' => 'No\Such\ClassHere']]); + } + + public function testClassWithoutInterfaceIsRejected(): void + { + $this->expectException(InvalidConfigurationException::class); + new ScrubberChainFactory(['broken' => ['className' => \stdClass::class]]); + } + + public function testNonArrayOptionsAreRejected(): void + { + $this->expectException(InvalidConfigurationException::class); + new ScrubberChainFactory(['broken' => ['className' => FrameVarsScrubber::class, 'options' => 'nope']]); + } + + public function testNullEntryDisablesScrubber(): void + { + OrderRecordingScrubber::$order = []; + $factory = new ScrubberChainFactory([ + 'active' => ['className' => OrderRecordingScrubber::class, 'options' => ['name' => 'active']], + 'disabled' => null, + ]); + $factory->getChain()->process(Event::createEvent(), null); + self::assertSame(['active'], OrderRecordingScrubber::$order); + } + + public function testNumericPositionsDetermineOrder(): void + { + OrderRecordingScrubber::$order = []; + $factory = new ScrubberChainFactory([ + 'later' => ['className' => OrderRecordingScrubber::class, 'position' => 200, 'options' => ['name' => 'later']], + 'earlier' => ['className' => OrderRecordingScrubber::class, 'position' => 100, 'options' => ['name' => 'earlier']], + ]); + $factory->getChain()->process(Event::createEvent(), null); + self::assertSame(['earlier', 'later'], OrderRecordingScrubber::$order); + } + + public function testUnresolvablePositionReferenceIsRejectedAtConstruction(): void + { + $this->expectException(InvalidConfigurationException::class); + new ScrubberChainFactory([ + 'orphan' => ['className' => FrameVarsScrubber::class, 'position' => 'after nonexisting'], + ]); + } + + public function testScrubberConstructorFailureIsRejectedAtConstruction(): void + { + $this->expectException(InvalidConfigurationException::class); + new ScrubberChainFactory([ + 'broken' => ['className' => \Flownative\Sentry\Scrubbing\ValuePatternScrubber::class, 'options' => ['patterns' => ['no-such-pattern']]], + ]); + } +} diff --git a/Tests/Unit/Scrubbing/ScrubberChainTest.php b/Tests/Unit/Scrubbing/ScrubberChainTest.php new file mode 100644 index 0000000..e9ee4b8 --- /dev/null +++ b/Tests/Unit/Scrubbing/ScrubberChainTest.php @@ -0,0 +1,108 @@ +calls[] = $this->name; + return $event; + } + }; + }; + + $chain = new ScrubberChain(['first' => $makeScrubber('first'), 'second' => $makeScrubber('second')]); + $result = $chain->process(Event::createEvent(), null); + + self::assertNotNull($result); + self::assertSame(['first', 'second'], $calls); + } + + public function testNullReturnDiscardsEventAndStopsChain(): void + { + $secondCalled = false; + $discarding = new class implements EventScrubberInterface { + public function scrub(Event $event, ?EventHint $hint): ?Event + { + return null; + } + }; + $recording = new class($secondCalled) implements EventScrubberInterface { + public function __construct(private bool &$called) + { + } + + public function scrub(Event $event, ?EventHint $hint): ?Event + { + $this->called = true; + return $event; + } + }; + + $chain = new ScrubberChain(['discard' => $discarding, 'after' => $recording]); + + self::assertNull($chain->process(Event::createEvent(), null)); + self::assertFalse($secondCalled); + } + + public function testThrowingScrubberProducesSyntheticFailureEvent(): void + { + $throwing = new class implements EventScrubberInterface { + public function scrub(Event $event, ?EventHint $hint): ?Event + { + throw new \RuntimeException('contains secret@example.com PII'); + } + }; + $chain = new ScrubberChain(['broken' => $throwing]); + + $originalEvent = Event::createEvent(); + $originalEvent->setMessage('original message with visitor@example.com'); + $hint = EventHint::fromArray(['exception' => new \DomainException('sensitive original message', 4711)]); + + $failureEvent = $chain->process($originalEvent, $hint); + + self::assertNotNull($failureEvent); + self::assertNotSame($originalEvent, $failureEvent); + self::assertSame('Flownative Sentry event scrubber failed – the original event was discarded', $failureEvent->getMessage()); + self::assertSame(['flownative-sentry-scrubber-failure', 'broken', 'RuntimeException'], $failureEvent->getFingerprint()); + self::assertSame('flownative.sentry.scrubber', $failureEvent->getLogger()); + + $extra = $failureEvent->getExtra(); + self::assertSame('broken', $extra['scrubber']); + self::assertSame('RuntimeException', $extra['scrubber_exception_class']); + self::assertSame('DomainException', $extra['original_exception_class']); + self::assertSame(4711, $extra['original_exception_code']); + self::assertSame((string)$originalEvent->getId(), $extra['discarded_event_id']); + + // Nothing from the original event or the throwable messages may appear + $serialized = var_export($extra, true) . (string)$failureEvent->getMessage(); + self::assertStringNotContainsString('visitor@example.com', $serialized); + self::assertStringNotContainsString('secret@example.com', $serialized); + self::assertStringNotContainsString('sensitive original message', $serialized); + } + + public function testNormalizeClassName(): void + { + self::assertSame('RuntimeException', ScrubberChain::normalizeClassName('RuntimeException')); + self::assertSame('Foo\Bar\Baz', ScrubberChain::normalizeClassName('Foo\Bar\Baz')); + self::assertSame('[anonymous]', ScrubberChain::normalizeClassName('class@anonymous/var/www/secret/path.php:12$0')); + self::assertSame('[invalid-class]', ScrubberChain::normalizeClassName("Weird\0Class/With/Path")); + } +} diff --git a/Tests/Unit/Scrubbing/SpanDataScrubberTest.php b/Tests/Unit/Scrubbing/SpanDataScrubberTest.php new file mode 100644 index 0000000..fe9642e --- /dev/null +++ b/Tests/Unit/Scrubbing/SpanDataScrubberTest.php @@ -0,0 +1,76 @@ +setTransaction('GET https://example.com/find?search=visitor%40example.com'); + + $event = (new SpanDataScrubber())->scrub($event, null); + + self::assertSame('GET https://example.com/find', $event->getTransaction()); + } + + public function testSpanDataIsDroppedAndDescriptionQueryStripped(): void + { + $span = new Span(); + $span->setDescription('GET https://api.example.com/person?email=visitor%40example.com'); + $span->setData(['body' => 'secret']); + + $event = Event::createEvent(); + $event->setSpans([$span]); + + (new SpanDataScrubber())->scrub($event, null); + + self::assertSame(['body' => '[Filtered]'], $span->getData()); + self::assertSame('GET https://api.example.com/person', $span->getDescription()); + } + + public function testSqlPlaceholdersSurvive(): void + { + $span = new Span(); + $span->setDescription('SELECT * FROM feedback WHERE id = ? AND text = ?'); + + $event = Event::createEvent(); + $event->setSpans([$span]); + + (new SpanDataScrubber())->scrub($event, null); + + self::assertSame('SELECT * FROM feedback WHERE id = ? AND text = ?', $span->getDescription()); + } + + public function testRelativeAndVerbPrefixedTargetsLoseTheirQuery(): void + { + $span = new Span(); + $span->setDescription('GET /person?email=visitor%40example.com'); + + $event = Event::createEvent(); + $event->setTransaction('/find?search=secret'); + $event->setSpans([$span]); + + $event = (new SpanDataScrubber())->scrub($event, null); + + self::assertSame('/find', $event->getTransaction()); + self::assertSame('GET /person', $span->getDescription()); + } + + public function testTraceContextDataIsCleared(): void + { + $event = Event::createEvent(); + $event->setContext('trace', ['trace_id' => 'abc', 'data' => ['url' => 'https://x?y=z']]); + + $event = (new SpanDataScrubber())->scrub($event, null); + + self::assertSame([], $event->getContexts()['trace']['data']); + self::assertSame('abc', $event->getContexts()['trace']['trace_id']); + } +} diff --git a/Tests/Unit/Scrubbing/ValuePatternScrubberTest.php b/Tests/Unit/Scrubbing/ValuePatternScrubberTest.php new file mode 100644 index 0000000..411663f --- /dev/null +++ b/Tests/Unit/Scrubbing/ValuePatternScrubberTest.php @@ -0,0 +1,258 @@ + $patterns], $options)); + } + + public function testEmailIsRedactedInMessageAndExtra(): void + { + $scrubber = $this->scrubberWith(['email']); + $event = Event::createEvent(); + $event->setMessage('Contact not found for email visitor@example.com'); + $event->setExtra(['note' => 'reply to visitor@example.com please']); + + $event = $scrubber->scrub($event, null); + + self::assertSame('Contact not found for email [Filtered]', $event->getMessage()); + self::assertSame('reply to [Filtered] please', $event->getExtra()['note']); + } + + public function testUrlCredentialsAreRedactedForAllowlistedSchemesOnly(): void + { + $scrubber = $this->scrubberWith(['url-credentials']); + $event = Event::createEvent(); + $event->setMessage(implode(' ', [ + 'https://elastic:s3cret@es.example.com:9200/index', + 'redis://default:hunter2@redis.example.com', + 'https://tokenonly@api.example.com', + 'namespace://foo:bar@notacredential', + 'mailto:someone@example.com', + ])); + + $message = $scrubber->scrub($event, null)->getMessage(); + + self::assertStringContainsString('https://[Filtered]@es.example.com:9200/index', $message); + self::assertStringContainsString('redis://[Filtered]@redis.example.com', $message); + self::assertStringContainsString('https://[Filtered]@api.example.com', $message); + self::assertStringContainsString('namespace://foo:bar@notacredential', $message); + self::assertStringContainsString('mailto:someone@example.com', $message); + self::assertStringNotContainsString('s3cret', $message); + self::assertStringNotContainsString('hunter2', $message); + self::assertStringNotContainsString('tokenonly', $message); + } + + public function testIpAddressesAreRedacted(): void + { + $scrubber = $this->scrubberWith(['ipv4', 'ipv6']); + $event = Event::createEvent(); + $event->setMessage('from 203.0.113.7 and fe80::1a2b:3c4d%eth0 but version 1.2 stays'); + + $message = $scrubber->scrub($event, null)->getMessage(); + + self::assertStringNotContainsString('203.0.113.7', $message); + self::assertStringNotContainsString('fe80::1a2b', $message); + self::assertStringContainsString('version 1.2 stays', $message); + } + + public function testSensitiveKeysAreRedactedEntirely(): void + { + $scrubber = $this->scrubberWith([]); + $event = Event::createEvent(); + $event->setExtra([ + 'Password' => 'hunter2', + 'nested' => ['api_key' => 'abc123', 'harmless' => 'value'], + ]); + + $extra = $scrubber->scrub($event, null)->getExtra(); + + self::assertSame('[Filtered]', $extra['Password']); + self::assertSame('[Filtered]', $extra['nested']['api_key']); + self::assertSame('value', $extra['nested']['harmless']); + } + + public function testObjectsAreReplacedWithoutSerializingContent(): void + { + $scrubber = $this->scrubberWith([]); + $formData = new class { + public string $email = 'visitor@example.com'; + }; + $event = Event::createEvent(); + $event->setExtra(['form' => $formData, 'when' => new \DateTimeImmutable('2026-08-15')]); + + $extra = $scrubber->scrub($event, null)->getExtra(); + + self::assertIsString($extra['form']); + self::assertStringStartsWith('[object ', $extra['form']); + self::assertStringNotContainsString('visitor@example.com', $extra['form']); + self::assertInstanceOf(\DateTimeImmutable::class, $extra['when']); + } + + public function testDepthLimitCutsDeepStructures(): void + { + $scrubber = $this->scrubberWith([], ['maxDepth' => 2]); + $event = Event::createEvent(); + $event->setExtra(['a' => ['b' => ['c' => ['d' => 'too deep']]]]); + + $extra = $scrubber->scrub($event, null)->getExtra(); + + self::assertSame('[Filtered] (depth limit)', $extra['a']['b']['c']); + } + + public function testTagsAndFingerprintAreScrubbed(): void + { + $scrubber = $this->scrubberWith(['email']); + $event = Event::createEvent(); + $event->setTags(['reporter' => 'visitor@example.com']); + $event->setFingerprint(['route', 'visitor@example.com']); + + $event = $scrubber->scrub($event, null); + + self::assertSame('[Filtered]', $event->getTags()['reporter']); + self::assertSame(['route', '[Filtered]'], $event->getFingerprint()); + } + + public function testUserInterfaceIsNotTouched(): void + { + $scrubber = $this->scrubberWith(['email']); + $event = Event::createEvent(); + $event->setUser(UserDataBag::createFromArray(['username' => 'editor@agency.example'])); + + $event = $scrubber->scrub($event, null); + + self::assertSame('editor@agency.example', $event->getUser()->getUsername()); + } + + public function testBreadcrumbMessageAndMetadataAreScrubbed(): void + { + $scrubber = $this->scrubberWith(['email']); + $event = Event::createEvent(); + $event->setBreadcrumb([ + new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_DEFAULT, + 'System_Development.log', + 'Blocked spam from visitor@example.com', + ['additionalData' => ['referrer' => 'mail from visitor@example.com']] + ), + ]); + + $breadcrumb = $scrubber->scrub($event, null)->getBreadcrumbs()[0]; + + self::assertSame('Blocked spam from [Filtered]', $breadcrumb->getMessage()); + self::assertSame('mail from [Filtered]', $breadcrumb->getMetadata()['additionalData']['referrer']); + } + + public function testUnknownPatternNameIsRejectedAtConstruction(): void + { + $this->expectException(\RuntimeException::class); + new ValuePatternScrubber(['patterns' => ['no-such-pattern']]); + } + + public function testNonStringMessageParamsAreScrubbedToo(): void + { + $scrubber = $this->scrubberWith(['email']); + $event = Event::createEvent(); + $event->setMessage('Submission failed for %s', [ + 'contact' => ['email' => 'visitor@example.com'], + 'Password' => 'hunter2', + ]); + + $params = $scrubber->scrub($event, null)->getMessageParams(); + + self::assertSame('[Filtered]', $params['contact']['email']); + self::assertSame('[Filtered]', $params['Password']); + } + + public function testDollarZeroReplacementDoesNotReinsertTheMatch(): void + { + $scrubber = $this->scrubberWith(['email'], ['replacement' => '$0']); + $event = Event::createEvent(); + $event->setMessage('mail from visitor@example.com'); + + self::assertSame('mail from $0', $scrubber->scrub($event, null)->getMessage()); + } + + public function testSeparatorVariantsOfSensitiveKeysAreCaught(): void + { + $scrubber = $this->scrubberWith([]); + $event = Event::createEvent(); + $event->setExtra([ + 'X-Api-Key' => 'abc', + 'session-id' => 'def', + 'sessionId' => 'ghi', + 'passphrase' => 'jkl', + ]); + + $extra = $scrubber->scrub($event, null)->getExtra(); + + self::assertSame(['X-Api-Key' => '[Filtered]', 'session-id' => '[Filtered]', 'sessionId' => '[Filtered]', 'passphrase' => '[Filtered]'], $extra); + } + + public function testIpv6TrailingCompressionAndIsoDatesAreHandled(): void + { + $scrubber = $this->scrubberWith(['ipv6', 'phone']); + $event = Event::createEvent(); + $event->setMessage('net 2001:db8:: on 2026-08-15 call +43 660 1234567'); + + $message = $scrubber->scrub($event, null)->getMessage(); + + self::assertStringNotContainsString('2001:db8::', $message); + self::assertStringContainsString('2026-08-15', $message); + self::assertStringNotContainsString('660 1234567', $message); + } + + public function testBareDigitRunsAndDatesSurviveThePhonePattern(): void + { + $scrubber = $this->scrubberWith(['phone']); + $event = Event::createEvent(); + $event->setMessage('code 1662712736 ref 20260816105252694 on 16.08.2026, call (0512) 53 60 93'); + + $message = $scrubber->scrub($event, null)->getMessage(); + + // Flow exception codes, reference codes and dotted dates are not phone numbers + self::assertStringContainsString('1662712736', $message); + self::assertStringContainsString('20260816105252694', $message); + self::assertStringContainsString('16.08.2026', $message); + // a separator-formatted number still is + self::assertStringNotContainsString('53 60 93', $message); + } + + public function testSpacedIbanIsRedacted(): void + { + $scrubber = $this->scrubberWith(['iban']); + $event = Event::createEvent(); + $event->setMessage('pay to AT61 1904 3002 3457 3201 please'); + + self::assertStringNotContainsString('1904 3002', $scrubber->scrub($event, null)->getMessage()); + } + + public function testTransactionNameAndSpanContentAreScrubbed(): void + { + $scrubber = $this->scrubberWith(['email']); + $span = new \Sentry\Tracing\Span(); + $span->setDescription('GET /person/visitor@example.com'); + $span->setData(['request_target' => 'lookup visitor@example.com']); + + $event = Event::createEvent(); + $event->setTransaction('GET /unsubscribe/visitor@example.com'); + $event->setSpans([$span]); + + $event = $scrubber->scrub($event, null); + + self::assertSame('GET /unsubscribe/[Filtered]', $event->getTransaction()); + self::assertSame('GET /person/[Filtered]', $span->getDescription()); + self::assertSame('lookup [Filtered]', $span->getData()['request_target']); + } +} diff --git a/composer.json b/composer.json index 62efd54..5b87fba 100644 --- a/composer.json +++ b/composer.json @@ -17,11 +17,23 @@ "ext-json": "*", "php": "^8.1", "neos/flow": "^8.0 || ^9.0 || @dev", + "neos/utility-arrays": "^8.0 || ^9.0 || @dev", "sentry/sentry": "^4.0" }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, "autoload": { "psr-4": { "Flownative\\Sentry\\": "Classes" } + }, + "autoload-dev": { + "psr-4": { + "Flownative\\Sentry\\Tests\\": "Tests" + } + }, + "scripts": { + "test": "phpunit" } } diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..d7aa7a0 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,13 @@ + + + + + Tests/Unit + + +