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); } protected function detectWaf(): bool { $paths = [ '/etc/nginx/modsec/main.conf', '/etc/nginx/modsecurity.conf', '/etc/modsecurity/modsecurity.conf', '/etc/modsecurity/modsecurity.conf-recommended', ]; foreach ($paths as $path) { if (file_exists($path)) { return true; } } return false; } protected function getForms(): array { return ['wafForm']; } public function wafForm(\Filament\Schemas\Schema $schema): \Filament\Schemas\Schema { return $schema ->statePath('wafFormData') ->schema([ Section::make(__('WAF Settings')) ->schema([ Toggle::make('enabled') ->label(__('Enable ModSecurity')) ->disabled(fn () => ! $this->wafInstalled) ->helperText(fn () => $this->wafInstalled ? null : __('ModSecurity is not installed. Install it to enable WAF.')), Select::make('paranoia') ->label(__('Paranoia Level')) ->options([ '1' => '1 - Basic', '2' => '2 - Moderate', '3' => '3 - Strict', '4' => '4 - Very Strict', ]) ->default('1'), Toggle::make('audit_log') ->label(__('Enable Audit Log')), ]) ->columns(2), Section::make(__('Whitelist Rules')) ->description(__('Exclude trusted traffic from specific ModSecurity rule IDs.')) ->schema([ Repeater::make('whitelist_rules') ->label(__('Whitelist Entries')) ->table([ RepeaterTableColumn::make(__('Name'))->width('18%'), RepeaterTableColumn::make(__('Match Type'))->width('16%'), RepeaterTableColumn::make(__('Match Value'))->width('28%'), RepeaterTableColumn::make(__('Rule IDs'))->width('26%'), RepeaterTableColumn::make(__('Enabled'))->width('12%')->alignCenter(), ]) ->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(), Toggle::make('enabled') ->label(__('Enabled')) ->default(true), ]) ->itemLabel(fn (array $state): ?string => $state['label'] ?? $state['match_value'] ?? null) ->addActionLabel(__('Add Whitelist Rule')) ->collapsible() ->columns(['default' => 2, 'md' => 2]), ]) ->columns(1), ]); } public function saveWafSettings(): void { $data = $this->wafForm->getState(); $requestedEnabled = ! empty($data['enabled']); if ($requestedEnabled && ! $this->wafInstalled) { $requestedEnabled = false; } 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)); try { $agent = new AgentClient; $agent->wafApplySettings( $requestedEnabled, (string) ($data['paranoia'] ?? '1'), ! empty($data['audit_log']), $data['whitelist_rules'] ?? [] ); if (! $this->wafInstalled && ! empty($data['enabled'])) { Notification::make() ->title(__('ModSecurity is not installed')) ->body(__('WAF was disabled automatically. Install ModSecurity to enable it.')) ->warning() ->send(); return; } Notification::make() ->title(__('WAF settings applied')) ->success() ->send(); } catch (Exception $e) { Notification::make() ->title(__('WAF settings saved, but apply failed')) ->body($e->getMessage()) ->warning() ->send(); } } public function loadAuditLogs(bool $notify = true): void { try { $agent = new AgentClient; $response = $agent->wafAuditLogList(); $entries = $response['entries'] ?? []; if (! is_array($entries)) { $entries = []; } $this->auditEntries = $this->markWhitelisted($entries); $this->auditLoaded = true; if ($notify) { Notification::make() ->title(__('WAF logs refreshed')) ->success() ->send(); } } catch (Exception $e) { Notification::make() ->title(__('Failed to load WAF logs')) ->body($e->getMessage()) ->warning() ->send(); } } protected function getWhitelistRules(): array { $raw = Setting::get('waf_whitelist_rules', '[]'); $rules = json_decode($raw, true); return is_array($rules) ? $rules : []; } protected function markWhitelisted(array $entries): array { $rules = $this->getWhitelistRules(); foreach ($entries as &$entry) { $entry['whitelisted'] = $this->matchesWhitelist($entry, $rules); } return $entries; } protected function matchesWhitelist(array $entry, array $rules): bool { $ruleId = (string) ($entry['rule_id'] ?? ''); $uri = (string) ($entry['uri'] ?? ''); $uriPath = $this->stripQueryString($uri); $host = (string) ($entry['host'] ?? ''); $ip = (string) ($entry['remote_ip'] ?? ''); foreach ($rules as $rule) { if (!is_array($rule)) { continue; } if (isset($rule['enabled']) && !$rule['enabled']) { continue; } $idsRaw = (string) ($rule['rule_ids'] ?? ''); $ids = preg_split('/[,\s]+/', $idsRaw, -1, PREG_SPLIT_NO_EMPTY) ?: []; $ids = array_map('trim', $ids); if ($ruleId !== '' && !empty($ids) && !in_array($ruleId, $ids, true)) { continue; } $matchType = (string) ($rule['match_type'] ?? ''); $matchValue = (string) ($rule['match_value'] ?? ''); if ($matchType === 'ip' && $matchValue !== '' && $this->ipMatches($ip, $matchValue)) { return true; } if ($matchType === 'uri_exact' && $matchValue !== '' && ($uri === $matchValue || $uriPath === $matchValue)) { return true; } if ($matchType === 'uri_prefix' && $matchValue !== '' && (str_starts_with($uri, $matchValue) || str_starts_with($uriPath, $matchValue))) { return true; } if ($matchType === 'host' && $matchValue !== '' && $host === $matchValue) { return true; } } return false; } protected function ruleMatchesEntry(array $rule, array $entry): bool { if (!is_array($rule)) { return false; } if (isset($rule['enabled']) && !$rule['enabled']) { return false; } $ruleId = (string) ($entry['rule_id'] ?? ''); $uri = (string) ($entry['uri'] ?? ''); $uriPath = $this->stripQueryString($uri); $host = (string) ($entry['host'] ?? ''); $ip = (string) ($entry['remote_ip'] ?? ''); $idsRaw = (string) ($rule['rule_ids'] ?? ''); $ids = preg_split('/[,\s]+/', $idsRaw, -1, PREG_SPLIT_NO_EMPTY) ?: []; $ids = array_map('trim', $ids); if ($ruleId !== '' && !empty($ids) && !in_array($ruleId, $ids, true)) { return false; } $matchType = (string) ($rule['match_type'] ?? ''); $matchValue = (string) ($rule['match_value'] ?? ''); if ($matchType === 'ip' && $matchValue !== '' && $this->ipMatches($ip, $matchValue)) { return true; } if ($matchType === 'uri_exact' && $matchValue !== '' && ($uri === $matchValue || $uriPath === $matchValue)) { return true; } if ($matchType === 'uri_prefix' && $matchValue !== '' && (str_starts_with($uri, $matchValue) || str_starts_with($uriPath, $matchValue))) { return true; } if ($matchType === 'host' && $matchValue !== '' && $host === $matchValue) { return true; } return false; } protected function ipMatches(string $ip, string $rule): bool { if ($ip === '' || $rule === '') { return false; } if (! str_contains($rule, '/')) { return $ip === $rule; } [$subnet, $bits] = array_pad(explode('/', $rule, 2), 2, null); $bits = is_numeric($bits) ? (int) $bits : null; if ($bits === null || $bits < 0 || $bits > 32) { return $ip === $rule; } $ipLong = ip2long($ip); $subnetLong = ip2long($subnet); if ($ipLong === false || $subnetLong === false) { return $ip === $rule; } $mask = -1 << (32 - $bits); return ($ipLong & $mask) === ($subnetLong & $mask); } public function whitelistEntry(array $record): void { $rules = $this->getWhitelistRules(); $matchType = 'uri_prefix'; $rawUri = (string) ($record['uri'] ?? ''); $matchValue = $this->stripQueryString($rawUri); if ($matchValue === '') { $matchType = 'ip'; $matchValue = (string) ($record['remote_ip'] ?? ''); } if ($matchValue === '' || empty($record['rule_id'])) { Notification::make() ->title(__('Unable to whitelist entry')) ->body(__('Missing URI/IP or rule ID for this entry.')) ->warning() ->send(); return; } $rules[] = [ 'label' => __('Whitelist rule {rule}', ['rule' => $record['rule_id'] ?? '']), 'match_type' => $matchType, 'match_value' => $matchValue, 'rule_ids' => $record['rule_id'] ?? '', 'enabled' => true, ]; 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(); } $this->loadAuditLogs(false); $this->resetTable(); Notification::make() ->title(__('Rule whitelisted')) ->success() ->send(); } protected function stripQueryString(string $uri): string { $pos = strpos($uri, '?'); if ($pos === false) { return $uri; } return substr($uri, 0, $pos); } public function removeWhitelistEntry(array $record): void { $rules = $this->getWhitelistRules(); $beforeCount = count($rules); $rules = array_values(array_filter($rules, function (array $rule) use ($record): bool { return ! $this->ruleMatchesEntry($rule, $record); })); if (count($rules) === $beforeCount) { Notification::make() ->title(__('No matching whitelist rule found')) ->warning() ->send(); return; } 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 updated, but apply failed')) ->body($e->getMessage()) ->warning() ->send(); } $this->loadAuditLogs(false); $this->resetTable(); Notification::make() ->title(__('Whitelist removed')) ->success() ->send(); } public function table(Table $table): Table { return $table ->paginated([25, 50, 100]) ->defaultPaginationPageOption(25) ->records(function (?array $filters, ?string $search, int|string $page, int|string $recordsPerPage, ?string $sortColumn, ?string $sortDirection) { if (! $this->auditLoaded) { $this->loadAuditLogs(false); } $records = $this->auditEntries; $records = $this->filterRecords($records, $search); $records = $this->sortRecords($records, $sortColumn, $sortDirection); return $this->paginateRecords($records, $page, $recordsPerPage); }) ->columns([ TextColumn::make('timestamp') ->label(__('Time')) ->formatStateUsing(function (array $record): string { $timestamp = (int) ($record['timestamp'] ?? 0); return $timestamp > 0 ? date('Y-m-d H:i:s', $timestamp) : ''; }) ->sortable(), TextColumn::make('rule_id') ->label(__('Rule ID')) ->fontFamily('mono') ->sortable() ->searchable(), TextColumn::make('event_type') ->label(__('Type')) ->badge() ->getStateUsing(function (array $record): string { if (!empty($record['blocked'])) { return __('Blocked'); } $severity = (int) ($record['severity'] ?? 0); if ($severity >= 4) { return __('Error'); } return __('Warning'); }) ->color(function (array $record): string { if (!empty($record['blocked'])) { return 'danger'; } $severity = (int) ($record['severity'] ?? 0); if ($severity >= 4) { return 'warning'; } return 'gray'; }) ->sortable() ->toggleable(), TextColumn::make('message') ->label(__('Message')) ->wrap() ->limit(80) ->searchable(), TextColumn::make('uri') ->label(__('URI')) ->getStateUsing(function (array $record): string { $host = (string) ($record['host'] ?? ''); $uri = (string) ($record['uri'] ?? ''); if ($host !== '' && $uri !== '') { return $host.$uri; } return $uri !== '' ? $uri : $host; }) ->tooltip(function (array $record): string { $host = (string) ($record['host'] ?? ''); $uri = (string) ($record['uri'] ?? ''); if ($host !== '' && $uri !== '') { return $host.$uri; } return $uri !== '' ? $uri : $host; }) ->limit(60) ->copyable() ->copyableState(function (array $record): string { $host = (string) ($record['host'] ?? ''); $uri = (string) ($record['uri'] ?? ''); if ($host !== '' && $uri !== '') { return $host.$uri; } return $uri !== '' ? $uri : $host; }) ->wrap() ->searchable(), TextColumn::make('remote_ip') ->label(__('IP')) ->fontFamily('mono') ->copyable() ->toggleable(isToggledHiddenByDefault: false), TextColumn::make('host') ->label(__('Host')) ->toggleable(isToggledHiddenByDefault: true), TextColumn::make('whitelisted') ->label(__('Whitelisted')) ->badge() ->formatStateUsing(fn (array $record): string => !empty($record['whitelisted']) ? __('Yes') : __('No')) ->color(fn (array $record): string => !empty($record['whitelisted']) ? 'success' : 'gray'), ]) ->recordActions([ \Filament\Actions\Action::make('whitelist') ->label(__('Whitelist')) ->icon('heroicon-o-check-badge') ->color('primary') ->visible(fn (array $record): bool => empty($record['whitelisted'])) ->action(fn (array $record) => $this->whitelistEntry($record)), \Filament\Actions\Action::make('removeWhitelist') ->label(__('Remove whitelist')) ->icon('heroicon-o-x-mark') ->color('danger') ->visible(fn (array $record): bool => !empty($record['whitelisted'])) ->requiresConfirmation() ->action(fn (array $record) => $this->removeWhitelistEntry($record)), ]) ->emptyStateHeading(__('No blocked rules found')) ->emptyStateDescription(__('No ModSecurity denials found in the audit log.')) ->headerActions([ \Filament\Actions\Action::make('refresh') ->label(__('Refresh Logs')) ->icon('heroicon-o-arrow-path') ->action(fn () => $this->loadAuditLogs()), ]); } protected function filterRecords(array $records, ?string $search): array { if (! $search) { return $records; } return array_values(array_filter($records, function (array $record) use ($search) { $haystack = implode(' ', array_filter([ (string) ($record['rule_id'] ?? ''), (string) ($record['message'] ?? ''), (string) ($record['uri'] ?? ''), (string) ($record['remote_ip'] ?? ''), (string) ($record['host'] ?? ''), ])); return str_contains(Str::lower($haystack), Str::lower($search)); })); } protected function sortRecords(array $records, ?string $sortColumn, ?string $sortDirection): array { $direction = $sortDirection === 'asc' ? 'asc' : 'desc'; if (! $sortColumn) { return $records; } usort($records, function (array $a, array $b) use ($sortColumn, $direction): int { $aValue = $a[$sortColumn] ?? null; $bValue = $b[$sortColumn] ?? null; if (is_numeric($aValue) && is_numeric($bValue)) { $result = (float) $aValue <=> (float) $bValue; } else { $result = strcmp((string) $aValue, (string) $bValue); } return $direction === 'asc' ? $result : -$result; }); return $records; } protected function paginateRecords(array $records, int|string $page, int|string $recordsPerPage): LengthAwarePaginator { $page = max(1, (int) $page); $perPage = max(1, (int) $recordsPerPage); $total = count($records); $items = array_slice($records, ($page - 1) * $perPage, $perPage); return new LengthAwarePaginator( $items, $total, $perPage, $page, [ 'path' => request()->url(), 'pageName' => $this->getTablePaginationPageName(), ], ); } }