Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,17 @@
/.stylelintignore export-ignore
/codeception.yml export-ignore
/composer-require-checker.json export-ignore
/docs export-ignore
/ecs.php export-ignore
/infection.json* export-ignore
/package-lock.json export-ignore
/package.json export-ignore
/phpstan*.neon* export-ignore
/phpunit.xml.dist export-ignore
/rector.php export-ignore
/resources/tests export-ignore
/runtime export-ignore
/scaffold-lock.json export-ignore
/stryker.config.mjs export-ignore
/tests export-ignore
/vite.config.js export-ignore

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- feat(ui): share User guest and RBAC section rendering and support selecting filtered tabs.
- feat(ui): add sensitive queue-payload redaction and recognize Yii3 queue producers for Dump, Mail, and Queue parity.
- fix(ui): add keyboard-resizable drawers with Escape handling and focus restoration.
- fix: harden packaging, privacy, collector lifecycle, snapshot recovery, dump rendering, and toolbar messaging.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ panel views remain in each adapter. Yii adapters resolve the packaged frontend a
The visual and behavioral synchronization contract for the Yii adapters is documented in the
[Yii Debug UI parity baseline](docs/ui-parity-baseline.md).

Persistent adapters apply `PHPForge\Debug\Capture\CapturePolicy` before snapshot capture. Its secure defaults redact
common credentials, authorization and cookie values recursively, suppress raw bodies whose decoded form changed,
truncate opaque bodies at 64 KiB, and sanitize query strings and diagnostic assignments. Tagged-value capture and
hydration also enforce depth and node budgets, while newly captured exception traces intentionally omit arguments.

`SnapshotStore::loadManifest()` and `readSnapshot()` retain their fail-closed `[]` / `null` behavior. Integrations that
need to report filesystem, lock, recovery, corruption, or envelope-integrity failures can use the additive
`loadManifestResult()` and `readSnapshotResult()` methods and inspect the result's nullable `error` property.

Current adapters:

- `yii2-extensions/debug`
Expand Down
12 changes: 12 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
coverage:
precision: 2
round: down
status:
project:
default:
target: 100%
threshold: 0%
patch:
default:
target: 100%
threshold: 0%
2 changes: 1 addition & 1 deletion resources/assets/dist/js/debug.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion resources/assets/dist/js/focus.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion resources/assets/dist/js/toolbar.min.js

Large diffs are not rendered by default.

10 changes: 6 additions & 4 deletions resources/src/toolbar/element.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
focusToolbarElement,
focusToolbarTrigger,
isToolbarDrawerCloseMessage,
isToolbarDrawerThemeMessage,
shouldCloseToolbarDrawer,
} from "./focus.js";
import {
Expand Down Expand Up @@ -358,10 +359,11 @@ YiiDebugToolbar.prototype.watchTheme = function () {
}

if (
!data ||
typeof data !== "object" ||
data.source !== "yii-debug-toolbar" ||
data.type !== "theme"
!isToolbarDrawerThemeMessage(
event,
window.location.origin,
drawerFrame ? drawerFrame.contentWindow : null,
)
) {
return;
}
Expand Down
14 changes: 14 additions & 0 deletions resources/src/toolbar/focus.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ export function isToolbarDrawerCloseMessage(event, origin, frameWindow) {
);
}

export function isToolbarDrawerThemeMessage(event, origin, frameWindow) {
var data = event && event.data;

return Boolean(
event &&
event.origin === origin &&
frameWindow &&
event.source === frameWindow &&
data &&
data.source === "yii-debug-toolbar" &&
data.type === "theme",
);
}

export function requestParentToolbarDrawerClose(
event,
browserWindow,
Expand Down
51 changes: 51 additions & 0 deletions resources/tests/toolbar-runtime.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
focusToolbarElement,
focusToolbarTrigger,
isToolbarDrawerCloseMessage,
isToolbarDrawerThemeMessage,
requestParentToolbarDrawerClose,
shouldCloseToolbarDrawer,
} from "../src/toolbar/focus.js";
Expand Down Expand Up @@ -330,6 +331,56 @@ test("drawer close messages are bound to the active same-origin iframe", () => {
);
});

test("theme messages are bound to the active same-origin iframe", () => {
var frameWindow = {};
var message = {
data: { source: "yii-debug-toolbar", type: "theme", theme: "dark" },
origin: "https://example.test",
source: frameWindow,
};

assert.equal(
isToolbarDrawerThemeMessage(message, "https://example.test", frameWindow),
true,
);
assert.equal(
isToolbarDrawerThemeMessage(
{ ...message, origin: "https://attacker.test" },
"https://example.test",
frameWindow,
),
false,
);
assert.equal(
isToolbarDrawerThemeMessage(message, "https://example.test", {}),
false,
);
assert.equal(
isToolbarDrawerThemeMessage(
{ ...message, data: { ...message.data, type: "close-drawer" } },
"https://example.test",
frameWindow,
),
false,
);
assert.equal(
isToolbarDrawerThemeMessage(
{ ...message, data: { ...message.data, source: "another-app" } },
"https://example.test",
frameWindow,
),
false,
);
assert.equal(
isToolbarDrawerThemeMessage(
{ ...message, data: null },
"https://example.test",
frameWindow,
),
false,
);
});

test("embedded debug pages request drawer closure after an unhandled Escape", () => {
var messages = [];
var parent = {
Expand Down
145 changes: 145 additions & 0 deletions src/Capture/CapturePolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<?php

declare(strict_types=1);

namespace PHPForge\Debug\Capture;

use InvalidArgumentException;
use PHPForge\Debug\Helper\SensitiveDataRedactor;
use SensitiveParameter;

use function array_map;
use function get_object_vars;
use function http_build_query;
use function implode;
use function is_array;
use function is_object;
use function parse_str;
use function preg_quote;
use function preg_replace_callback;
use function strlen;
use function strpos;
use function substr;

/**
* Applies the default redaction and size limits before debug data reaches persistent storage.
*/
final readonly class CapturePolicy
{
/**
* @param list<string> $sensitiveKeys Exact, case-insensitive keys to redact recursively.
* @param int $maxBodyBytes Maximum raw request or response body bytes to retain; must be positive.
*/
public function __construct(
private array $sensitiveKeys = SensitiveDataRedactor::DEFAULT_KEYS,
private int $maxBodyBytes = 65536,

Check warning on line 35 in src/Capture/CapturePolicy.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "IncrementInteger": @@ @@ */ public function __construct( private array $sensitiveKeys = SensitiveDataRedactor::DEFAULT_KEYS, - private int $maxBodyBytes = 65536, + private int $maxBodyBytes = 65537, ) { if ($this->maxBodyBytes < 1) { throw new InvalidArgumentException(

Check warning on line 35 in src/Capture/CapturePolicy.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "DecrementInteger": @@ @@ */ public function __construct( private array $sensitiveKeys = SensitiveDataRedactor::DEFAULT_KEYS, - private int $maxBodyBytes = 65536, + private int $maxBodyBytes = 65535, ) { if ($this->maxBodyBytes < 1) { throw new InvalidArgumentException(

Check warning on line 35 in src/Capture/CapturePolicy.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "DecrementInteger": @@ @@ */ public function __construct( private array $sensitiveKeys = SensitiveDataRedactor::DEFAULT_KEYS, - private int $maxBodyBytes = 65536, + private int $maxBodyBytes = 65535, ) { if ($this->maxBodyBytes < 1) { throw new InvalidArgumentException(

Check warning on line 35 in src/Capture/CapturePolicy.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "IncrementInteger": @@ @@ */ public function __construct( private array $sensitiveKeys = SensitiveDataRedactor::DEFAULT_KEYS, - private int $maxBodyBytes = 65536, + private int $maxBodyBytes = 65537, ) { if ($this->maxBodyBytes < 1) { throw new InvalidArgumentException(
) {
if ($this->maxBodyBytes < 1) {

Check warning on line 37 in src/Capture/CapturePolicy.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "LessThan": @@ @@ private array $sensitiveKeys = SensitiveDataRedactor::DEFAULT_KEYS, private int $maxBodyBytes = 65536, ) { - if ($this->maxBodyBytes < 1) { + if ($this->maxBodyBytes <= 1) { throw new InvalidArgumentException( 'The maximum body size must be greater than zero.', );

Check warning on line 37 in src/Capture/CapturePolicy.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "LessThan": @@ @@ private array $sensitiveKeys = SensitiveDataRedactor::DEFAULT_KEYS, private int $maxBodyBytes = 65536, ) { - if ($this->maxBodyBytes < 1) { + if ($this->maxBodyBytes <= 1) { throw new InvalidArgumentException( 'The maximum body size must be greater than zero.', );
throw new InvalidArgumentException(
'The maximum body size must be greater than zero.',
);
}
}

/**
* Returns whether a key is denied by this policy.
*/
public function isSensitiveKey(string $key): bool
{
return SensitiveDataRedactor::isSensitiveKey($key, $this->sensitiveKeys);
}

/**
* Redacts sensitive keys throughout a bounded value tree.
*
* @template TKey of array-key
*
* @param array<TKey, mixed> $value Value tree to sanitize.
*
* @return array<TKey, mixed> Sanitized value tree.
*/
public function redact(#[SensitiveParameter] array $value): array
{
return SensitiveDataRedactor::redact($value, $this->sensitiveKeys);
}

/**
* Redacts a decoded body and suppresses its raw representation whenever redaction was required.
*
* @return array{decoded: mixed, raw: string}
*/
public function redactBody(#[SensitiveParameter] string $raw, #[SensitiveParameter] mixed $decoded): array
{
$sanitized = match (true) {
is_array($decoded) => $this->redact($decoded),
is_object($decoded) => $this->redact(get_object_vars($decoded)),
default => $decoded,
};

return [
'decoded' => $sanitized,
'raw' => $sanitized !== $decoded
? SensitiveDataRedactor::PLACEHOLDER
: $this->truncateBody($raw),
];
}

/**
* Redacts common `key=value` and `key: value` secret fragments in diagnostic text.
*/
public function redactText(#[SensitiveParameter] string $text): string
{
if ($this->sensitiveKeys === []) {
return $text;

Check warning on line 93 in src/Capture/CapturePolicy.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "ReturnRemoval": @@ @@ public function redactText(#[SensitiveParameter] string $text): string { if ($this->sensitiveKeys === []) { - return $text; + } $keys = array_map(

Check warning on line 93 in src/Capture/CapturePolicy.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "ReturnRemoval": @@ @@ public function redactText(#[SensitiveParameter] string $text): string { if ($this->sensitiveKeys === []) { - return $text; + } $keys = array_map(
}

$keys = array_map(

Check warning on line 96 in src/Capture/CapturePolicy.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "UnwrapArrayMap": @@ @@ return $text; } - $keys = array_map( - static fn(string $key): string => preg_quote($key, '~'), - $this->sensitiveKeys, - ); + $keys = $this->sensitiveKeys; $pattern = '~(?<![[:alnum:]_])(["\']?(?:' . implode('|', $keys) . ')["\']?)(\s*[:=]\s*)[^,;&\r\n]+~i'; return preg_replace_callback(

Check warning on line 96 in src/Capture/CapturePolicy.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "UnwrapArrayMap": @@ @@ return $text; } - $keys = array_map( - static fn(string $key): string => preg_quote($key, '~'), - $this->sensitiveKeys, - ); + $keys = $this->sensitiveKeys; $pattern = '~(?<![[:alnum:]_])(["\']?(?:' . implode('|', $keys) . ')["\']?)(\s*[:=]\s*)[^,;&\r\n]+~i'; return preg_replace_callback(
static fn(string $key): string => preg_quote($key, '~'),

Check warning on line 97 in src/Capture/CapturePolicy.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "PregQuote": @@ @@ } $keys = array_map( - static fn(string $key): string => preg_quote($key, '~'), + static fn(string $key): string => $key, $this->sensitiveKeys, ); $pattern = '~(?<![[:alnum:]_])(["\']?(?:' . implode('|', $keys) . ')["\']?)(\s*[:=]\s*)[^,;&\r\n]+~i';

Check warning on line 97 in src/Capture/CapturePolicy.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "PregQuote": @@ @@ } $keys = array_map( - static fn(string $key): string => preg_quote($key, '~'), + static fn(string $key): string => $key, $this->sensitiveKeys, ); $pattern = '~(?<![[:alnum:]_])(["\']?(?:' . implode('|', $keys) . ')["\']?)(\s*[:=]\s*)[^,;&\r\n]+~i';
$this->sensitiveKeys,
);
$pattern = '~(?<![[:alnum:]_])(["\']?(?:' . implode('|', $keys) . ')["\']?)(\s*[:=]\s*)[^,;&\r\n]+~i';

return preg_replace_callback(
$pattern,
static fn(array $match): string => ($match[1] ?? '')
. ($match[2] ?? '')
. SensitiveDataRedactor::PLACEHOLDER,
$text,
) ?? $text;
}

/**
* Redacts sensitive values in a URL query string without changing the URL outside its query component.
*/
public function redactUrl(#[SensitiveParameter] string $url): string
{
$fragmentPosition = strpos($url, '#');
$fragment = $fragmentPosition === false ? '' : substr($url, $fragmentPosition);
$withoutFragment = $fragmentPosition === false ? $url : substr($url, 0, $fragmentPosition);

Check warning on line 118 in src/Capture/CapturePolicy.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "UnwrapSubstr": @@ @@ { $fragmentPosition = strpos($url, '#'); $fragment = $fragmentPosition === false ? '' : substr($url, $fragmentPosition); - $withoutFragment = $fragmentPosition === false ? $url : substr($url, 0, $fragmentPosition); + $withoutFragment = $fragmentPosition === false ? $url : $url; $queryPosition = strpos($withoutFragment, '?'); if ($queryPosition === false) {

Check warning on line 118 in src/Capture/CapturePolicy.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "UnwrapSubstr": @@ @@ { $fragmentPosition = strpos($url, '#'); $fragment = $fragmentPosition === false ? '' : substr($url, $fragmentPosition); - $withoutFragment = $fragmentPosition === false ? $url : substr($url, 0, $fragmentPosition); + $withoutFragment = $fragmentPosition === false ? $url : $url; $queryPosition = strpos($withoutFragment, '?'); if ($queryPosition === false) {
$queryPosition = strpos($withoutFragment, '?');

if ($queryPosition === false) {
return $url;
}

$query = [];

parse_str(substr($withoutFragment, $queryPosition + 1), $query);

return substr($withoutFragment, 0, $queryPosition + 1)
. http_build_query($this->redact($query))
. $fragment;
}

/**
* Truncates an opaque body at the configured byte boundary.
*/
private function truncateBody(#[SensitiveParameter] string $body): string
{
$body = $this->redactText($body);

return strlen($body) > $this->maxBodyBytes

Check warning on line 141 in src/Capture/CapturePolicy.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "GreaterThan": @@ @@ { $body = $this->redactText($body); - return strlen($body) > $this->maxBodyBytes + return strlen($body) >= $this->maxBodyBytes ? substr($body, 0, $this->maxBodyBytes) . SensitiveDataRedactor::TRUNCATED : $body; }

Check warning on line 141 in src/Capture/CapturePolicy.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "GreaterThan": @@ @@ { $body = $this->redactText($body); - return strlen($body) > $this->maxBodyBytes + return strlen($body) >= $this->maxBodyBytes ? substr($body, 0, $this->maxBodyBytes) . SensitiveDataRedactor::TRUNCATED : $body; }
? substr($body, 0, $this->maxBodyBytes) . SensitiveDataRedactor::TRUNCATED
: $body;
}
}
Loading
Loading