Skip to content

Commit 1270038

Browse files
dp88claude
andauthored
Redact secret-bearing environment variables (#3)
* Redact Secret-Bearing Environment Variables phpinfo() prints the whole process environment three times: as APP_KEY under Environment, and as $_ENV['APP_KEY'] and $_SERVER['APP_KEY'] under PHP Variables. On a Laravel app that renders the encryption key, the database password and every third-party credential on one page. Redaction::value() replaces the value of a variable whose name contains a sensitive pattern. The row stays, so the page still shows which variables are set. Matching is scoped to the two environment modules, so PHP settings such as "Max keys", "Tokenizer Support" and "highlight.keyword" keep their values. Config controls the behaviour: redact-environment turns it off, redact-patterns changes the set, redact-placeholder changes the text. The keys are flat, so mergeConfigFrom gives an already-published config file the new defaults. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Unblock The Laravel 11 CI Legs Laravel 11 is past its security-support window, so Composer now refuses to load any 11.x release and both Laravel 11 legs die at composer update. The failure predates this branch: origin/main resolves the same matrix leg the same way. Those legs check that the package still works on Laravel 11, not that Laravel 11 is patched, so turn off advisory blocking for them alone. Every other leg keeps it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent fa3bacf commit 1270038

6 files changed

Lines changed: 260 additions & 8 deletions

File tree

‎.github/workflows/tests.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ jobs:
7070

7171
- name: Install dependencies
7272
run: |
73+
# Laravel 11 is past its security-support window, so Composer refuses to load any
74+
# 11.x release. This leg checks that the package still works on Laravel 11, not that
75+
# Laravel 11 is patched, so let the resolver see it.
76+
if [[ "${{ matrix.laravel }}" == "11.*" ]]; then
77+
composer config --no-plugins policy.advisories.block false
78+
fi
7379
composer require "filament/filament:${{ matrix.filament }}" "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update
7480
if [[ "${{ matrix.laravel }}" != "13.*" ]]; then
7581
composer require "pestphp/pest-plugin-laravel:^3.0" --no-interaction --no-update

‎README.md‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,36 @@ $panel
2323
])
2424
```
2525

26+
## Secrets
27+
28+
`phpinfo()` prints the whole process environment three times: once under `Environment` as
29+
`APP_KEY`, and twice under `PHP Variables` as `$_ENV['APP_KEY']` and `$_SERVER['APP_KEY']`. On a
30+
Laravel app that puts the encryption key, the database password and every third-party credential
31+
on one page.
32+
33+
This package replaces those values with `[redacted]` by default. It keeps the row, so the page
34+
still tells you which variables are set. Matching applies only to the two environment modules, so
35+
PHP settings such as `Max keys` and `Tokenizer Support` keep their values.
36+
37+
The page is still worth gating to your most trusted users. Redaction removes the credentials, not
38+
the rest of the host's configuration.
39+
2640
## Configuration
27-
The navigation group and icon are configurable.
41+
The navigation group, icon, and secret redaction are configurable.
2842

2943
Publish the `filament-phpinfo` config file with:
3044
```bash
3145
php artisan vendor:publish --tag=filament-phpinfo-config
3246
```
3347

34-
| Option | Description |
35-
|--------------------|----------------------------------------------------------------------------------------------------------------------|
36-
| `navigation-group` | The PHPInfo page's [navigation group](https://filamentphp.com/docs/3.x/panels/navigation#grouping-navigation-items). |
37-
| `navigation-icon` | The PHPInfo page's icon. See Filament's [documentation](https://filamentphp.com/docs/3.x/support/icons) for values. |
48+
| Option | Description |
49+
|-----------------------|----------------------------------------------------------------------------------------------------------------------|
50+
| `navigation-group` | The PHPInfo page's [navigation group](https://filamentphp.com/docs/3.x/panels/navigation#grouping-navigation-items). |
51+
| `navigation-icon` | The PHPInfo page's icon. See Filament's [documentation](https://filamentphp.com/docs/3.x/support/icons) for values. |
52+
| `page-slug` | The PHPInfo page's URL slug. |
53+
| `redact-environment` | Whether to replace the values of secret-bearing environment variables. Defaults to `true`. |
54+
| `redact-patterns` | The strings that mark an environment variable name as secret-bearing. Matching ignores case. |
55+
| `redact-placeholder` | What a redacted value shows instead. Defaults to `[redacted]`. |
3856

3957
| Screenshot |
4058
|---|

‎config/filament-phpinfo.php‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,31 @@
11
<?php
22

3+
use STS\FilamentPHPInfo\Redaction;
4+
35
return [
46
'navigation-group' => 'System Management',
57
'navigation-icon' => 'heroicon-o-information-circle',
68
'page-slug' => 'phpinfo',
9+
10+
/*
11+
* phpinfo() prints the whole process environment three times, under Environment as
12+
* APP_KEY and under PHP Variables as $_ENV['APP_KEY'] and $_SERVER['APP_KEY']. On a
13+
* Laravel app that puts the encryption key, the database password and every third-party
14+
* credential on one page. Set this to false to print the values.
15+
*/
16+
'redact-environment' => true,
17+
18+
/*
19+
* An environment variable whose name contains any of these strings has its value replaced.
20+
* Matching ignores case, and applies only to the two environment modules above, so PHP
21+
* settings such as "Max keys" and "Tokenizer Support" keep their values. Replace this with
22+
* your own array to change the set.
23+
*/
24+
'redact-patterns' => Redaction::DEFAULT_PATTERNS,
25+
26+
/*
27+
* What a redacted value shows instead. The row itself stays, so the page still tells you
28+
* which variables are set.
29+
*/
30+
'redact-placeholder' => Redaction::DEFAULT_PLACEHOLDER,
731
];

‎resources/views/phpinfo.blade.php‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
@use(STS\FilamentPHPInfo\Redaction)
2+
13
<x-filament-panels::page>
24
@foreach ($info->modules() as $module)
35
<x-filament::section :heading="$module->name()" collapsible>
@@ -22,13 +24,13 @@
2224

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

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

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

‎src/Redaction.php‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
3+
namespace STS\FilamentPHPInfo;
4+
5+
class Redaction
6+
{
7+
/**
8+
* phpinfo() prints the process environment under these two PHP-generated headings, and
9+
* nowhere else. Every other module holds PHP settings, where names such as "Max keys",
10+
* "Tokenizer Support" and "highlight.keyword" match the patterns below but hold no secret.
11+
*/
12+
public const ENVIRONMENT_MODULES = ['Environment', 'PHP Variables'];
13+
14+
public const DEFAULT_PATTERNS = [
15+
'KEY',
16+
'SECRET',
17+
'PASSWORD',
18+
'TOKEN',
19+
'CREDENTIAL',
20+
'PRIVATE',
21+
'SALT',
22+
'SIGNING',
23+
'SIGNATURE',
24+
'DSN',
25+
'LICENSE',
26+
'WEBHOOK',
27+
];
28+
29+
public const DEFAULT_PLACEHOLDER = '[redacted]';
30+
31+
public static function value(string $module, string $name, ?string $value): ?string
32+
{
33+
// An unset or empty variable leaks nothing. Leave it, so the page keeps showing which
34+
// variables carry a value and which do not.
35+
if ($value === null || $value === '') {
36+
return $value;
37+
}
38+
39+
return static::isSensitive($module, $name) ? static::placeholder() : $value;
40+
}
41+
42+
public static function isSensitive(string $module, string $name): bool
43+
{
44+
if (! static::enabled()) {
45+
return false;
46+
}
47+
48+
if (! in_array($module, static::ENVIRONMENT_MODULES, true)) {
49+
return false;
50+
}
51+
52+
$variable = static::variableName($name);
53+
54+
foreach (static::patterns() as $pattern) {
55+
if (str_contains($variable, strtoupper($pattern))) {
56+
return true;
57+
}
58+
}
59+
60+
return false;
61+
}
62+
63+
public static function enabled(): bool
64+
{
65+
return (bool) config('filament-phpinfo.redact-environment', true);
66+
}
67+
68+
public static function placeholder(): string
69+
{
70+
return config('filament-phpinfo.redact-placeholder') ?? static::DEFAULT_PLACEHOLDER;
71+
}
72+
73+
/**
74+
* @return array<int, string>
75+
*/
76+
public static function patterns(): array
77+
{
78+
return config('filament-phpinfo.redact-patterns') ?? static::DEFAULT_PATTERNS;
79+
}
80+
81+
/**
82+
* phpinfo() prints the same variable three times, as APP_KEY under Environment and as
83+
* $_ENV['APP_KEY'] and $_SERVER['APP_KEY'] under PHP Variables. Reduce all three forms to
84+
* the bare variable name before matching.
85+
*/
86+
protected static function variableName(string $name): string
87+
{
88+
$name = trim($name);
89+
90+
if (preg_match('/^\$_[A-Z]+\[[\'"]?(.*?)[\'"]?\]$/', $name, $matches)) {
91+
$name = $matches[1];
92+
}
93+
94+
return strtoupper($name);
95+
}
96+
}

‎tests/RedactionTest.php‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php
2+
3+
use STS\FilamentPHPInfo\Redaction;
4+
use STS\Phpinfo\Info;
5+
6+
it('replaces the value of a secret environment variable', function () {
7+
expect(Redaction::value('Environment', 'APP_KEY', 'base64:abc123'))->toBe('[redacted]');
8+
});
9+
10+
it('redacts every row form phpinfo prints for one variable', function ($module, $name) {
11+
expect(Redaction::isSensitive($module, $name))->toBeTrue();
12+
})->with([
13+
['Environment', 'APP_KEY'],
14+
['PHP Variables', "\$_ENV['APP_KEY']"],
15+
['PHP Variables', '$_SERVER["APP_KEY"]'],
16+
]);
17+
18+
it('redacts on every pattern', function ($name) {
19+
expect(Redaction::isSensitive('Environment', $name))->toBeTrue();
20+
})->with([
21+
'AWS_SECRET_ACCESS_KEY',
22+
'DB_PASSWORD',
23+
'NIGHTWATCH_TOKEN',
24+
'GOOGLE_APPLICATION_CREDENTIALS',
25+
'OAUTH_PRIVATE_KEY',
26+
'JWT_SIGNING_SALT',
27+
'JWT_RECIPIENT_SIGNATURE',
28+
'SENTRY_DSN',
29+
'NOVA_LICENSE_KEY',
30+
'SLACK_WEBHOOK',
31+
]);
32+
33+
it('keeps ordinary environment variables', function ($name) {
34+
expect(Redaction::value('Environment', $name, 'kept'))->toBe('kept');
35+
})->with(['APP_ENV', 'DB_HOST', 'MAIL_FROM_ADDRESS', 'AWS_DEFAULT_REGION', 'PATH']);
36+
37+
it('keeps php settings whose name matches a pattern', function ($module, $name) {
38+
expect(Redaction::value($module, $name, 'kept'))->toBe('kept');
39+
})->with([
40+
['Zend OPcache', 'Max keys'],
41+
['Zend OPcache', 'Cached keys'],
42+
['Zend OPcache', 'Hash keys restarts'],
43+
['tokenizer', 'Tokenizer Support'],
44+
['Core', 'highlight.keyword'],
45+
]);
46+
47+
it('leaves an unset variable alone, so the page still shows it is empty', function ($value) {
48+
expect(Redaction::value('Environment', 'APP_KEY', $value))->toBe($value);
49+
})->with([null, '']);
50+
51+
it('can be turned off', function () {
52+
config()->set('filament-phpinfo.redact-environment', false);
53+
54+
expect(Redaction::value('Environment', 'APP_KEY', 'base64:abc123'))->toBe('base64:abc123');
55+
});
56+
57+
it('respects custom patterns from config', function () {
58+
config()->set('filament-phpinfo.redact-patterns', ['tenant']);
59+
60+
expect(Redaction::value('Environment', 'TENANT_ID', 'acme'))->toBe('[redacted]');
61+
expect(Redaction::value('Environment', 'APP_KEY', 'base64:abc123'))->toBe('base64:abc123');
62+
});
63+
64+
it('respects a custom placeholder from config', function () {
65+
config()->set('filament-phpinfo.redact-placeholder', '***');
66+
67+
expect(Redaction::value('Environment', 'APP_KEY', 'base64:abc123'))->toBe('***');
68+
});
69+
70+
it('redacts nothing outside the environment modules of a real capture', function () {
71+
$redacted = [];
72+
73+
foreach (Info::capture()->modules() as $module) {
74+
if (in_array($module->name(), Redaction::ENVIRONMENT_MODULES, true)) {
75+
continue;
76+
}
77+
78+
foreach ($module->configs() as $config) {
79+
if (Redaction::isSensitive($module->name(), $config->name())) {
80+
$redacted[] = $module->name() . ' / ' . $config->name();
81+
}
82+
}
83+
}
84+
85+
expect($redacted)->toBeEmpty();
86+
});
87+
88+
it('redacts a secret in a real capture', function () {
89+
putenv('FILAMENT_PHPINFO_TEST_SECRET=must-not-render');
90+
91+
$names = [];
92+
93+
foreach (Info::capture()->modules() as $module) {
94+
foreach ($module->configs() as $config) {
95+
if (str_contains($config->name(), 'FILAMENT_PHPINFO_TEST_SECRET')) {
96+
$names[] = $config->name();
97+
expect(Redaction::value($module->name(), $config->name(), $config->localValue()))
98+
->toBe('[redacted]');
99+
}
100+
}
101+
}
102+
103+
expect($names)->not->toBeEmpty();
104+
105+
putenv('FILAMENT_PHPINFO_TEST_SECRET');
106+
});

0 commit comments

Comments
 (0)