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
6 changes: 6 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 23 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|
Expand Down
24 changes: 24 additions & 0 deletions config/filament-phpinfo.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,31 @@
<?php

use STS\FilamentPHPInfo\Redaction;

return [
'navigation-group' => '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,
];
8 changes: 5 additions & 3 deletions resources/views/phpinfo.blade.php
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@use(STS\FilamentPHPInfo\Redaction)

<x-filament-panels::page>
@foreach ($info->modules() as $module)
<x-filament::section :heading="$module->name()" collapsible>
Expand All @@ -22,13 +24,13 @@

<x-filament-tables::cell class="whitespace-normal" style="overflow-wrap: anywhere">
<div class="filament-tables-column-wrapper px-4 py-3">
{{ $config->localValue() }}
{{ Redaction::value($module->name(), $config->name(), $config->localValue()) }}
</div>
</x-filament-tables::cell>

<x-filament-tables::cell class="whitespace-normal" style="overflow-wrap: anywhere">
<div class="filament-tables-column-wrapper px-4 py-3">
{{ $config->masterValue() }}
{{ Redaction::value($module->name(), $config->name(), $config->masterValue()) }}
</div>
</x-filament-tables::cell>
</x-filament-tables::row>
Expand All @@ -42,7 +44,7 @@

<x-filament-tables::cell class="whitespace-normal" style="overflow-wrap: anywhere">
<div class="filament-tables-column-wrapper px-4 py-3">
{{ $config->localValue() }}
{{ Redaction::value($module->name(), $config->name(), $config->localValue()) }}
</div>
</x-filament-tables::cell>
</x-filament-tables::row>
Expand Down
96 changes: 96 additions & 0 deletions src/Redaction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

namespace STS\FilamentPHPInfo;

class Redaction
{
/**
* phpinfo() prints the process environment under these two PHP-generated headings, and
* nowhere else. Every other module holds PHP settings, where names such as "Max keys",
* "Tokenizer Support" and "highlight.keyword" match the patterns below but hold no secret.
*/
public const ENVIRONMENT_MODULES = ['Environment', 'PHP Variables'];

public const DEFAULT_PATTERNS = [
'KEY',
'SECRET',
'PASSWORD',
'TOKEN',
'CREDENTIAL',
'PRIVATE',
'SALT',
'SIGNING',
'SIGNATURE',
'DSN',
'LICENSE',
'WEBHOOK',
];

public const DEFAULT_PLACEHOLDER = '[redacted]';

public static function value(string $module, string $name, ?string $value): ?string
{
// An unset or empty variable leaks nothing. Leave it, so the page keeps showing which
// variables carry a value and which do not.
if ($value === null || $value === '') {
return $value;
}

return static::isSensitive($module, $name) ? static::placeholder() : $value;
}

public static function isSensitive(string $module, string $name): bool
{
if (! static::enabled()) {
return false;
}

if (! in_array($module, static::ENVIRONMENT_MODULES, true)) {
return false;
}

$variable = static::variableName($name);

foreach (static::patterns() as $pattern) {
if (str_contains($variable, strtoupper($pattern))) {
return true;
}
}

return false;
}

public static function enabled(): bool
{
return (bool) config('filament-phpinfo.redact-environment', true);
}

public static function placeholder(): string
{
return config('filament-phpinfo.redact-placeholder') ?? static::DEFAULT_PLACEHOLDER;
}

/**
* @return array<int, string>
*/
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);
}
}
106 changes: 106 additions & 0 deletions tests/RedactionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

use STS\FilamentPHPInfo\Redaction;
use STS\Phpinfo\Info;

it('replaces the value of a secret environment variable', function () {
expect(Redaction::value('Environment', 'APP_KEY', 'base64:abc123'))->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');
});
Loading