Add WAF audit log table with whitelist actions

This commit is contained in:
root
2026-01-30 21:58:39 +02:00
parent f55ffdc263
commit 7a09449d60
4 changed files with 425 additions and 1 deletions

View File

@@ -18,11 +18,18 @@ use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Filament\Support\Icons\Heroicon; 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\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 InteractsWithForms;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck; protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck;
@@ -36,6 +43,10 @@ class Waf extends Page implements HasForms
public array $wafFormData = []; public array $wafFormData = [];
public array $auditEntries = [];
public bool $auditLoaded = false;
public function getTitle(): string|Htmlable public function getTitle(): string|Htmlable
{ {
return __('ModSecurity / WAF'); return __('ModSecurity / WAF');
@@ -194,4 +205,307 @@ class Waf extends Page implements HasForms
->send(); ->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(),
],
);
}
} }

View File

@@ -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 public function geoApplyRules(array $rules): array
{ {
return $this->send('geo.apply_rules', [ return $this->send('geo.apply_rules', [

View File

@@ -546,6 +546,7 @@ function handleAction(array $request): array
'updates.list' => updatesList($params), 'updates.list' => updatesList($params),
'updates.run' => updatesRun($params), 'updates.run' => updatesRun($params),
'waf.apply' => wafApplySettings($params), 'waf.apply' => wafApplySettings($params),
'waf.audit_log' => wafAuditLogList($params),
'geo.apply_rules' => geoApplyRules($params), 'geo.apply_rules' => geoApplyRules($params),
'geo.update_database' => geoUpdateDatabase($params), 'geo.update_database' => geoUpdateDatabase($params),
'geo.upload_database' => geoUploadDatabase($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 ============ // ============ USER MANAGEMENT ============
function createUser(array $params): array function createUser(array $params): array

View File

@@ -17,5 +17,15 @@
</div> </div>
</div> </div>
<div class="mt-8">
<x-filament::section icon="heroicon-o-document-text" icon-color="primary">
<x-slot name="heading">{{ __('Blocked Requests') }}</x-slot>
<x-slot name="description">
{{ __('Recent ModSecurity denials from the audit log. Use whitelist to allow trusted traffic.') }}
</x-slot>
{{ $this->table }}
</x-filament::section>
</div>
<x-filament-actions::modals /> <x-filament-actions::modals />
</x-filament-panels::page> </x-filament-panels::page>