diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a6513f7..9f2e43f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -70,6 +70,12 @@ jobs: - name: Install dependencies run: | + # Laravel 11 is past its security-support window, so Composer refuses to load any + # 11.x release. This leg checks that the package still works on Laravel 11, not that + # Laravel 11 is patched, so let the resolver see it. + if [[ "${{ matrix.laravel }}" == "11.*" ]]; then + composer config --no-plugins policy.advisories.block false + fi composer require "filament/filament:${{ matrix.filament }}" "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update if [[ "${{ matrix.laravel }}" != "13.*" ]]; then composer require "pestphp/pest-plugin-laravel:^3.0" --no-interaction --no-update diff --git a/README.md b/README.md index 4b8f0ed..582d93c 100644 --- a/README.md +++ b/README.md @@ -23,18 +23,36 @@ $panel ]) ``` +## Secrets + +`phpinfo()` prints the whole process environment three times: once under `Environment` as +`APP_KEY`, and twice under `PHP Variables` as `$_ENV['APP_KEY']` and `$_SERVER['APP_KEY']`. On a +Laravel app that puts the encryption key, the database password and every third-party credential +on one page. + +This package replaces those values with `[redacted]` by default. It keeps the row, so the page +still tells you which variables are set. Matching applies only to the two environment modules, so +PHP settings such as `Max keys` and `Tokenizer Support` keep their values. + +The page is still worth gating to your most trusted users. Redaction removes the credentials, not +the rest of the host's configuration. + ## Configuration -The navigation group and icon are configurable. +The navigation group, icon, and secret redaction are configurable. Publish the `filament-phpinfo` config file with: ```bash php artisan vendor:publish --tag=filament-phpinfo-config ``` -| Option | Description | -|--------------------|----------------------------------------------------------------------------------------------------------------------| -| `navigation-group` | The PHPInfo page's [navigation group](https://filamentphp.com/docs/3.x/panels/navigation#grouping-navigation-items). | -| `navigation-icon` | The PHPInfo page's icon. See Filament's [documentation](https://filamentphp.com/docs/3.x/support/icons) for values. | +| Option | Description | +|-----------------------|----------------------------------------------------------------------------------------------------------------------| +| `navigation-group` | The PHPInfo page's [navigation group](https://filamentphp.com/docs/3.x/panels/navigation#grouping-navigation-items). | +| `navigation-icon` | The PHPInfo page's icon. See Filament's [documentation](https://filamentphp.com/docs/3.x/support/icons) for values. | +| `page-slug` | The PHPInfo page's URL slug. | +| `redact-environment` | Whether to replace the values of secret-bearing environment variables. Defaults to `true`. | +| `redact-patterns` | The strings that mark an environment variable name as secret-bearing. Matching ignores case. | +| `redact-placeholder` | What a redacted value shows instead. Defaults to `[redacted]`. | | Screenshot | |---| diff --git a/config/filament-phpinfo.php b/config/filament-phpinfo.php index 2ffb9bb..1bbc041 100644 --- a/config/filament-phpinfo.php +++ b/config/filament-phpinfo.php @@ -1,7 +1,31 @@ 'System Management', 'navigation-icon' => 'heroicon-o-information-circle', 'page-slug' => 'phpinfo', + + /* + * phpinfo() prints the whole process environment three times, under Environment as + * APP_KEY and under PHP Variables as $_ENV['APP_KEY'] and $_SERVER['APP_KEY']. On a + * Laravel app that puts the encryption key, the database password and every third-party + * credential on one page. Set this to false to print the values. + */ + 'redact-environment' => true, + + /* + * An environment variable whose name contains any of these strings has its value replaced. + * Matching ignores case, and applies only to the two environment modules above, so PHP + * settings such as "Max keys" and "Tokenizer Support" keep their values. Replace this with + * your own array to change the set. + */ + 'redact-patterns' => Redaction::DEFAULT_PATTERNS, + + /* + * What a redacted value shows instead. The row itself stays, so the page still tells you + * which variables are set. + */ + 'redact-placeholder' => Redaction::DEFAULT_PLACEHOLDER, ]; diff --git a/resources/views/phpinfo.blade.php b/resources/views/phpinfo.blade.php index f84b751..1d09d5c 100644 --- a/resources/views/phpinfo.blade.php +++ b/resources/views/phpinfo.blade.php @@ -1,3 +1,5 @@ +@use(STS\FilamentPHPInfo\Redaction) + @foreach ($info->modules() as $module) @@ -22,13 +24,13 @@
- {{ $config->localValue() }} + {{ Redaction::value($module->name(), $config->name(), $config->localValue()) }}
- {{ $config->masterValue() }} + {{ Redaction::value($module->name(), $config->name(), $config->masterValue()) }}
@@ -42,7 +44,7 @@
- {{ $config->localValue() }} + {{ Redaction::value($module->name(), $config->name(), $config->localValue()) }}
diff --git a/src/Redaction.php b/src/Redaction.php new file mode 100644 index 0000000..c52320a --- /dev/null +++ b/src/Redaction.php @@ -0,0 +1,96 @@ + + */ + public static function patterns(): array + { + return config('filament-phpinfo.redact-patterns') ?? static::DEFAULT_PATTERNS; + } + + /** + * phpinfo() prints the same variable three times, as APP_KEY under Environment and as + * $_ENV['APP_KEY'] and $_SERVER['APP_KEY'] under PHP Variables. Reduce all three forms to + * the bare variable name before matching. + */ + protected static function variableName(string $name): string + { + $name = trim($name); + + if (preg_match('/^\$_[A-Z]+\[[\'"]?(.*?)[\'"]?\]$/', $name, $matches)) { + $name = $matches[1]; + } + + return strtoupper($name); + } +} diff --git a/tests/RedactionTest.php b/tests/RedactionTest.php new file mode 100644 index 0000000..a68ec1a --- /dev/null +++ b/tests/RedactionTest.php @@ -0,0 +1,106 @@ +toBe('[redacted]'); +}); + +it('redacts every row form phpinfo prints for one variable', function ($module, $name) { + expect(Redaction::isSensitive($module, $name))->toBeTrue(); +})->with([ + ['Environment', 'APP_KEY'], + ['PHP Variables', "\$_ENV['APP_KEY']"], + ['PHP Variables', '$_SERVER["APP_KEY"]'], +]); + +it('redacts on every pattern', function ($name) { + expect(Redaction::isSensitive('Environment', $name))->toBeTrue(); +})->with([ + 'AWS_SECRET_ACCESS_KEY', + 'DB_PASSWORD', + 'NIGHTWATCH_TOKEN', + 'GOOGLE_APPLICATION_CREDENTIALS', + 'OAUTH_PRIVATE_KEY', + 'JWT_SIGNING_SALT', + 'JWT_RECIPIENT_SIGNATURE', + 'SENTRY_DSN', + 'NOVA_LICENSE_KEY', + 'SLACK_WEBHOOK', +]); + +it('keeps ordinary environment variables', function ($name) { + expect(Redaction::value('Environment', $name, 'kept'))->toBe('kept'); +})->with(['APP_ENV', 'DB_HOST', 'MAIL_FROM_ADDRESS', 'AWS_DEFAULT_REGION', 'PATH']); + +it('keeps php settings whose name matches a pattern', function ($module, $name) { + expect(Redaction::value($module, $name, 'kept'))->toBe('kept'); +})->with([ + ['Zend OPcache', 'Max keys'], + ['Zend OPcache', 'Cached keys'], + ['Zend OPcache', 'Hash keys restarts'], + ['tokenizer', 'Tokenizer Support'], + ['Core', 'highlight.keyword'], +]); + +it('leaves an unset variable alone, so the page still shows it is empty', function ($value) { + expect(Redaction::value('Environment', 'APP_KEY', $value))->toBe($value); +})->with([null, '']); + +it('can be turned off', function () { + config()->set('filament-phpinfo.redact-environment', false); + + expect(Redaction::value('Environment', 'APP_KEY', 'base64:abc123'))->toBe('base64:abc123'); +}); + +it('respects custom patterns from config', function () { + config()->set('filament-phpinfo.redact-patterns', ['tenant']); + + expect(Redaction::value('Environment', 'TENANT_ID', 'acme'))->toBe('[redacted]'); + expect(Redaction::value('Environment', 'APP_KEY', 'base64:abc123'))->toBe('base64:abc123'); +}); + +it('respects a custom placeholder from config', function () { + config()->set('filament-phpinfo.redact-placeholder', '***'); + + expect(Redaction::value('Environment', 'APP_KEY', 'base64:abc123'))->toBe('***'); +}); + +it('redacts nothing outside the environment modules of a real capture', function () { + $redacted = []; + + foreach (Info::capture()->modules() as $module) { + if (in_array($module->name(), Redaction::ENVIRONMENT_MODULES, true)) { + continue; + } + + foreach ($module->configs() as $config) { + if (Redaction::isSensitive($module->name(), $config->name())) { + $redacted[] = $module->name() . ' / ' . $config->name(); + } + } + } + + expect($redacted)->toBeEmpty(); +}); + +it('redacts a secret in a real capture', function () { + putenv('FILAMENT_PHPINFO_TEST_SECRET=must-not-render'); + + $names = []; + + foreach (Info::capture()->modules() as $module) { + foreach ($module->configs() as $config) { + if (str_contains($config->name(), 'FILAMENT_PHPINFO_TEST_SECRET')) { + $names[] = $config->name(); + expect(Redaction::value($module->name(), $config->name(), $config->localValue())) + ->toBe('[redacted]'); + } + } + } + + expect($names)->not->toBeEmpty(); + + putenv('FILAMENT_PHPINFO_TEST_SECRET'); +});