diff --git a/app/Filament/Admin/Pages/Waf.php b/app/Filament/Admin/Pages/Waf.php index 1191af0..9aa2a40 100644 --- a/app/Filament/Admin/Pages/Waf.php +++ b/app/Filament/Admin/Pages/Waf.php @@ -8,10 +8,7 @@ use App\Models\Setting; use App\Services\Agent\AgentClient; use BackedEnum; use Exception; -use Filament\Forms\Components\Repeater; -use Filament\Forms\Components\Repeater\TableColumn as RepeaterTableColumn; use Filament\Forms\Components\Select; -use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Contracts\HasForms; @@ -61,16 +58,10 @@ class Waf extends Page implements HasForms, HasTable public function mount(): void { $this->wafInstalled = $this->detectWaf(); - $whitelistRaw = Setting::get('waf_whitelist_rules', '[]'); - $whitelistRules = json_decode($whitelistRaw, true); - if (! is_array($whitelistRules)) { - $whitelistRules = []; - } $this->wafFormData = [ 'enabled' => Setting::get('waf_enabled', '0') === '1', 'paranoia' => Setting::get('waf_paranoia', '1'), 'audit_log' => Setting::get('waf_audit_log', '1') === '1', - 'whitelist_rules' => $whitelistRules, ]; $this->loadAuditLogs(false); @@ -123,109 +114,6 @@ class Waf extends Page implements HasForms, HasTable ->label(__('Enable Audit Log')), ]) ->columns(2), - Section::make(__('Whitelist Rules')) - ->description(__('Exclude trusted traffic from specific ModSecurity rule IDs.')) - ->headerActions([ - \Filament\Actions\Action::make('addWhitelistRule') - ->label(__('Add Custom Rule')) - ->icon('heroicon-o-plus') - ->modalHeading(__('Add Whitelist Rule')) - ->form([ - TextInput::make('label') - ->label(__('Name')) - ->placeholder(__('Example: Admin API allowlist')) - ->maxLength(80), - Select::make('match_type') - ->label(__('Match Type')) - ->options([ - 'ip' => __('IP Address or CIDR'), - 'uri_exact' => __('Exact URI'), - 'uri_prefix' => __('URI Prefix'), - 'host' => __('Host Header'), - ]) - ->required(), - TextInput::make('match_value') - ->label(__('Match Value')) - ->placeholder(__('Example: 203.0.113.10 or /wp-admin/admin-ajax.php')) - ->required(), - TextInput::make('rule_ids') - ->label(__('Rule IDs')) - ->placeholder(__('Example: 942100,949110')) - ->helperText(__('Comma-separated ModSecurity rule IDs to disable for matches.')) - ->required(), - ]) - ->action(function (array $data): void { - $rules = $this->getWhitelistRules(); - - $rules[] = [ - 'label' => $data['label'] ?? null, - 'match_type' => $data['match_type'] ?? '', - 'match_value' => $data['match_value'] ?? '', - 'rule_ids' => $data['rule_ids'] ?? '', - ]; - - Setting::set('waf_whitelist_rules', json_encode(array_values($rules), JSON_UNESCAPED_SLASHES)); - $this->wafFormData['whitelist_rules'] = $rules; - - try { - $agent = new AgentClient; - $agent->wafApplySettings( - Setting::get('waf_enabled', '0') === '1', - (string) Setting::get('waf_paranoia', '1'), - Setting::get('waf_audit_log', '1') === '1', - $rules - ); - } catch (Exception $e) { - Notification::make() - ->title(__('Whitelist saved, but apply failed')) - ->body($e->getMessage()) - ->warning() - ->send(); - } - - Notification::make() - ->title(__('Whitelist rule added')) - ->success() - ->send(); - }), - ]) - ->schema([ - Repeater::make('whitelist_rules') - ->label(__('Whitelist Entries')) - ->table([ - RepeaterTableColumn::make(__('Name'))->width('22%'), - RepeaterTableColumn::make(__('Match Type'))->width('16%'), - RepeaterTableColumn::make(__('Match Value'))->width('32%'), - RepeaterTableColumn::make(__('Rule IDs'))->width('30%'), - ]) - ->schema([ - TextInput::make('label') - ->label(__('Name')) - ->placeholder(__('Admin API allowlist')) - ->maxLength(80), - Select::make('match_type') - ->label(__('Match Type')) - ->options([ - 'ip' => __('IP Address or CIDR'), - 'uri_exact' => __('Exact URI'), - 'uri_prefix' => __('URI Prefix'), - 'host' => __('Host Header'), - ]) - ->required(), - TextInput::make('match_value') - ->label(__('Match Value')) - ->placeholder(__('Example: 203.0.113.10 or /wp-admin/admin-ajax.php')) - ->required(), - TextInput::make('rule_ids') - ->label(__('Rule IDs')) - ->placeholder(__('Example: 942100,949110')) - ->helperText(__('Comma-separated ModSecurity rule IDs to disable for matches.')) - ->required(), - ]) - ->addActionLabel(__('Add Whitelist Rule')) - ->columns(['default' => 2, 'md' => 2]), - ]) - ->columns(1), ]); } @@ -240,7 +128,7 @@ class Waf extends Page implements HasForms, HasTable Setting::set('waf_enabled', $requestedEnabled ? '1' : '0'); Setting::set('waf_paranoia', (string) ($data['paranoia'] ?? '1')); Setting::set('waf_audit_log', ! empty($data['audit_log']) ? '1' : '0'); - Setting::set('waf_whitelist_rules', json_encode(array_values($data['whitelist_rules'] ?? []), JSON_UNESCAPED_SLASHES)); + $whitelistRules = $this->getWhitelistRules(); try { $agent = new AgentClient; @@ -248,7 +136,7 @@ class Waf extends Page implements HasForms, HasTable $requestedEnabled, (string) ($data['paranoia'] ?? '1'), ! empty($data['audit_log']), - $data['whitelist_rules'] ?? [] + $whitelistRules ); if (! $this->wafInstalled && ! empty($data['enabled'])) { @@ -306,8 +194,53 @@ class Waf extends Page implements HasForms, HasTable { $raw = Setting::get('waf_whitelist_rules', '[]'); $rules = json_decode($raw, true); + if (! is_array($rules)) { + return []; + } - return is_array($rules) ? $rules : []; + $changed = false; + + foreach ($rules as &$rule) { + if (! is_array($rule)) { + $rule = []; + $changed = true; + continue; + } + + if (array_key_exists('enabled', $rule)) { + unset($rule['enabled']); + $changed = true; + } + + $label = (string) ($rule['label'] ?? ''); + if ($label === '' || str_contains($label, '{rule}') || str_contains($label, ':rule')) { + $rule['label'] = $this->defaultWhitelistLabel($rule); + $changed = true; + } + } + + $rules = array_values(array_filter($rules, fn ($rule) => is_array($rule) && ($rule !== []))); + + if ($changed) { + Setting::set('waf_whitelist_rules', json_encode($rules, JSON_UNESCAPED_SLASHES)); + } + + return $rules; + } + + protected function defaultWhitelistLabel(array $rule): string + { + $ids = trim((string) ($rule['rule_ids'] ?? '')); + if ($ids !== '') { + return __('Rule :id', ['id' => $ids]); + } + + $matchValue = trim((string) ($rule['match_value'] ?? '')); + if ($matchValue !== '') { + return $matchValue; + } + + return __('Whitelist rule'); } protected function markWhitelisted(array $entries): array @@ -459,7 +392,7 @@ class Waf extends Page implements HasForms, HasTable } $rules[] = [ - 'label' => __('Whitelist rule :rule', ['rule' => $record['rule_id'] ?? '']), + 'label' => __('Rule :rule', ['rule' => $record['rule_id'] ?? '']), 'match_type' => $matchType, 'match_value' => $matchValue, 'rule_ids' => $record['rule_id'] ?? '', diff --git a/app/Livewire/Admin/WafWhitelistTable.php b/app/Livewire/Admin/WafWhitelistTable.php new file mode 100644 index 0000000..5c2b0c5 --- /dev/null +++ b/app/Livewire/Admin/WafWhitelistTable.php @@ -0,0 +1,258 @@ +records(fn () => $this->getWhitelistRules()) + ->recordKey('id') + ->paginated([10, 25, 50]) + ->defaultPaginationPageOption(10) + ->columns([ + TextColumn::make('label') + ->label(__('Name')) + ->wrap(), + TextColumn::make('match_type') + ->label(__('Match Type')) + ->formatStateUsing(fn (string $state): string => $this->matchTypeLabel($state)) + ->toggleable(), + TextColumn::make('match_value') + ->label(__('Match Value')) + ->wrap() + ->copyable(), + TextColumn::make('rule_ids') + ->label(__('Rule IDs')) + ->fontFamily('mono') + ->copyable(), + ]) + ->headerActions([ + Action::make('add') + ->label(__('Add Custom Rule')) + ->icon('heroicon-o-plus') + ->modalHeading(__('Add Whitelist Rule')) + ->form($this->whitelistRuleForm()) + ->action(function (array $data): void { + $rules = $this->getWhitelistRules(); + $rules[] = $this->normalizeRule($data); + $this->persistWhitelistRules($rules); + + Notification::make() + ->title(__('Whitelist rule added')) + ->success() + ->send(); + }), + ]) + ->actions([ + Action::make('edit') + ->label(__('Edit')) + ->icon('heroicon-o-pencil-square') + ->form($this->whitelistRuleForm()) + ->fillForm(fn (array $record) => Arr::only($record, ['label', 'match_type', 'match_value', 'rule_ids'])) + ->action(function (array $data, array $record): void { + $rules = $this->getWhitelistRules(); + + foreach ($rules as &$rule) { + if (($rule['id'] ?? '') === ($record['id'] ?? '')) { + $rule = array_merge($rule, $this->normalizeRule($data)); + break; + } + } + + $this->persistWhitelistRules($rules); + + Notification::make() + ->title(__('Whitelist rule updated')) + ->success() + ->send(); + }), + Action::make('delete') + ->label(__('Delete')) + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->action(function (array $record): void { + $rules = array_values(array_filter($this->getWhitelistRules(), function (array $rule) use ($record) { + return ($rule['id'] ?? '') !== ($record['id'] ?? ''); + })); + + $this->persistWhitelistRules($rules); + + Notification::make() + ->title(__('Whitelist rule removed')) + ->success() + ->send(); + }), + ]) + ->emptyStateHeading(__('No whitelist rules')) + ->emptyStateDescription(__('Add a custom whitelist rule to allow trusted traffic.')); + } + + protected function whitelistRuleForm(): array + { + return [ + TextInput::make('label') + ->label(__('Name')) + ->placeholder(__('Rule 942100')) + ->maxLength(80), + Select::make('match_type') + ->label(__('Match Type')) + ->options([ + 'ip' => __('IP Address or CIDR'), + 'uri_exact' => __('Exact URI'), + 'uri_prefix' => __('URI Prefix'), + 'host' => __('Host Header'), + ]) + ->required(), + TextInput::make('match_value') + ->label(__('Match Value')) + ->placeholder(__('Example: 203.0.113.10 or /wp-admin/admin-ajax.php')) + ->required(), + TextInput::make('rule_ids') + ->label(__('Rule IDs')) + ->placeholder(__('Example: 942100,949110')) + ->helperText(__('Comma-separated ModSecurity rule IDs to disable for matches.')) + ->required(), + ]; + } + + protected function getWhitelistRules(): array + { + $raw = Setting::get('waf_whitelist_rules', '[]'); + $rules = json_decode($raw, true); + if (! is_array($rules)) { + return []; + } + + $changed = false; + + foreach ($rules as &$rule) { + if (! is_array($rule)) { + $rule = []; + $changed = true; + continue; + } + + if (array_key_exists('enabled', $rule)) { + unset($rule['enabled']); + $changed = true; + } + + $rule['label'] = $this->normalizeLabel($rule); + $rule['id'] = $this->ruleId($rule); + } + + $rules = array_values(array_filter($rules, fn ($rule) => is_array($rule) && ($rule !== []))); + + if ($changed) { + Setting::set('waf_whitelist_rules', json_encode($this->stripIds($rules), JSON_UNESCAPED_SLASHES)); + } + + return $rules; + } + + protected function normalizeRule(array $data): array + { + $rule = [ + 'label' => $data['label'] ?? null, + 'match_type' => $data['match_type'] ?? '', + 'match_value' => $data['match_value'] ?? '', + 'rule_ids' => $data['rule_ids'] ?? '', + ]; + + $rule['label'] = $this->normalizeLabel($rule); + $rule['id'] = $this->ruleId($rule); + + return $rule; + } + + protected function normalizeLabel(array $rule): string + { + $label = trim((string) ($rule['label'] ?? '')); + if ($label !== '' && !str_contains($label, '{rule}') && !str_contains($label, ':rule')) { + return $label; + } + + $ids = trim((string) ($rule['rule_ids'] ?? '')); + if ($ids !== '') { + return __('Rule :id', ['id' => $ids]); + } + + $matchValue = trim((string) ($rule['match_value'] ?? '')); + if ($matchValue !== '') { + return $matchValue; + } + + return __('Whitelist rule'); + } + + protected function ruleId(array $rule): string + { + return md5(json_encode(Arr::only($rule, ['label', 'match_type', 'match_value', 'rule_ids']))); + } + + protected function stripIds(array $rules): array + { + return array_map(function (array $rule): array { + return Arr::only($rule, ['label', 'match_type', 'match_value', 'rule_ids']); + }, $rules); + } + + protected function persistWhitelistRules(array $rules): void + { + $rules = $this->stripIds($rules); + Setting::set('waf_whitelist_rules', json_encode(array_values($rules), JSON_UNESCAPED_SLASHES)); + + try { + $agent = new AgentClient; + $agent->wafApplySettings( + Setting::get('waf_enabled', '0') === '1', + (string) Setting::get('waf_paranoia', '1'), + Setting::get('waf_audit_log', '1') === '1', + $rules + ); + } catch (Exception $e) { + Notification::make() + ->title(__('Whitelist saved, but apply failed')) + ->body($e->getMessage()) + ->warning() + ->send(); + } + } + + protected function matchTypeLabel(string $type): string + { + return match ($type) { + 'ip' => __('IP Address or CIDR'), + 'uri_exact' => __('Exact URI'), + 'uri_prefix' => __('URI Prefix'), + 'host' => __('Host Header'), + default => $type, + }; + } +} diff --git a/resources/views/filament/admin/pages/waf.blade.php b/resources/views/filament/admin/pages/waf.blade.php index 08f5b08..988698b 100644 --- a/resources/views/filament/admin/pages/waf.blade.php +++ b/resources/views/filament/admin/pages/waf.blade.php @@ -17,6 +17,16 @@ +