diff --git a/.docs/README.md b/.docs/README.md index 26ef805..cb1aa28 100644 --- a/.docs/README.md +++ b/.docs/README.md @@ -7,6 +7,7 @@ Tuned Tracy Bars/Panels/BlueScreens for easy-developing. - [Setup](#setup) - [TracyBlueScreens - better BlueScreen panels](#tracybluescreen) - [Logger - register additional Tracy loggers](#logger) +- [Sanitized Markdown - safe AI-friendly exception export](#sanitized-markdown) ## Setup @@ -37,5 +38,20 @@ extensions: Extra loggers can then be wired in your app and added to the multi logger. +## Sanitized Markdown + +`SanitizedMarkdownExtension` registers a BlueScreen panel that renders a sanitized Markdown document for the current exception. Values are replaced by type descriptors like `string(40)` or `array(3)`, so the output can be shared with an AI agent without leaking request or payload data. + +```neon +extensions: + tracy.sanitizedMarkdown: Contributte\Tracy\DI\SanitizedMarkdownExtension +``` + +You can also inject `Contributte\Tracy\SanitizedMarkdownRenderer` and render the Markdown string directly: + +```php +$markdown = $sanitizedMarkdownRenderer->render($throwable); +``` + [container-builder-parameters]: https://raw.githubusercontent.com/contributte/tracy/master/.docs/assets/container-builder-parameters.png "Container Builder - parameters" [container-builder-definitions]: https://raw.githubusercontent.com/contributte/tracy/master/.docs/assets/container-builder-definitions.png "Container Builder - definitions" diff --git a/src/BlueScreen/SanitizedMarkdownBlueScreen.php b/src/BlueScreen/SanitizedMarkdownBlueScreen.php new file mode 100644 index 0000000..07a774a --- /dev/null +++ b/src/BlueScreen/SanitizedMarkdownBlueScreen.php @@ -0,0 +1,32 @@ + 'Sanitized Markdown', + 'panel' => sprintf('
%s
', Helpers::escapeHtml($this->renderer->render($e))), + 'collapsed' => true, + ]; + } + +} diff --git a/src/DI/SanitizedMarkdownExtension.php b/src/DI/SanitizedMarkdownExtension.php new file mode 100644 index 0000000..58bc33e --- /dev/null +++ b/src/DI/SanitizedMarkdownExtension.php @@ -0,0 +1,35 @@ +getContainerBuilder(); + + $builder->addDefinition($this->prefix('dumper')) + ->setFactory(SanitizedDumper::class); + + $builder->addDefinition($this->prefix('renderer')) + ->setFactory(SanitizedMarkdownRenderer::class); + + $builder->addDefinition($this->prefix('blueScreen')) + ->setFactory(SanitizedMarkdownBlueScreen::class); + } + + public function afterCompile(ClassType $class): void + { + $class->getMethod('initialize') + ->addBody('?::getBlueScreen()->addPanel($this->getService(?));', [Debugger::class, $this->prefix('blueScreen')]); + } + +} diff --git a/src/SanitizedDumper.php b/src/SanitizedDumper.php new file mode 100644 index 0000000..5101440 --- /dev/null +++ b/src/SanitizedDumper.php @@ -0,0 +1,114 @@ +getValue(); + } + + return match (true) { + $value === null => 'null', + is_bool($value) => 'bool', + is_int($value) => sprintf('int(%d)', strlen(ltrim((string) $value, '-'))), + is_float($value) => sprintf('float(%d)', strlen((string) $value)), + is_string($value) => sprintf('string(%d)', strlen($value)), + is_array($value) => sprintf('array(%d)', count($value)), + $value instanceof \Closure => 'Closure', + is_object($value) => sprintf('object(%s)', $value::class), + is_resource($value) => sprintf('resource(%s)', get_resource_type($value)), + default => get_debug_type($value), + }; + } + + /** + * @param array $seenArrayReferences + * @param list> $seenArrays + * @return string|array + */ + public function sanitize(mixed $value, ?string $key = null, ?string $class = null, int $depth = 0, array $seenArrayReferences = [], array $seenArrays = []): string|array + { + if ($this->isSensitive($key, $value, $class)) { + return $this->summarize($value, $key, $class); + } + + if (!is_array($value)) { + return $this->summarize($value, $key, $class); + } + + if ($depth >= Debugger::getBlueScreen()->maxDepth) { + return $this->summarize($value, $key, $class); + } + + $sanitized = []; + $maxItems = Debugger::getBlueScreen()->maxItems; + $seenArrays[] = $value; + + foreach ($value as $itemKey => $itemValue) { + if ($maxItems > 0 && count($sanitized) >= $maxItems) { + $sanitized['...'] = sprintf('omitted(%d)', count($value) - $maxItems); + break; + } + + if (is_array($itemValue)) { + foreach ($seenArrays as $seenArray) { + if ($itemValue === $seenArray) { + $sanitized[$itemKey] = '*RECURSION* array'; + continue 2; + } + } + } + + $referenceId = is_array($itemValue) + ? ReflectionReference::fromArrayElement($value, $itemKey)?->getId() + : null; + + if ($referenceId !== null && isset($seenArrayReferences[$referenceId])) { + $sanitized[$itemKey] = '*RECURSION* array'; + continue; + } + + $nextSeenArrayReferences = $seenArrayReferences; + if ($referenceId !== null) { + $nextSeenArrayReferences[$referenceId] = true; + } + + $sanitized[$itemKey] = $this->sanitize( + $itemValue, + is_int($itemKey) ? (string) $itemKey : $itemKey, + null, + $depth + 1, + $nextSeenArrayReferences, + $seenArrays, + ); + } + + return $sanitized; + } + + private function isSensitive(?string $key, mixed $value, ?string $class = null): bool + { + if ($value instanceof \SensitiveParameterValue) { + return true; + } + + if ($key === null) { + return false; + } + + $blueScreen = Debugger::getBlueScreen(); + $keysToHide = array_flip(array_map(strtolower(...), array_merge(Debugger::$keysToHide, $blueScreen->keysToHide))); + + return ($blueScreen->scrubber !== null && ($blueScreen->scrubber)($key, $value, $class)) + || isset($keysToHide[strtolower($key)]) + || ($class !== null && isset($keysToHide[strtolower($class . '::$' . $key)])); + } + +} diff --git a/src/SanitizedMarkdownRenderer.php b/src/SanitizedMarkdownRenderer.php new file mode 100644 index 0000000..00a7dbb --- /dev/null +++ b/src/SanitizedMarkdownRenderer.php @@ -0,0 +1,319 @@ +dumper->summarize($throwable->getMessage())), + sprintf('File: %s:%d', $this->formatFile($throwable->getFile()), $throwable->getLine()), + ]; + + $previous = array_slice(Helpers::getExceptionChain($throwable), 1); + if ($previous !== []) { + $lines[] = ''; + $lines[] = '## Previous Exceptions'; + + foreach ($previous as $index => $item) { + $lines[] = sprintf('%d. %s', $index + 1, get_debug_type($item)); + $lines[] = sprintf(' Message: %s', $this->dumper->summarize($item->getMessage())); + $lines[] = sprintf(' File: %s:%d', $this->formatFile($item->getFile()), $item->getLine()); + } + } + + $lines[] = ''; + $lines[] = '## Stack Trace'; + + foreach ($this->createFrames($throwable) as $index => $frame) { + $lines[] = sprintf('#%d %s', $index, $this->formatLocation($frame['file'], $frame['line'])); + $lines[] = sprintf(' %s(%s)', $frame['call'], $this->formatArguments($frame['args'])); + } + + $lines[] = ''; + $lines[] = '## Request Context'; + + foreach ($this->renderRequestContext() as $line) { + $lines[] = $line; + } + + return implode("\n", $lines); + } + + /** + * @return list}> + */ + private function createFrames(Throwable $throwable): array + { + $trace = $throwable->getTrace(); + + if ($trace === []) { + return [[ + 'file' => $throwable->getFile(), + 'line' => $throwable->getLine(), + 'call' => '{main}', + 'args' => [], + ]]; + } + + $frames = []; + + foreach ($trace as $index => $row) { + $args = isset($row['args']) + ? array_values($row['args']) + : []; + + $frames[] = [ + 'file' => $index === 0 ? $throwable->getFile() : ($trace[$index - 1]['file'] ?? null), + 'line' => $index === 0 ? $throwable->getLine() : ($trace[$index - 1]['line'] ?? null), + 'call' => $this->formatCall($row), + 'args' => $args, + ]; + } + + return $frames; + } + + /** + * @param array $frame + */ + private function formatCall(array $frame): string + { + $function = $frame['function'] ?? null; + if (is_string($function) && str_starts_with($function, '{closure')) { + return 'Closure'; + } + + $class = $frame['class'] ?? null; + if (is_string($class) && is_string($function)) { + return $class . (is_string($frame['type'] ?? null) ? $frame['type'] : '::') . $function; + } + + return is_string($function) ? $function : '{main}'; + } + + /** + * @param list $arguments + */ + private function formatArguments(array $arguments): string + { + if ($arguments === []) { + return ''; + } + + return implode(', ', array_map(fn (mixed $argument): string => $this->dumper->summarize($argument), $arguments)); + } + + /** + * @return list + */ + private function renderRequestContext(): array + { + $lines = []; + + if ($this->isHttpRequest()) { + $url = $this->createHttpUrl(); + if ($url !== null) { + $lines[] = sprintf('URL: %s', $this->dumper->summarize($url)); + } + + if (isset($_SERVER['REQUEST_METHOD'])) { + $lines[] = sprintf('Method: %s', $this->dumper->summarize($_SERVER['REQUEST_METHOD'])); + } + + $this->appendSection($lines, 'Headers', $this->dumper->sanitize($this->extractHeaders())); + $this->appendSection($lines, 'Query', $this->dumper->sanitize($_GET)); + $this->appendSection($lines, 'POST', $this->extractRequestBody()); + + return $lines; + } + + $command = $this->createCliCommand(); + if ($command !== null) { + $lines[] = sprintf('Command: %s', $this->dumper->summarize($command)); + } + + $this->appendSection($lines, 'Arguments', $this->dumper->sanitize($_SERVER['argv'] ?? [])); + + return $lines; + } + + /** + * @param list $lines + * @param string|array $value + */ + private function appendSection(array &$lines, string $label, string|array $value): void + { + if (is_string($value)) { + $lines[] = sprintf('%s: %s', $label, $value); + return; + } + + if ($value === []) { + $lines[] = sprintf('%s: array(0)', $label); + return; + } + + $lines[] = sprintf('%s:', $label); + + foreach ($this->renderMapping($value) as $line) { + $lines[] = $line; + } + } + + /** + * @param array $values + * @return list + */ + private function renderMapping(array $values, int $depth = 1): array + { + $lines = []; + $indent = str_repeat(' ', $depth); + + foreach ($values as $key => $value) { + if (is_array($value)) { + if ($value === []) { + $lines[] = sprintf('%s%s: array(0)', $indent, (string) $key); + continue; + } + + $lines[] = sprintf('%s%s:', $indent, (string) $key); + array_push($lines, ...$this->renderMapping($value, $depth + 1)); + continue; + } + + if (!is_string($value)) { + $value = $this->dumper->summarize($value); + } + + $lines[] = sprintf('%s%s: %s', $indent, (string) $key, $value); + } + + return $lines; + } + + /** @return array */ + private function extractHeaders(): array + { + if (function_exists('apache_request_headers')) { + $headers = apache_request_headers(); + ksort($headers); + return $headers; + } + + $headers = []; + + foreach ($_SERVER as $key => $value) { + if (!is_string($value)) { + continue; + } + + if (str_starts_with($key, 'HTTP_')) { + $headers[$this->normalizeHeaderName(substr($key, 5))] = $value; + continue; + } + + if (in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5', 'AUTH_TYPE'], true)) { + $headers[$this->normalizeHeaderName($key)] = $value; + } + } + + ksort($headers); + + return $headers; + } + + /** @return string|array */ + private function extractRequestBody(): string|array + { + if ($_POST !== []) { + return $this->dumper->sanitize($_POST); + } + + if (!in_array($_SERVER['REQUEST_METHOD'] ?? null, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { + return []; + } + + $rawBody = file_get_contents('php://input'); + if (!is_string($rawBody) || $rawBody === '') { + return []; + } + + $decoded = json_decode($rawBody, true); + if (json_last_error() === JSON_ERROR_NONE) { + return is_array($decoded) + ? $this->dumper->sanitize($decoded) + : $this->dumper->summarize($decoded); + } + + return $this->dumper->summarize($rawBody); + } + + private function isHttpRequest(): bool + { + return isset($_SERVER['REQUEST_METHOD']) || isset($_SERVER['REQUEST_URI']) || isset($_SERVER['HTTP_HOST']); + } + + private function createHttpUrl(): ?string + { + if (!isset($_SERVER['REQUEST_URI']) && !isset($_SERVER['HTTP_HOST']) && !isset($_SERVER['SERVER_NAME'])) { + return null; + } + + $scheme = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== '' && strcasecmp((string) $_SERVER['HTTPS'], 'off') !== 0 + ? 'https://' + : 'http://'; + $host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? ''; + + return $scheme . $host . ($_SERVER['REQUEST_URI'] ?? ''); + } + + private function createCliCommand(): ?string + { + if (!isset($_SERVER['argv']) || !is_array($_SERVER['argv'])) { + return null; + } + + return implode(' ', array_map(static fn (mixed $value): string => is_scalar($value) ? (string) $value : get_debug_type($value), $_SERVER['argv'])); + } + + private function normalizeHeaderName(string $key): string + { + return str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', $key)))); + } + + private function formatLocation(?string $file, ?int $line): string + { + if ($file === null) { + return 'inner-code'; + } + + return sprintf('%s:%s', $this->formatFile($file), $line ?? '?'); + } + + private function formatFile(string $file): string + { + $normalizedFile = str_replace('\\', '/', $file); + $cwd = getcwd(); + $normalizedCwd = $cwd !== false + ? str_replace('\\', '/', $cwd) + : ''; + + if ($normalizedCwd !== '' && str_starts_with($normalizedFile, $normalizedCwd . '/')) { + return substr($normalizedFile, strlen($normalizedCwd) + 1); + } + + return $normalizedFile; + } + +} diff --git a/tests/Cases/DI/SanitizedMarkdownExtension.phpt b/tests/Cases/DI/SanitizedMarkdownExtension.phpt new file mode 100644 index 0000000..5d37cd3 --- /dev/null +++ b/tests/Cases/DI/SanitizedMarkdownExtension.phpt @@ -0,0 +1,38 @@ +getProperty('panels'); + + Assert::count(0, $panelsrf->getValue(Debugger::getBlueScreen())); + + $container = ContainerBuilder::of() + ->withCompiler(static function (Compiler $compiler): void { + $compiler->addExtension('sanitizedMarkdown', new SanitizedMarkdownExtension()); + }) + ->build(); + + Assert::count(0, $panelsrf->getValue(Debugger::getBlueScreen())); + + $container->initialize(); + + Assert::count(1, $panelsrf->getValue(Debugger::getBlueScreen())); + Assert::type(SanitizedMarkdownRenderer::class, $container->getByType(SanitizedMarkdownRenderer::class)); + + $panel = $panelsrf->getValue(Debugger::getBlueScreen())[0]; + $output = $panel(new RuntimeException('super-secret-message')); + + Assert::same('Sanitized Markdown', $output['tab']); + Assert::contains('Message: string(20)', $output['panel']); + Assert::notContains('super-secret-message', $output['panel']); +}); diff --git a/tests/Cases/Sanitized/SanitizedDumper.phpt b/tests/Cases/Sanitized/SanitizedDumper.phpt new file mode 100644 index 0000000..f2b023d --- /dev/null +++ b/tests/Cases/Sanitized/SanitizedDumper.phpt @@ -0,0 +1,44 @@ +keysToHide[] = 'token'; + + $payload = [ + 'secret_tree' => ['apiKey' => 'super-secret-key'], + 'nested' => [ + 'token' => ['value' => 'abc-123'], + 'profile' => [ + 'email' => 'customer@example.com', + 'active' => true, + 'flags' => [1, 2, 3], + ], + ], + 'nothing' => null, + 'price' => 1250, + 'callback' => static fn (): string => 'secret', + 'request' => new stdClass(), + ]; + $payload['self'] = []; + $payload['self']['parent'] = &$payload; + + $sanitized = $dumper->sanitize($payload); + + Assert::same('array(1)', $sanitized['secret_tree']); + Assert::same('array(1)', $sanitized['nested']['token']); + Assert::same('string(20)', $sanitized['nested']['profile']['email']); + Assert::same('bool', $sanitized['nested']['profile']['active']); + Assert::same('int(4)', $sanitized['price']); + Assert::same('null', $sanitized['nothing']); + Assert::same('Closure', $sanitized['callback']); + Assert::same('object(stdClass)', $sanitized['request']); + Assert::same('*RECURSION* array', $sanitized['self']['parent']); +}); diff --git a/tests/Cases/Sanitized/SanitizedMarkdownRenderer.phpt b/tests/Cases/Sanitized/SanitizedMarkdownRenderer.phpt new file mode 100644 index 0000000..6fa814a --- /dev/null +++ b/tests/Cases/Sanitized/SanitizedMarkdownRenderer.phpt @@ -0,0 +1,85 @@ + 'card', + ]; + $_POST = [ + 'order_id' => 123, + 'payload' => [ + 'customer' => [ + 'email' => 'customer@example.com', + ], + 'items' => [ + 1, + 2, + ], + ], + ]; + + ini_set('zend.exception_ignore_args', '0'); + + $renderer = new SanitizedMarkdownRenderer(new SanitizedDumper()); + + try { + checkout($_POST); + Assert::fail('Exception was not thrown.'); + } catch (RuntimeException $e) { + $markdown = $renderer->render($e); + + Assert::contains('## Error: RuntimeException', $markdown); + Assert::contains('Message: string(40)', $markdown); + Assert::contains('Method: string(4)', $markdown); + Assert::contains('URL: string(47)', $markdown); + Assert::contains('Authorization: string(23)', $markdown); + Assert::contains('Content-Type: string(16)', $markdown); + Assert::contains('Host: string(17)', $markdown); + Assert::contains('gateway: string(4)', $markdown); + Assert::contains('order_id: int(3)', $markdown); + Assert::contains('email: string(20)', $markdown); + Assert::contains('processPayment(int(3), string(22), array(2))', $markdown); + Assert::contains('checkout(array(2))', $markdown); + Assert::contains('Closure()', $markdown); + Assert::notContains('4111111111111111', $markdown); + Assert::notContains('tok_live_secret_123456', $markdown); + Assert::notContains('Bearer top-secret-token', $markdown); + Assert::notContains('customer@example.com', $markdown); + Assert::notContains('/checkout?gateway=card', $markdown); + } + + $_SERVER = $originalServer; + $_GET = $originalGet; + $_POST = $originalPost; + + if ($originalIgnoreArgs !== false) { + ini_set('zend.exception_ignore_args', (string) $originalIgnoreArgs); + } +});