diff --git a/app/Filament/Admin/Pages/Waf.php b/app/Filament/Admin/Pages/Waf.php index abac14c..0a9c9c1 100644 --- a/app/Filament/Admin/Pages/Waf.php +++ b/app/Filament/Admin/Pages/Waf.php @@ -18,11 +18,18 @@ use Filament\Notifications\Notification; use Filament\Pages\Page; use Filament\Schemas\Components\Section; use Filament\Support\Icons\Heroicon; +use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Concerns\InteractsWithTable; +use Filament\Tables\Contracts\HasTable; +use Filament\Tables\Table; use Illuminate\Contracts\Support\Htmlable; +use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Str; -class Waf extends Page implements HasForms +class Waf extends Page implements HasForms, HasTable { use InteractsWithForms; + use InteractsWithTable; protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck; @@ -36,6 +43,10 @@ class Waf extends Page implements HasForms public array $wafFormData = []; + public array $auditEntries = []; + + public bool $auditLoaded = false; + public function getTitle(): string|Htmlable { return __('ModSecurity / WAF'); @@ -194,4 +205,307 @@ class Waf extends Page implements HasForms ->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'] ?? ''); + $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) { + return true; + } + if ($matchType === 'uri_prefix' && $matchValue !== '' && str_starts_with($uri, $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_exact'; + $matchValue = (string) ($record['uri'] ?? ''); + 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->auditEntries = $this->markWhitelisted($this->auditEntries); + + Notification::make() + ->title(__('Rule whitelisted')) + ->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('message') + ->label(__('Message')) + ->wrap() + ->limit(80) + ->searchable(), + TextColumn::make('uri') + ->label(__('URI')) + ->wrap() + ->searchable(), + TextColumn::make('remote_ip') + ->label(__('IP')) + ->fontFamily('mono') + ->toggleable(), + 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)), + ]) + ->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(), + ], + ); + } } diff --git a/app/Services/Agent/AgentClient.php b/app/Services/Agent/AgentClient.php index bf119b9..23e54f3 100644 --- a/app/Services/Agent/AgentClient.php +++ b/app/Services/Agent/AgentClient.php @@ -1335,6 +1335,13 @@ class AgentClient ]); } + public function wafAuditLogList(int $limit = 200): array + { + return $this->send('waf.audit_log', [ + 'limit' => $limit, + ]); + } + public function geoApplyRules(array $rules): array { return $this->send('geo.apply_rules', [ diff --git a/bin/jabali-agent b/bin/jabali-agent index 4ea112c..0b50007 100755 --- a/bin/jabali-agent +++ b/bin/jabali-agent @@ -546,6 +546,7 @@ function handleAction(array $request): array 'updates.list' => updatesList($params), 'updates.run' => updatesRun($params), 'waf.apply' => wafApplySettings($params), + 'waf.audit_log' => wafAuditLogList($params), 'geo.apply_rules' => geoApplyRules($params), 'geo.update_database' => geoUpdateDatabase($params), 'geo.upload_database' => geoUploadDatabase($params), @@ -724,6 +725,98 @@ function handleAction(array $request): array }; } +function wafAuditLogList(array $params): array +{ + $limit = (int) ($params['limit'] ?? 200); + if ($limit <= 0) { + $limit = 200; + } + + $logPath = '/var/log/nginx/modsec_audit.log'; + if (!file_exists($logPath)) { + return ['success' => true, 'entries' => []]; + } + + $lines = []; + exec('tail -n 5000 ' . escapeshellarg($logPath) . ' 2>/dev/null', $lines); + + $entries = []; + $current = [ + 'timestamp' => null, + 'remote_ip' => null, + 'host' => null, + 'uri' => null, + ]; + + foreach ($lines as $line) { + if (preg_match('/^---[A-Za-z0-9]+---A--$/', $line)) { + $current = [ + 'timestamp' => null, + 'remote_ip' => null, + 'host' => null, + 'uri' => null, + ]; + continue; + } + + if (preg_match('/^\[(\d{2}\/[A-Za-z]{3}\/\d{4}:\d{2}:\d{2}:\d{2}) ([+-]\d{4})\]\s+\d+\.\d+\s+([0-9a-fA-F:.]+)/', $line, $matches)) { + $date = DateTime::createFromFormat('d/M/Y:H:i:s O', $matches[1] . ' ' . $matches[2]); + if ($date instanceof DateTime) { + $current['timestamp'] = $date->getTimestamp(); + } + $current['remote_ip'] = $matches[3]; + continue; + } + + if (preg_match('/^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH)\s+([^ ]+)\s+HTTP/i', $line, $matches)) { + $current['uri'] = $matches[2]; + continue; + } + + if (preg_match('/^host:\s*(.+)$/i', $line, $matches)) { + $current['host'] = trim($matches[1]); + continue; + } + + if (str_contains($line, 'ModSecurity:') && str_contains($line, 'Access denied')) { + $entry = [ + 'timestamp' => $current['timestamp'], + 'remote_ip' => $current['remote_ip'], + 'host' => $current['host'], + 'uri' => $current['uri'], + 'rule_id' => null, + 'message' => null, + 'severity' => null, + ]; + + if (preg_match('/\\[id "([0-9]+)"\\]/', $line, $matches)) { + $entry['rule_id'] = $matches[1]; + } + if (preg_match('/\\[msg "([^"]+)"\\]/', $line, $matches)) { + $entry['message'] = $matches[1]; + } + if (preg_match('/\\[severity "([^"]+)"\\]/', $line, $matches)) { + $entry['severity'] = $matches[1]; + } + if (preg_match('/\\[uri "([^"]+)"\\]/', $line, $matches)) { + $entry['uri'] = $matches[1]; + } + if (preg_match('/\\[hostname "([^"]+)"\\]/', $line, $matches)) { + $entry['host'] = $matches[1]; + } + + $entries[] = $entry; + } + } + + $entries = array_reverse($entries); + if (count($entries) > $limit) { + $entries = array_slice($entries, 0, $limit); + } + + return ['success' => true, 'entries' => $entries]; +} + // ============ USER MANAGEMENT ============ function createUser(array $params): array diff --git a/resources/views/filament/admin/pages/waf.blade.php b/resources/views/filament/admin/pages/waf.blade.php index e70e004..08f5b08 100644 --- a/resources/views/filament/admin/pages/waf.blade.php +++ b/resources/views/filament/admin/pages/waf.blade.php @@ -17,5 +17,15 @@ +