Add WAF audit log table with whitelist actions
This commit is contained in:
@@ -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(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user